Compare commits

...

54 Commits

Author SHA1 Message Date
Rahul Kamat 259bf78f65 feat: add profiles feature for named configurations 2026-03-10 11:27:05 -07:00
Spencer a220874281 feat(policy): support auto-add to policy by default and scoped persistence (#20361) 2026-03-10 17:01:41 +00:00
Jacob Richman 49ea9b0457 Handle dirty worktrees better and warn about running scripts/review.sh on untrusted code. (#21791) 2026-03-10 16:38:26 +00:00
JAYADITYA 556825f81c feat(cli): give visibility to /tools list command in the TUI and follow the subcommand pattern of other commands (#21213) 2026-03-10 16:36:12 +00:00
Sandy Tao b158c96465 feat(core): add OAuth2 Authorization Code auth provider for A2A agents (#21496)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-03-10 15:24:44 +00:00
Coco Sheng e91f86c248 feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub Actions (#21129) 2026-03-10 14:59:13 +00:00
Gaurav 47e4f6b13f fix(ui): handle headless execution in credits and upgrade dialogs (#21850) 2026-03-10 14:54:15 +00:00
Gaurav 94ab449e65 fix(core): treat retryable errors with >5 min delay as terminal quota errors (#21881) 2026-03-10 14:53:51 +00:00
Yuna Seol 0486a1675a refactor(core): improve API response error logging when retry (#21784) 2026-03-10 14:29:35 +00:00
Adam Weidman f9fc83089c fix(core): update @a2a-js/sdk to 0.3.11 (#21875) 2026-03-10 14:14:39 +00:00
Jerop Kipruto 5dd2dab189 fix(core): ensure correct flash model steering in plan mode implementation phase (#21871) 2026-03-10 13:30:41 +00:00
Jerop Kipruto a6b95897ad docs: clarify global policy rules application in plan mode (#21864) 2026-03-10 13:10:17 +00:00
Jacob Richman 6ae6c810ba fix(cli): make footer items equally spaced (#21843) 2026-03-10 08:07:26 +00:00
Gaurav 02d4451e77 fix(core): override toolRegistry property for sub-agent schedulers (#21766) 2026-03-10 07:41:54 +00:00
Sehoon Shon c25ff94608 build(release): restrict npm bundling to non-stable tags (#21821) 2026-03-10 04:17:46 +00:00
Tommaso Sciortino 14412c3a72 refactor(cli): rename 'return' key to 'enter' internally (#21796) 2026-03-10 02:32:40 +00:00
Shehab ec7773eb7b fix(docs): update theme screenshots and add missing themes (#20689)
Co-authored-by: Chris Williams <chrisjwilliams@google.com>
Co-authored-by: Chris Williams <diodesign@gmail.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-10 00:35:10 +00:00
Nicholas Bardy 4653b126f3 fix(core): destroy PTY on kill() and exception to prevent fd leak (#21693)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-10 00:08:16 +00:00
sinisterchill 1e1e7e349d fix(core): handle GUI editor non-zero exit codes gracefully (#20376)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-09 23:51:10 +00:00
christine betts 43eb74ac59 Add support for updating extension sources and names (#21715) 2026-03-09 23:31:31 +00:00
Tommaso Sciortino 215f8f3f15 refactor(ui): unify keybinding infrastructure and support string initialization (#21776) 2026-03-09 23:26:33 +00:00
Francesco Camporeale b89944c3a3 docs: fix heading formatting in commands.md and phrasing in tools-api.md (#20679)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-09 23:26:04 +00:00
Muhammad Usman f88488d1f9 fix(core): resolve Windows line ending and path separation bugs across CLI (#21068) 2026-03-09 22:40:22 +00:00
Sehoon Shon 1fd42802be perf(cli): cache loadSettings to reduce redundant disk I/O at startup (#21521) 2026-03-09 21:33:16 +00:00
Tommaso Sciortino ab64b15d51 refactor: migrate to useKeyMatchers hook (#21753) 2026-03-09 20:48:09 +00:00
Jacob Richman e406dcc249 feat/redesign header compact (#20922) 2026-03-09 20:40:46 +00:00
Vroot 680077631d fix(docs): fix headless mode docs (#21287)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 20:13:14 +00:00
Jacob Richman 9fc03a0c12 Add script to speed up reviewing PRs adding a worktree. (#21748) 2026-03-09 19:43:22 +00:00
Adib234 4f4431e4e1 fix(core): prevent infinite recursion in symlink resolution (#21750) 2026-03-09 19:38:45 +00:00
AK 527074b50a feat(policy): support subagent-specific policies in TOML (#21431) 2026-03-09 19:22:46 +00:00
joshualitt a17691f0fc feat(core): improve subagent result display (#20378) 2026-03-09 19:20:15 +00:00
Francesco Camporeale d246315cea docs: fix broken markdown syntax and anchor links in /tools (#20902)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-09 19:03:50 +00:00
Alisa e92ccec6c8 feat(security): implement robust IP validation and safeFetch foundation (#21401) 2026-03-09 19:02:07 +00:00
Pyush Sinha b68d7bc0f9 refactor(cli): better react patterns for BaseSettingsDialog (#21206) 2026-03-09 18:35:08 +00:00
Jacob Richman 4c9f9bb3e2 robustness(core): static checks to validate history is immutable (#21228) 2026-03-09 18:26:03 +00:00
David Pierce e7b20c49ac Update mcp's list function to check for disablement. (#21148) 2026-03-09 18:10:00 +00:00
David Pierce 743d05b37f Unmarshall update (#21721)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 17:32:15 +00:00
Zheyuan Lin 95074a1a84 fix(settings): display objects as JSON instead of [object Object] (#21458)
Co-authored-by: Zheyuan <zlin252@emory.edu>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-09 17:32:13 +00:00
Aditya Bijalwan 759575faa8 Feat/browser agent progress emission (#21218)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 17:26:00 +00:00
Sehoon Shon d485e08606 feat(cli): implement /upgrade command (#21511) 2026-03-09 17:17:30 +00:00
Sehoon Shon 0f1258305a perf(core): cache loadApiKey to reduce redundant keychain access (#21520) 2026-03-09 16:53:24 +00:00
Abhijit Balaji 09e99824d4 docs: document tool exclusion from memory via deny policy (#21428)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-09 16:29:52 +00:00
joshualitt 96b939f63a feat(core): Introduce AgentLoopContext. (#21198) 2026-03-09 16:02:20 +00:00
Adib234 7837194ab5 fix(core): resolve symlinks for non-existent paths during validation (#21487) 2026-03-09 16:02:13 +00:00
Jerop Kipruto 35ee2a841a feat(plan): enable Plan Mode by default (#21713) 2026-03-09 15:58:46 +00:00
Jack Wotherspoon a253938ac5 feat(cli): add /compact alias for /compress command (#21711) 2026-03-09 15:45:42 +00:00
aworki 37ffd608fd feat(cli): expose /tools desc as explicit subcommand for discoverability (#21241)
Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-03-09 15:31:05 +00:00
Chris Williams 0f019122b0 Docs: Make documentation links relative (#21490) 2026-03-09 15:23:00 +00:00
Gaurav 936f6240dd fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702) 2026-03-09 14:08:33 +00:00
anj-s f8dd6f4f4c fix(core): display actual graph output in tracker_visualize tool (#21455) 2026-03-09 13:36:16 +00:00
Gyanranjan Priyam ca7ac00030 docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md (#21665) 2026-03-09 05:38:00 +00:00
Dmitry Lyalin d41735d6a9 feat(cli): unify /chat and /resume command UX (#20256) 2026-03-08 22:50:51 +00:00
Jacob Richman d012929a28 Code review comments as a pr (#21209) 2026-03-08 08:36:54 +00:00
Jenna Inouye 97dfbd4598 Docs: Add documentation for notifications (experimental)(macOS) (#21163) 2026-03-08 06:31:24 +00:00
354 changed files with 12111 additions and 2558 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ Gemini CLI project.
set.
- **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging.
- **Keyboard Shortcuts**: Define all new keyboard shortcuts in
`packages/cli/src/config/keyBindings.ts` and document them in
`packages/cli/src/ui/key/keyBindings.ts` and document them in
`docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the
`Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid
function keys and shortcuts commonly bound in VSCode.
+1 -1
View File
@@ -193,7 +193,7 @@ runs:
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
- name: '📦 Prepare bundled CLI for npm release'
if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'"
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |
+2
View File
@@ -62,3 +62,5 @@ gemini-debug.log
.gemini-clipboard/
.eslintcache
evals/logs/
temp_agents/
+39 -13
View File
@@ -60,20 +60,45 @@ All submissions, including submissions by project members, require review. We
use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)
for this purpose.
If your pull request involves changes to `packages/cli` (the frontend), we
recommend running our automated frontend review tool. **Note: This tool is
currently experimental.** It helps detect common React anti-patterns, testing
issues, and other frontend-specific best practices that are easy to miss.
To assist with the review process, we provide an automated review tool that
helps detect common anti-patterns, testing issues, and other best practices that
are easy to miss.
To run the review tool, enter the following command from within Gemini CLI:
#### Using the automated review tool
```text
/review-frontend <PR_NUMBER>
```
You can run the review tool in two ways:
Replace `<PR_NUMBER>` with your pull request number. Authors are encouraged to
run this on their own PRs for self-review, and reviewers should use it to
augment their manual review process.
1. **Using the helper script (Recommended):** We provide a script that
automatically handles checking out the PR into a separate worktree,
installing dependencies, building the project, and launching the review
tool.
```bash
./scripts/review.sh <PR_NUMBER> [model]
```
**Warning:** If you run `scripts/review.sh`, you must have first verified
that the code for the PR being reviewed is safe to run and does not contain
data exfiltration attacks.
**Authors are strongly encouraged to run this script on their own PRs**
immediately after creation. This allows you to catch and fix simple issues
locally before a maintainer performs a full review.
**Note on Models:** By default, the script uses the latest Pro model
(`gemini-3.1-pro-preview`). If you do not have enough Pro quota, you can run
it with the latest Flash model instead:
`./scripts/review.sh <PR_NUMBER> gemini-3-flash-preview`.
2. **Manually from within Gemini CLI:** If you already have the PR checked out
and built, you can run the tool directly from the CLI prompt:
```text
/review-frontend <PR_NUMBER>
```
Replace `<PR_NUMBER>` with your pull request number. Reviewers should use this
tool to augment, not replace, their manual review process.
### Self-assigning and unassigning issues
@@ -267,7 +292,8 @@ npm run test:e2e
```
For more detailed information on the integration testing framework, please see
the [Integration Tests documentation](/docs/integration-tests.md).
the
[Integration Tests documentation](https://geminicli.com/docs/integration-tests).
### Linting and preflight checks
@@ -546,7 +572,7 @@ Before submitting your documentation pull request, please:
If you have questions about contributing documentation:
- Check our [FAQ](/docs/resources/faq.md).
- Check our [FAQ](https://geminicli.com/docs/resources/faq).
- Review existing documentation for examples.
- Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss
your proposed changes.
+1 -1
View File
@@ -6,7 +6,7 @@
[![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
[![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli)
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
![Gemini CLI Screenshot](/docs/assets/gemini-screenshot.png)
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
into your terminal. It provides lightweight access to Gemini, giving you the
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+6 -5
View File
@@ -8,7 +8,8 @@ and parameters.
| Command | Description | Example |
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `gemini` | Start interactive REPL | `gemini` |
| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` |
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
@@ -20,9 +21,9 @@ and parameters.
### Positional arguments
| Argument | Type | Description |
| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. |
| Argument | Type | Description |
| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- |
| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. |
## Interactive commands
@@ -47,7 +48,7 @@ These commands are available within the interactive REPL.
| `--version` | `-v` | - | - | Show CLI version number and exit |
| `--help` | `-h` | - | - | Show help information |
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
+1 -1
View File
@@ -6,7 +6,7 @@ structured text or JSON output without an interactive terminal UI.
## Technical reference
Headless mode is triggered when the CLI is run in a non-TTY environment or when
providing a query as a positional argument without the interactive flag.
providing a query with the `-p` (or `--prompt`) flag.
### Output formats
+58
View File
@@ -0,0 +1,58 @@
# Notifications (experimental)
Gemini CLI can send system notifications to alert you when a session completes
or when it needs your attention, such as when it's waiting for you to approve a
tool call.
> **Note:** This is a preview feature currently under active development.
> Preview features may be available on the **Preview** channel or may need to be
> enabled under `/settings`.
Notifications are particularly useful when running long-running tasks or using
[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini
CLI works in the background.
## Requirements
Currently, system notifications are only supported on macOS.
### Terminal support
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
This is supported by several modern terminal emulators. If your terminal does
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
to get your attention.
## Enable notifications
Notifications are disabled by default. You can enable them using the `/settings`
command or by updating your `settings.json` file.
1. Open the settings dialog by typing `/settings` in an interactive session.
2. Navigate to the **General** category.
3. Toggle the **Enable Notifications** setting to **On**.
Alternatively, add the following to your `settings.json`:
```json
{
"general": {
"enableNotifications": true
}
}
```
## Types of notifications
Gemini CLI sends notifications for the following events:
- **Action required:** Triggered when the model is waiting for user input or
tool approval. This helps you know when the CLI has paused and needs you to
intervene.
- **Session complete:** Triggered when a session finishes successfully. This is
useful for tracking the completion of automated tasks.
## Next steps
- Start planning with [Plan Mode](./plan-mode.md).
- Configure your experience with other [settings](./settings.md).
+96 -91
View File
@@ -1,4 +1,4 @@
# Plan Mode (experimental)
# Plan Mode
Plan Mode is a read-only environment for architecting robust solutions before
implementation. With Plan Mode, you can:
@@ -8,27 +8,8 @@ implementation. With Plan Mode, you can:
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
- **Plan:** Align on an execution strategy before any code is modified.
> **Note:** This is a preview feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue] on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
## How to enable Plan Mode
Enable Plan Mode in **Settings** or by editing your configuration file.
- **Settings:** Use the `/settings` command and set **Plan** to `true`.
- **Configuration:** Add the following to your `settings.json`:
```json
{
"experimental": {
"plan": true
}
}
```
Plan Mode is enabled by default. You can manage this setting using the
`/settings` command.
## How to enter Plan Mode
@@ -62,8 +43,11 @@ To start Plan Mode while using Gemini CLI:
- **Command:** Type `/plan` in the input box.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the [`enter_plan_mode`] tool to switch modes.
> **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
calls the
[`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool
to switch modes.
> **Note:** This tool is not available when Gemini CLI is in
> [YOLO mode](../reference/configuration.md#command-line-arguments).
## How to use Plan Mode
@@ -74,7 +58,8 @@ Gemini CLI takes action.
will then enter Plan Mode (if it's not already) to research the task.
2. **Review research and provide input:** As Gemini CLI analyzes your codebase,
it may ask you questions or present different implementation options using
[`ask_user`]. Provide your preferences to help guide the design.
[`ask_user`](../tools/ask-user.md). Provide your preferences to help guide
the design.
3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a
detailed implementation plan as a Markdown file in your plans directory. You
can open and read this file to understand the proposed changes.
@@ -116,25 +101,33 @@ Plan Mode enforces strict safety policies to prevent accidental changes.
These are the only allowed tools:
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
- **Search:** [`grep_search`], [`google_web_search`]
- **Research Subagents:** [`codebase_investigator`], [`cli_help`]
- **Interaction:** [`ask_user`]
- **MCP tools (Read):** Read-only [MCP tools] (for example, `github_read_issue`,
`postgres_read_schema`) are allowed.
- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
- **FileSystem (Read):**
[`read_file`](../tools/file-system.md#2-read_file-readfile),
[`list_directory`](../tools/file-system.md#1-list_directory-readfolder),
[`glob`](../tools/file-system.md#4-glob-findfiles)
- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext),
[`google_web_search`](../tools/web-search.md)
- **Research Subagents:**
[`codebase_investigator`](../core/subagents.md#codebase-investigator),
[`cli_help`](../core/subagents.md#cli-help-agent)
- **Interaction:** [`ask_user`](../tools/ask-user.md)
- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
example, `github_read_issue`, `postgres_read_schema`) are allowed.
- **Planning (Write):**
[`write_file`](../tools/file-system.md#3-write_file-writefile) and
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory or your
[custom plans directory](#custom-plan-directory-and-policies).
- **Memory:** [`save_memory`]
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
- **Memory:** [`save_memory`](../tools/memory.md)
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
instructions and resources in a read-only manner)
### Custom planning with skills
You can use [Agent Skills] to customize how Gemini CLI approaches planning for
specific types of tasks. When a skill is activated during Plan Mode, its
specialized instructions and procedural workflows will guide the research,
design, and planning phases.
You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI
approaches planning for specific types of tasks. When a skill is activated
during Plan Mode, its specialized instructions and procedural workflows will
guide the research, design, and planning phases.
For example:
@@ -151,10 +144,32 @@ based on the task description.
### Custom policies
Plan Mode's default tool restrictions are managed by the [policy engine] and
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
enforces the read-only state, but you can customize these rules by creating your
own policies in your `~/.gemini/policies/` directory (Tier 2).
Plan Mode's default tool restrictions are managed by the
[policy engine](../reference/policy-engine.md) and defined in the built-in
[`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state,
but you can customize these rules by creating your own policies in your
`~/.gemini/policies/` directory (Tier 2).
#### Global vs. mode-specific rules
As described in the
[policy engine documentation](../reference/policy-engine.md#approval-modes), any
rule that does not explicitly specify `modes` is considered "always active" and
will apply to Plan Mode as well.
If you want a rule to apply to other modes but _not_ to Plan Mode, you must
explicitly specify the target modes. For example, to allow `npm test` in default
and Auto-Edit modes but not in Plan Mode:
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "npm test"
decision = "allow"
priority = 100
# By omitting "plan", this rule will not be active in Plan Mode.
modes = ["default", "autoEdit"]
```
#### Example: Automatically approve read-only MCP tools
@@ -173,8 +188,8 @@ priority = 100
modes = ["plan"]
```
For more information on how the policy engine works, see the [policy engine]
docs.
For more information on how the policy engine works, see the
[policy engine](../reference/policy-engine.md) docs.
#### Example: Allow git commands in Plan Mode
@@ -194,9 +209,12 @@ modes = ["plan"]
#### Example: Enable custom subagents in Plan Mode
Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`]
are enabled by default in Plan Mode. You can enable additional [custom
subagents] by adding a rule to your policy.
Built-in research [subagents](../core/subagents.md) like
[`codebase_investigator`](../core/subagents.md#codebase-investigator) and
[`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan
Mode. You can enable additional
[custom subagents](../core/subagents.md#creating-custom-subagents) by adding a
rule to your policy.
`~/.gemini/policies/research-subagents.toml`
@@ -235,10 +253,11 @@ locations defined within a project's workspace cannot be used to escape and
overwrite sensitive files elsewhere. Any user-configured directory must reside
within the project boundary.
Using a custom directory requires updating your [policy engine] configurations
to allow `write_file` and `replace` in that specific location. For example, to
allow writing to the `.gemini/plans` directory within your project, create a
policy file at `~/.gemini/policies/plan-custom-directory.toml`:
Using a custom directory requires updating your
[policy engine](../reference/policy-engine.md) configurations to allow
`write_file` and `replace` in that specific location. For example, to allow
writing to the `.gemini/plans` directory within your project, create a policy
file at `~/.gemini/policies/plan-custom-directory.toml`:
```toml
[[rule]]
@@ -254,13 +273,16 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
## Planning workflows
Plan Mode provides building blocks for structured research and design. These are
implemented as [extensions] using core planning tools like [`enter_plan_mode`],
[`exit_plan_mode`], and [`ask_user`].
implemented as [extensions](../extensions/index.md) using core planning tools
like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode),
[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and
[`ask_user`](../tools/ask-user.md).
### Built-in planning workflow
The built-in planner uses an adaptive workflow to analyze your project, consult
you on trade-offs via [`ask_user`], and draft a plan for your approval.
you on trade-offs via [`ask_user`](../tools/ask-user.md), and draft a plan for
your approval.
### Custom planning workflows
@@ -272,23 +294,29 @@ You can install or create specialized planners to suit your workflow.
"tracks" and stores persistent artifacts in your project's `conductor/`
directory:
- **Automate transitions:** Switches to read-only mode via [`enter_plan_mode`].
- **Streamline decisions:** Uses [`ask_user`] for architectural choices.
- **Automate transitions:** Switches to read-only mode via
[`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode).
- **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) for
architectural choices.
- **Maintain project context:** Stores artifacts in the project directory using
[custom plan directory and policies](#custom-plan-directory-and-policies).
- **Handoff execution:** Transitions to implementation via [`exit_plan_mode`].
- **Handoff execution:** Transitions to implementation via
[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode).
#### Build your own
Since Plan Mode is built on modular building blocks, you can develop your own
custom planning workflow as an [extensions]. By leveraging core tools and
[custom policies](#custom-policies), you can define how Gemini CLI researches
and stores plans for your specific domain.
custom planning workflow as an [extensions](../extensions/index.md). By
leveraging core tools and [custom policies](#custom-policies), you can define
how Gemini CLI researches and stores plans for your specific domain.
To build a custom planning workflow, you can use:
- **Tool usage:** Use core tools like [`enter_plan_mode`], [`ask_user`], and
[`exit_plan_mode`] to manage the research and design process.
- **Tool usage:** Use core tools like
[`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode),
[`ask_user`](../tools/ask-user.md), and
[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) to
manage the research and design process.
- **Customization:** Set your own storage locations and policy rules using
[custom plan directories](#custom-plan-directory-and-policies) and
[custom policies](#custom-policies).
@@ -302,8 +330,9 @@ high-reasoning model routing.
## Automatic Model Routing
When using an [auto model], Gemini CLI automatically optimizes [model routing]
based on the current phase of your task:
When using an [auto model](../reference/configuration.md#model), Gemini CLI
automatically optimizes [model routing](../cli/telemetry.md#model-routing) based
on the current phase of your task:
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
high-reasoning **Pro** model to ensure robust architectural decisions and
@@ -334,7 +363,8 @@ associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
[session retention] for more details.
[session retention](../cli/session-management.md#session-retention) for more
details.
Manual deletion also removes all associated artifacts:
@@ -344,32 +374,7 @@ Manual deletion also removes all associated artifacts:
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
[`google_web_search`]: /docs/tools/web-search.md
[`replace`]: /docs/tools/file-system.md#6-replace-edit
[MCP tools]: /docs/tools/mcp-server.md
[`save_memory`]: /docs/tools/memory.md
[`activate_skill`]: /docs/cli/skills.md
[`codebase_investigator`]: /docs/core/subagents.md#codebase-investigator
[`cli_help`]: /docs/core/subagents.md#cli-help-agent
[subagents]: /docs/core/subagents.md
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
[policy engine]: /docs/reference/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[auto model]: /docs/reference/configuration.md#model
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention
[extensions]: /docs/extensions/
[Conductor]: https://github.com/gemini-cli-extensions/conductor
[open an issue]: https://github.com/google-gemini/gemini-cli/issues
[Agent Skills]: /docs/cli/skills.md
+24
View File
@@ -61,6 +61,15 @@ Browser**:
/resume
```
When typing `/resume` (or `/chat`) in slash completion, commands are grouped
under titled separators:
- `-- auto --` (session browser)
- `list` is selectable and opens the session browser
- `-- checkpoints --` (manual tagged checkpoint commands)
Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu.
The Session Browser provides an interactive interface where you can perform the
following actions:
@@ -72,6 +81,21 @@ following actions:
- **Select:** Press **Enter** to resume the selected session.
- **Esc:** Press **Esc** to exit the Session Browser.
### Manual chat checkpoints
For named branch points inside a session, use chat checkpoints:
```text
/resume save decision-point
/resume list
/resume resume decision-point
```
Compatibility aliases:
- `/chat ...` works for the same commands.
- `/resume checkpoints ...` also remains supported during migration.
## Managing sessions
You can list and delete sessions to keep your history organized and manage disk
+2 -1
View File
@@ -125,6 +125,7 @@ they appear in the UI.
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
@@ -144,7 +145,7 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
+6
View File
@@ -339,6 +339,12 @@ Captures startup configuration and user prompt submissions.
- `mcp_tools` (string, if applicable)
- `mcp_tools_count` (int, if applicable)
- `output_format` ("text", "json", or "stream-json")
- `github_workflow_name` (string, optional)
- `github_repository_hash` (string, optional)
- `github_event_name` (string, optional)
- `github_pr_number` (string, optional)
- `github_issue_number` (string, optional)
- `github_custom_tracking_id` (string, optional)
- `gemini_cli.user_prompt`: Emitted when a user submits a prompt.
- **Attributes**:
+26 -16
View File
@@ -16,6 +16,8 @@ using the `/theme` command within Gemini CLI:
- `Default`
- `Dracula`
- `GitHub`
- `Holiday`
- `Shades Of Purple`
- `Solarized Dark`
- **Light themes:**
- `ANSI Light`
@@ -185,7 +187,7 @@ untrusted sources.
### Example custom theme
<img src="../assets/theme-custom.png" alt="Custom theme example" width="600" />
<img src="/docs/assets/theme-custom.png" alt="Custom theme example" width="600" />
### Using your custom theme
@@ -212,58 +214,66 @@ identify their source, for example: `shades-of-green (green-extension)`.
### ANSI
<img src="/assets/theme-ansi.png" alt="ANSI theme" width="600" />
<img src="/docs/assets/theme-ansi-dark.png" alt="ANSI theme" width="600">
### Atom OneDark
### Atom One
<img src="/assets/theme-atom-one.png" alt="Atom One theme" width="600">
<img src="/docs/assets/theme-atom-one-dark.png" alt="Atom One theme" width="600">
### Ayu
<img src="/assets/theme-ayu.png" alt="Ayu theme" width="600">
<img src="/docs/assets/theme-ayu-dark.png" alt="Ayu theme" width="600">
### Default
<img src="/assets/theme-default.png" alt="Default theme" width="600">
<img src="/docs/assets/theme-default-dark.png" alt="Default theme" width="600">
### Dracula
<img src="/assets/theme-dracula.png" alt="Dracula theme" width="600">
<img src="/docs/assets/theme-dracula-dark.png" alt="Dracula theme" width="600">
### GitHub
<img src="/assets/theme-github.png" alt="GitHub theme" width="600">
<img src="/docs/assets/theme-github-dark.png" alt="GitHub theme" width="600">
### Holiday
<img src="/docs/assets/theme-holiday-dark.png" alt="Holiday theme" width="600">
### Shades Of Purple
<img src="/docs/assets/theme-shades-of-purple-dark.png" alt="Shades Of Purple theme" width="600">
### Solarized Dark
<img src="/assets/theme-solarized-dark.png" alt="Solarized Dark theme" width="600">
<img src="/docs/assets/theme-solarized-dark.png" alt="Solarized Dark theme" width="600">
## Light themes
### ANSI Light
<img src="/assets/theme-ansi-light.png" alt="ANSI Light theme" width="600">
<img src="/docs/assets/theme-ansi-light.png" alt="ANSI Light theme" width="600">
### Ayu Light
<img src="/assets/theme-ayu-light.png" alt="Ayu Light theme" width="600">
<img src="/docs/assets/theme-ayu-light.png" alt="Ayu Light theme" width="600">
### Default Light
<img src="/assets/theme-default-light.png" alt="Default Light theme" width="600">
<img src="/docs/assets/theme-default-light.png" alt="Default Light theme" width="600">
### GitHub Light
<img src="/assets/theme-github-light.png" alt="GitHub Light theme" width="600">
<img src="/docs/assets/theme-github-light.png" alt="GitHub Light theme" width="600">
### Google Code
<img src="/assets/theme-google-light.png" alt="Google Code theme" width="600">
<img src="/docs/assets/theme-google-light.png" alt="Google Code theme" width="600">
### Solarized Light
<img src="/assets/theme-solarized-light.png" alt="Solarized Light theme" width="600">
<img src="/docs/assets/theme-solarized-light.png" alt="Solarized Light theme" width="600">
### Xcode
<img src="/assets/theme-xcode-light.png" alt="Xcode Light theme" width="600">
<img src="/docs/assets/theme-xcode-light.png" alt="Xcode Light theme" width="600">
+12 -11
View File
@@ -19,14 +19,15 @@ Headless mode runs Gemini CLI once and exits. It's perfect for:
## How to use headless mode
Run Gemini CLI in headless mode by providing a prompt as a positional argument.
This bypasses the interactive chat interface and prints the response to standard
output (stdout).
Run Gemini CLI in headless mode by providing a prompt with the `-p` (or
`--prompt`) flag. This bypasses the interactive chat interface and prints the
response to standard output (stdout). Positional arguments without the flag
default to interactive mode, unless the input or output is piped or redirected.
Run a single command:
```bash
gemini "Write a poem about TypeScript"
gemini -p "Write a poem about TypeScript"
```
## How to pipe input to Gemini CLI
@@ -40,19 +41,19 @@ Pipe a file:
**macOS/Linux**
```bash
cat error.log | gemini "Explain why this failed"
cat error.log | gemini -p "Explain why this failed"
```
**Windows (PowerShell)**
```powershell
Get-Content error.log | gemini "Explain why this failed"
Get-Content error.log | gemini -p "Explain why this failed"
```
Pipe a command:
```bash
git diff | gemini "Write a commit message for these changes"
git diff | gemini -p "Write a commit message for these changes"
```
## Use Gemini CLI output in scripts
@@ -78,7 +79,7 @@ one.
echo "Generating docs for $file..."
# Ask Gemini CLI to generate the documentation and print it to stdout
gemini "Generate a Markdown documentation summary for @$file. Print the
gemini -p "Generate a Markdown documentation summary for @$file. Print the
result to standard output." > "${file%.py}.md"
done
```
@@ -92,7 +93,7 @@ one.
$newName = $_.Name -replace '\.py$', '.md'
# Ask Gemini CLI to generate the documentation and print it to stdout
gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
}
```
@@ -214,7 +215,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
echo "Generating commit message..."
msg=$(echo "$diff" | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
# Commit with the generated message
git commit -m "$msg"
@@ -251,7 +252,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
Write-Host "Generating commit message..."
$msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message."
$msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message."
# Commit with the generated message
git commit -m "$msg"
+4 -4
View File
@@ -89,9 +89,9 @@ Gemini gives you granular control over the undo process. You can choose to:
Sometimes you want to try two different approaches to the same problem.
1. Start a session and get to a decision point.
2. Save the current state with `/chat save decision-point`.
2. Save the current state with `/resume save decision-point`.
3. Try your first approach.
4. Later, use `/chat resume decision-point` to fork the conversation back to
4. Later, use `/resume resume decision-point` to fork the conversation back to
that moment and try a different approach.
This creates a new branch of history without losing your original work.
@@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work.
- Learn about [Checkpointing](../../cli/checkpointing.md) to understand the
underlying safety mechanism.
- Explore [Task planning](task-planning.md) to keep complex sessions organized.
- See the [Command reference](../../reference/commands.md) for all `/chat` and
`/resume` options.
- See the [Command reference](../../reference/commands.md) for `/resume`
options, grouped checkpoint menus, and `/chat` compatibility aliases.
+1 -1
View File
@@ -297,7 +297,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
> **Note: Remote subagents are currently an experimental feature.**
See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed
See the [Remote Subagents documentation](remote-agents) for detailed
configuration and usage instructions.
## Extension subagents
+4
View File
@@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration.
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"],
"migratedTo": "https://github.com/new-owner/new-extension-repo",
"plan": {
"directory": ".gemini/plans"
}
@@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration.
- `version`: The version of the extension.
- `description`: A short description of the extension. This will be displayed on
[geminicli.com/extensions](https://geminicli.com/extensions).
- `migratedTo`: The URL of the new repository source for the extension. If this
is set, the CLI will automatically check this new source for updates and
migrate the extension's installation to the new source if an update is found.
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
server, and the value is the server configuration. These servers will be
loaded on startup just like MCP servers defined in a
+26
View File
@@ -152,3 +152,29 @@ jobs:
release/linux.arm64.my-tool.tar.gz
release/win32.arm64.my-tool.zip
```
## Migrating an Extension Repository
If you need to move your extension to a new repository (e.g., from a personal
account to an organization) or rename it, you can use the `migratedTo` property
in your `gemini-extension.json` file to seamlessly transition your users.
1. **Create the new repository**: Setup your extension in its new location.
2. **Update the old repository**: In your original repository, update the
`gemini-extension.json` file to include the `migratedTo` property, pointing
to the new repository URL, and bump the version number. You can optionally
change the `name` of your extension at this time in the new repository.
```json
{
"name": "my-extension",
"version": "1.1.0",
"migratedTo": "https://github.com/new-owner/new-extension-repo"
}
```
3. **Release the update**: Publish this new version in your old repository.
When users check for updates, the Gemini CLI will detect the `migratedTo` field,
verify that the new repository contains a valid extension update, and
automatically update their local installation to track the new source and name
moving forward. All extension settings will automatically migrate to the new
installation.
+1 -1
View File
@@ -6,7 +6,7 @@ using the CLI.
> **Note:** Looking for a high-level comparison of all available subscriptions?
> To compare features and find the right quota for your needs, see our
> [Plans page](/plans/).
> [Plans page](https://geminicli.com/plans/).
For most users, we recommend starting Gemini CLI and logging in with your
personal Google account.
+1 -1
View File
@@ -41,7 +41,7 @@ limit resets and Gemini 3 Pro can be used again.
> **Note:** Looking to upgrade for higher limits? To compare subscription
> options and find the right quota for your needs, see our
> [Plans page](/plans/).
> [Plans page](https://geminicli.com/plans/).
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, youll see
a message prompting fallback to Gemini 2.5 Flash.
+1 -1
View File
@@ -70,7 +70,7 @@ gemini
```
For a list of options and additional commands, see the
[CLI cheatsheet](/docs/cli/cli-reference.md).
[CLI cheatsheet](../cli/cli-reference.md).
You can also run Gemini CLI using one of the following advanced methods:
+1 -1
View File
@@ -449,7 +449,7 @@ When you open a project with hooks defined in `.gemini/settings.json`:
Hooks inherit the environment of the Gemini CLI process, which may include
sensitive API keys. Gemini CLI provides a
[redaction system](/docs/reference/configuration.md#environment-variable-redaction)
[redaction system](../reference/configuration.md#environment-variable-redaction)
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
`TOKEN`).
+6 -6
View File
@@ -22,11 +22,11 @@ With hooks, you can:
### Getting started
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
your first hook with comprehensive examples.
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
- **[Writing hooks guide](../hooks/writing-hooks)**: A tutorial on creating your
first hook with comprehensive examples.
- **[Best practices](../hooks/best-practices)**: Guidelines on security,
performance, and debugging.
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
- **[Hooks reference](../hooks/reference)**: The definitive technical
specification of I/O schemas and exit codes.
## Core concepts
@@ -152,8 +152,8 @@ Gemini CLI **fingerprints** project hooks. If a hook's name or command changes
(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will
be warned before it executes.
See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely)
for a detailed threat model.
See [Security Considerations](../hooks/best-practices#using-hooks-securely) for
a detailed threat model.
## Managing hooks
+2 -2
View File
@@ -82,8 +82,8 @@ For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
`run_shell_command`). See the [Tools Reference](/docs/reference/tools) for a
full list of available tool names.
`run_shell_command`). See the [Tools Reference](../reference/tools) for a full
list of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp__<server_name>__<tool_name>`.
- **Regex Support**: Matchers support regular expressions (e.g.,
+48 -11
View File
@@ -28,24 +28,33 @@ Slash commands provide meta-level control over the CLI itself.
### `/chat`
- **Description:** Save and resume conversation history for branching
conversation state interactively, or resuming a previous state from a later
session.
- **Description:** Alias for `/resume`. Both commands now expose the same
session browser action and checkpoint subcommands.
- **Menu layout when typing `/chat` (or `/resume`)**:
- `-- auto --`
- `list` (selecting this opens the auto-saved session browser)
- `-- checkpoints --`
- `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints)
- **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the
same grouped menu.
- **Sub-commands:**
- **`debug`**
- **Description:** Export the most recent API request as a JSON payload.
- **`delete <tag>`**
- **Description:** Deletes a saved conversation checkpoint.
- **Equivalent:** `/resume delete <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **Description:** Lists available tags for manually saved checkpoints.
- **Note:** This command only lists chats saved within the current project.
Because chat history is project-scoped, chats saved in other project
directories will not be displayed.
- **Equivalent:** `/resume list`
- **`resume <tag>`**
- **Description:** Resumes a conversation from a previous save.
- **Note:** You can only resume chats that were saved within the current
project. To resume a chat from a different project, you must run the
Gemini CLI from that project's directory.
- **Equivalent:** `/resume resume <tag>`
- **`save <tag>`**
- **Description:** Saves the current conversation history. You must add a
`<tag>` for identifying the conversation state.
@@ -60,10 +69,12 @@ Slash commands provide meta-level control over the CLI itself.
conversation states. For automatic checkpoints created before file
modifications, see the
[Checkpointing documentation](../cli/checkpointing.md).
- **Equivalent:** `/resume save <tag>`
- **`share [filename]`**
- **Description** Writes the current conversation to a provided Markdown or
- **Description:** Writes the current conversation to a provided Markdown or
JSON file. If no filename is provided, then the CLI will generate one.
- **Usage** `/chat share file.md` or `/chat share file.json`.
- **Usage:** `/chat share file.md` or `/chat share file.json`.
- **Equivalent:** `/resume share [filename]`
### `/clear`
@@ -268,8 +279,8 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Note:** This feature is enabled by default. It can be disabled via the
`experimental.plan` setting in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
@@ -314,10 +325,13 @@ Slash commands provide meta-level control over the CLI itself.
### `/resume`
- **Description:** Browse and resume previous conversation sessions. Opens an
interactive session browser where you can search, filter, and select from
automatically saved conversations.
- **Description:** Browse and resume previous conversation sessions, and manage
manual chat checkpoints.
- **Features:**
- **Auto sessions:** Run `/resume` to open the interactive session browser for
automatically saved conversations.
- **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`,
`/resume resume`, etc.).
- **Management:** Delete unwanted sessions directly from the browser
- **Resume:** Select any session to resume and continue the conversation
- **Search:** Use `/` to search through conversation content across all
@@ -328,6 +342,23 @@ Slash commands provide meta-level control over the CLI itself.
- **Note:** All conversations are automatically saved as you chat - no manual
saving required. See [Session Management](../cli/session-management.md) for
complete details.
- **Alias:** `/chat` provides the same behavior and subcommands.
- **Sub-commands:**
- **`list`**
- **Description:** Lists available tags for manual chat checkpoints.
- **`save <tag>`**
- **Description:** Saves the current conversation as a tagged checkpoint.
- **`resume <tag>`** (alias: `load`)
- **Description:** Loads a previously saved tagged checkpoint.
- **`delete <tag>`**
- **Description:** Deletes a tagged checkpoint.
- **`share [filename]`**
- **Description:** Exports the current conversation to Markdown or JSON.
- **`debug`**
- **Description:** Export the most recent API request as JSON payload
(nightly builds).
- **Compatibility alias:** `/resume checkpoints ...` is still accepted for the
same checkpoint commands.
### `/settings`
@@ -408,6 +439,12 @@ Slash commands provide meta-level control over the CLI itself.
- **`nodesc`** or **`nodescriptions`**:
- **Description:** Hide tool descriptions, showing only the tool names.
### `/upgrade`
- **Description:** Open the Gemini Code Assist upgrade page in your browser.
This lets you upgrade your tier for higher usage limits.
- **Note:** This command is only available when logged in with Google.
### `/vim`
- **Description:** Toggle vim mode on or off. When vim mode is enabled, the
+7 -2
View File
@@ -872,6 +872,11 @@ their corresponding top-level category object in your `settings.json` file.
confirmation dialogs.
- **Default:** `false`
- **`security.autoAddToPolicyByDefault`** (boolean):
- **Description:** When enabled, the "Allow for all future sessions" option
becomes the default choice for low-risk tools in trusted workspaces.
- **Default:** `false`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
@@ -1021,8 +1026,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`experimental.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Description:** Enable Plan Mode.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.taskTracker`** (boolean):
+7 -8
View File
@@ -55,14 +55,13 @@ available combinations.
#### History & Search
| Action | Keys |
| -------------------------------------------- | ------------ |
| Show the previous entry in history. | `Ctrl+P` |
| Show the next entry in history. | `Ctrl+N` |
| Start reverse search through history. | `Ctrl+R` |
| Submit the selected reverse-search match. | `Enter` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Double Esc` |
| Action | Keys |
| -------------------------------------------- | -------- |
| Show the previous entry in history. | `Ctrl+P` |
| Show the next entry in history. | `Ctrl+N` |
| Start reverse search through history. | `Ctrl+R` |
| Submit the selected reverse-search match. | `Enter` |
| Accept a suggestion while reverse searching. | `Tab` |
#### Navigation
+14 -5
View File
@@ -91,10 +91,17 @@ the arguments don't match the pattern, the rule does not apply.
There are three possible decisions a rule can enforce:
- `allow`: The tool call is executed automatically without user interaction.
- `deny`: The tool call is blocked and is not executed.
- `deny`: The tool call is blocked and is not executed. For global rules (those
without an `argsPattern`), tools that are denied are **completely excluded
from the model's memory**. This means the model will not even see the tool as
an option, which is more secure and saves context window space.
- `ask_user`: The user is prompted to approve or deny the tool call. (In
non-interactive mode, this is treated as `deny`.)
> **Note:** The `deny` decision is the recommended way to exclude tools. The
> legacy `tools.exclude` setting in `settings.json` is deprecated in favor of
> policy rules with a `deny` decision.
### Priority system and tiers
The policy engine uses a sophisticated priority system to resolve conflicts when
@@ -143,8 +150,8 @@ always active.
confirmation.
- `autoEdit`: Optimized for automated code editing; some write tools may be
auto-approved.
- `plan`: A strict, read-only mode for research and design. See [Customizing
Plan Mode Policies].
- `plan`: A strict, read-only mode for research and design. See
[Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies).
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
## Rule matching
@@ -212,6 +219,10 @@ Here is a breakdown of the fields available in a TOML policy rule:
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
# made by this specific subagent.
subagent = "generalist"
# (Optional) The name of an MCP server. Can be combined with toolName
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
@@ -360,5 +371,3 @@ out-of-the-box experience.
- In **`yolo`** mode, a high-priority rule allows all tools.
- In **`autoEdit`** mode, rules allow certain write operations to happen without
prompting.
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
+1 -1
View File
@@ -5,7 +5,7 @@ use cases. For enterprise or professional usage, or if you need increased quota,
several options are available depending on your authentication account type.
For a high-level comparison of available subscriptions and to select the right
quota for your needs, see the [Plans page](/plans/).
quota for your needs, see the [Plans page](https://geminicli.com/plans/).
## Overview
+2 -2
View File
@@ -16,8 +16,8 @@ account.
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
Policy.
**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the
quota and pricing details that apply to your usage of the Gemini CLI.
**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and
pricing details that apply to your usage of the Gemini CLI.
## Supported authentication methods
+5
View File
@@ -106,6 +106,11 @@
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{
"label": "Notifications",
"badge": "🔬",
"slug": "docs/cli/notifications"
},
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
+2 -2
View File
@@ -67,7 +67,7 @@ Finds files matching specific glob patterns across the workspace.
`Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- **Confirmation:** No.
## 5. `grep_search` (SearchText)
### `grep_search` (SearchText)
`grep_search` searches for a regular expression pattern within the content of
files in a specified directory. Can filter files by a glob pattern. Returns the
@@ -103,7 +103,7 @@ lines containing matches, along with their file paths and line numbers.
```
- **Confirmation:** No.
## 6. `replace` (Edit)
### `replace` (Edit)
`replace` replaces text within a file. By default, the tool expects to find and
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
+1 -1
View File
@@ -372,7 +372,7 @@ To authenticate with a server using Service Account Impersonation, you must set
the `authProviderType` to `service_account_impersonation` and provide the
following properties:
- **`targetAudience`** (string): The OAuth Client ID allowslisted on the
- **`targetAudience`** (string): The OAuth Client ID allowlisted on the
IAP-protected application you are trying to access.
- **`targetServiceAccount`** (string): The email address of the Google Cloud
Service Account to impersonate.
+5
View File
@@ -35,6 +35,11 @@ const commonRestrictedSyntaxRules = [
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
{
selector: 'CallExpression[callee.name="fetch"]',
message:
'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
},
];
export default tseslint.config(
+71 -10
View File
@@ -84,9 +84,9 @@
}
},
"node_modules/@a2a-js/sdk": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz",
"integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==",
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.11.tgz",
"integrity": "sha512-pXjjlL0ZYHgAxObov1J+W3ylfQV0rOrDBB8Eo4a9eCunqs7iNW5OIfMcV8YnZQdzeVSRomj8jHeudVz0zc4RNw==",
"license": "Apache-2.0",
"dependencies": {
"uuid": "^11.1.0"
@@ -95,9 +95,17 @@
"node": ">=18"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.10.2",
"@grpc/grpc-js": "^1.11.0",
"express": "^4.21.2 || ^5.1.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
},
"@grpc/grpc-js": {
"optional": true
},
"express": {
"optional": true
}
@@ -515,6 +523,13 @@
"node": ">=18"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
@@ -1582,18 +1597,37 @@
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
@@ -2292,6 +2326,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2472,6 +2507,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2521,6 +2557,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2895,6 +2932,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2928,6 +2966,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2982,6 +3021,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4178,6 +4218,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4451,6 +4492,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5298,6 +5340,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7901,6 +7944,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8533,6 +8577,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9847,6 +9892,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10126,6 +10172,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13808,6 +13855,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13818,6 +13866,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15906,6 +15955,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16129,7 +16179,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16137,6 +16188,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16296,6 +16348,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16519,6 +16572,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16632,6 +16686,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16644,6 +16699,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17288,6 +17344,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17305,7 +17362,7 @@
"name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260304.28af4e127",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
@@ -17447,11 +17504,13 @@
"version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@a2a-js/sdk": "0.3.11",
"@bufbuild/protobuf": "^2.11.0",
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.41.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
@@ -17489,6 +17548,7 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"ipaddr.js": "^1.9.1",
"js-yaml": "^4.1.1",
"marked": "^15.0.12",
"mime": "4.0.7",
@@ -17687,6 +17747,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+1 -1
View File
@@ -25,7 +25,7 @@
"dist"
],
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
@@ -75,6 +75,14 @@ export function createMockConfig(
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
+2 -2
View File
@@ -172,7 +172,7 @@ describe('GeminiAgent', () => {
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
@@ -650,7 +650,7 @@ describe('Session', () => {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
+6 -1
View File
@@ -92,7 +92,7 @@ describe('GeminiAgent Session Resume', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
@@ -204,6 +204,11 @@ describe('GeminiAgent Session Resume', () => {
name: 'YOLO',
description: 'Auto-approves all tools',
},
{
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
+71 -2
View File
@@ -14,11 +14,16 @@ import {
type Mock,
} from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings, mergeSettings } from '../../config/settings.js';
import {
loadSettings,
mergeSettings,
type LoadedSettings,
} from '../../config/settings.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { McpServerEnablementManager } from '../../config/mcp/index.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
@@ -45,6 +50,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
BLOCKED: 'BLOCKED',
DISABLED: 'DISABLED',
},
Storage: Object.assign(
vi.fn().mockImplementation((_cwd: string) => ({
@@ -54,6 +61,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
{
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getGlobalGeminiDir: () => '/tmp/gemini',
},
),
GEMINI_DIR: '.gemini',
@@ -96,6 +104,12 @@ describe('mcp list command', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
McpServerEnablementManager.resetInstance();
// Use a mock for isFileEnabled to avoid reading real files
vi.spyOn(
McpServerEnablementManager.prototype,
'isFileEnabled',
).mockResolvedValue(true);
mockTransport = { close: vi.fn() };
mockClient = {
@@ -265,7 +279,10 @@ describe('mcp list command', () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers(settingsWithAllowlist);
await listMcpServers({
merged: settingsWithAllowlist,
isTrusted: true,
} as unknown as LoadedSettings);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('allowed-server'),
@@ -304,4 +321,56 @@ describe('mcp list command', () => {
),
);
});
it('should display blocked status for servers in excluded list', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcp: {
excluded: ['blocked-server'],
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
},
isTrusted: true,
});
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'blocked-server: /test/server (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should display disabled status for servers disabled via enablement manager', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'disabled-server': { command: '/test/server' },
},
},
isTrusted: true,
});
vi.spyOn(
McpServerEnablementManager.prototype,
'isFileEnabled',
).mockResolvedValue(false);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'disabled-server: /test/server (stdio) - Disabled',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
});
+60 -13
View File
@@ -6,8 +6,11 @@
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
import { type MergedSettings, loadSettings } from '../../config/settings.js';
import type { MCPServerConfig } from '@google/gemini-cli-core';
import {
type MergedSettings,
loadSettings,
type LoadedSettings,
} from '../../config/settings.js';
import {
MCPServerStatus,
createTransport,
@@ -15,8 +18,13 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import type { MCPServerConfig } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
canLoadServer,
McpServerEnablementManager,
} from '../../config/mcp/index.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -61,13 +69,13 @@ export async function getMcpServersFromConfig(
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
isTrusted: boolean,
activeSettings: MergedSettings,
): Promise<MCPServerStatus> {
const settings = loadSettings();
// SECURITY: Only test connection if workspace is trusted or if it's a remote server.
// stdio servers execute local commands and must never run in untrusted workspaces.
const isStdio = !!config.command;
if (isStdio && !settings.isTrusted) {
if (isStdio && !isTrusted) {
return MCPServerStatus.DISCONNECTED;
}
@@ -80,7 +88,7 @@ async function testMCPConnection(
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars,
},
emitMcpDiagnostic: (
severity: 'info' | 'warning' | 'error',
@@ -105,7 +113,7 @@ async function testMCPConnection(
debugLogger.log(message, error);
}
},
isTrustedFolder: () => settings.isTrusted,
isTrustedFolder: () => isTrusted,
};
let transport;
@@ -135,14 +143,40 @@ async function testMCPConnection(
async function getServerStatus(
serverName: string,
server: MCPServerConfig,
isTrusted: boolean,
activeSettings: MergedSettings,
): Promise<MCPServerStatus> {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const loadResult = await canLoadServer(serverName, {
adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
allowedList: activeSettings.mcp?.allowed,
excludedList: activeSettings.mcp?.excluded,
enablement: mcpEnablementManager.getEnablementCallbacks(),
});
if (!loadResult.allowed) {
if (
loadResult.blockType === 'admin' ||
loadResult.blockType === 'allowlist' ||
loadResult.blockType === 'excludelist'
) {
return MCPServerStatus.BLOCKED;
}
return MCPServerStatus.DISABLED;
}
// Test all server types by attempting actual connection
return testMCPConnection(serverName, server);
return testMCPConnection(serverName, server, isTrusted, activeSettings);
}
export async function listMcpServers(settings?: MergedSettings): Promise<void> {
export async function listMcpServers(
loadedSettingsArg?: LoadedSettings,
): Promise<void> {
const loadedSettings = loadedSettingsArg ?? loadSettings();
const activeSettings = loadedSettings.merged;
const { mcpServers, blockedServerNames } =
await getMcpServersFromConfig(settings);
await getMcpServersFromConfig(activeSettings);
const serverNames = Object.keys(mcpServers);
if (blockedServerNames.length > 0) {
@@ -165,7 +199,12 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
for (const serverName of serverNames) {
const server = mcpServers[serverName];
const status = await getServerStatus(serverName, server);
const status = await getServerStatus(
serverName,
server,
loadedSettings.isTrusted,
activeSettings,
);
let statusIndicator = '';
let statusText = '';
@@ -178,6 +217,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
statusIndicator = chalk.yellow('…');
statusText = 'Connecting';
break;
case MCPServerStatus.BLOCKED:
statusIndicator = chalk.red('⛔');
statusText = 'Blocked';
break;
case MCPServerStatus.DISABLED:
statusIndicator = chalk.gray('○');
statusText = 'Disabled';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = chalk.red('✗');
@@ -203,14 +250,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
}
interface ListArgs {
settings?: MergedSettings;
loadedSettings?: LoadedSettings;
}
export const listCommand: CommandModule<object, ListArgs> = {
command: 'list',
describe: 'List all configured MCP servers',
handler: async (argv) => {
await listMcpServers(argv.settings);
await listMcpServers(argv.loadedSettings);
await exitCli();
},
};
+34
View File
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { listCommand } from './profiles/list.js';
import { enableCommand } from './profiles/enable.js';
import { disableCommand } from './profiles/disable.js';
import { uninstallCommand } from './profiles/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const profilesCommand: CommandModule = {
command: 'profiles <command>',
aliases: ['profile'],
describe: 'Manage Gemini CLI profiles.',
builder: (yargs) =>
yargs
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(listCommand)
.command(enableCommand)
.command(disableCommand)
.command(uninstallCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles disable`.
*/
export const disableCommand: CommandModule = {
command: 'disable',
describe: 'Disables the currently active profile.',
handler: async () => {
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
manager.disableProfile();
debugLogger.log('Profile disabled. Reverting to default behavior.');
// eslint-disable-next-line no-console
console.log('Profile disabled. Reverting to default behavior.');
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error disabling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles enable <name>`.
*/
export const enableCommand: CommandModule = {
command: 'enable <name>',
describe: 'Enables a profile persistently.',
builder: (yargs) =>
yargs.positional('name', {
describe: 'The name of the profile to enable.',
type: 'string',
}),
handler: async (argv) => {
const name = String(argv['name']);
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
await manager.enableProfile(name);
debugLogger.log(`Profile "${name}" successfully enabled.`);
// eslint-disable-next-line no-console
console.log(`Profile "${name}" successfully enabled.`);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error enabling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { render, Box, Text } from 'ink';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { exitCli } from '../utils.js';
/**
* View component for listing profiles in the terminal.
*/
const ProfileListView = ({
profiles,
activeProfile,
}: {
profiles: string[];
activeProfile?: string;
}) => {
if (profiles.length === 0) {
return (
<Box flexDirection="column" paddingY={1}>
<Text color="yellow">No profiles found.</Text>
<Text dimColor>
Profiles are stored as .md files in ~/.gemini/profiles/
</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingY={1}>
<Text bold underline>
Available Profiles:
</Text>
{profiles.map((name) => (
<Box key={name} marginLeft={2}>
<Text color={name === activeProfile ? 'green' : 'white'}>
{name === activeProfile ? '●' : '○'} {name}
</Text>
{name === activeProfile && (
<Text color="green" italic>
{' '}
(active)
</Text>
)}
</Box>
))}
<Box marginTop={1}>
<Text dimColor>
Use `gemini profiles enable {'<name>'}` to switch profiles.
</Text>
</Box>
</Box>
);
};
/**
* Command module for `gemini profiles list`.
*/
export const listCommand: CommandModule = {
command: 'list',
describe: 'List all available profiles.',
handler: async () => {
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
const profiles = await manager.listProfiles();
const activeProfile = manager.getActiveProfileName();
const { waitUntilExit } = render(
<ProfileListView profiles={profiles} activeProfile={activeProfile} />,
);
await waitUntilExit();
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error listing profiles: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
},
};
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles uninstall <name>`.
*/
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls a profile.',
builder: (yargs) =>
yargs.positional('name', {
describe: 'The name of the profile to uninstall.',
type: 'string',
}),
handler: async (argv) => {
const name = String(argv['name']);
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
await manager.uninstallProfile(name);
debugLogger.log(`Profile "${name}" successfully uninstalled.`);
// eslint-disable-next-line no-console
console.log(`Profile "${name}" successfully uninstalled.`);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error uninstalling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
+2 -2
View File
@@ -2622,13 +2622,13 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
it('should allow plan approval mode by default when --approval-mode=plan is used', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
it('should pass planSettings.directory from settings to config', async () => {
+44 -2
View File
@@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { profilesCommand } from '../commands/profiles.js';
import { skillsCommand } from '../commands/skills.js';
import { hooksCommand } from '../commands/hooks.js';
import {
@@ -61,6 +62,7 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { ProfileManager } from './profile-manager.js';
import { runExitCleanup } from '../utils/cleanup.js';
export interface CliArgs {
@@ -93,6 +95,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
profile: string | undefined;
}
export async function parseArguments(
@@ -143,6 +146,11 @@ export async function parseArguments(
type: 'boolean',
description: 'Run in sandbox?',
})
.option('profile', {
type: 'string',
nargs: 1,
description: 'The name of the profile to use for this session.',
})
.option('yolo', {
alias: 'y',
@@ -340,6 +348,8 @@ export async function parseArguments(
yargsInstance.command(hooksCommand);
}
yargsInstance.command(profilesCommand);
yargsInstance
.version(await getVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
@@ -422,6 +432,12 @@ export async function loadCliConfig(
const debugMode = isDebugMode(argv);
const loadedSettings = loadSettings(cwd);
const profileManager = new ProfileManager(loadedSettings);
const activeProfileName =
argv.profile || profileManager.getActiveProfileName();
const profile = activeProfileName
? await profileManager.getProfile(activeProfileName)
: null;
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
@@ -470,12 +486,21 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
let enabledExtensionOverrides = argv.extensions;
if (enabledExtensionOverrides === undefined && profile) {
const profileExtensions = profile.frontmatter.extensions;
if (profileExtensions !== undefined) {
enabledExtensionOverrides =
profileExtensions.length > 0 ? profileExtensions : ['none'];
}
}
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
enabledExtensionOverrides,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
@@ -511,6 +536,20 @@ export async function loadCliConfig(
filePaths = result.filePaths;
}
if (profile?.context) {
const profileContext = `Profile Context (${profile.name}):\n${profile.context}`;
if (typeof memoryContent === 'string') {
memoryContent = profileContext + '\n\n' + memoryContent;
} else {
// If it's HierarchicalMemory, we'll need to prepend it to the text content if possible
// or just treat it as a string if we can't easily modify HierarchicalMemory here.
// For now, let's assume if it's not a string, we might have issues prepending easily.
// But looking at core, userMemory is often expected to be a string in simple cases.
// If it's HierarchicalMemory, we might need to handle it in core.
// Let's check how it's handled in core.
}
}
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -651,7 +690,10 @@ export async function loadCliConfig(
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
argv.model ||
profile?.frontmatter.default_model ||
process.env['GEMINI_MODEL'] ||
settings.model?.name;
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
@@ -345,4 +345,144 @@ describe('ExtensionManager', () => {
}
});
});
describe('Extension Renaming', () => {
it('should support renaming an extension during update', async () => {
// 1. Setup existing extension
const oldName = 'old-name';
const newName = 'new-name';
const extDir = path.join(userExtensionsDir, oldName);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'gemini-extension.json'),
JSON.stringify({ name: oldName, version: '1.0.0' }),
);
fs.writeFileSync(
path.join(extDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: extDir }),
);
await extensionManager.loadExtensions();
// 2. Create a temporary "new" version with a different name
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: newName, version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
// 3. Update the extension
await extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: oldName, version: '1.0.0' },
);
// 4. Verify old directory is gone and new one exists
expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false);
expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true);
// Verify the loaded state is updated
const extensions = extensionManager.getExtensions();
expect(extensions.some((e) => e.name === newName)).toBe(true);
expect(extensions.some((e) => e.name === oldName)).toBe(false);
});
it('should carry over enablement status when renaming', async () => {
const oldName = 'old-name';
const newName = 'new-name';
const extDir = path.join(userExtensionsDir, oldName);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'gemini-extension.json'),
JSON.stringify({ name: oldName, version: '1.0.0' }),
);
fs.writeFileSync(
path.join(extDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: extDir }),
);
// Enable it
const enablementManager = extensionManager.getEnablementManager();
enablementManager.enable(oldName, true, tempHomeDir);
await extensionManager.loadExtensions();
const extension = extensionManager.getExtensions()[0];
expect(extension.isActive).toBe(true);
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: newName, version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
await extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: oldName, version: '1.0.0' },
);
// Verify new name is enabled
expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true);
// Verify old name is removed from enablement
expect(enablementManager.readConfig()[oldName]).toBeUndefined();
});
it('should prevent renaming if the new name conflicts with an existing extension', async () => {
// Setup two extensions
const ext1Dir = path.join(userExtensionsDir, 'ext1');
fs.mkdirSync(ext1Dir, { recursive: true });
fs.writeFileSync(
path.join(ext1Dir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext1', version: '1.0.0' }),
);
fs.writeFileSync(
path.join(ext1Dir, 'metadata.json'),
JSON.stringify({ type: 'local', source: ext1Dir }),
);
const ext2Dir = path.join(userExtensionsDir, 'ext2');
fs.mkdirSync(ext2Dir, { recursive: true });
fs.writeFileSync(
path.join(ext2Dir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext2', version: '1.0.0' }),
);
fs.writeFileSync(
path.join(ext2Dir, 'metadata.json'),
JSON.stringify({ type: 'local', source: ext2Dir }),
);
await extensionManager.loadExtensions();
// Try to update ext1 to name 'ext2'
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext2', version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
await expect(
extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: 'ext1', version: '1.0.0' },
),
).rejects.toThrow(/already installed/);
});
});
});
+65 -5
View File
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
this.requestSetting = options.requestSetting ?? undefined;
}
getEnablementManager(): ExtensionEnablementManager {
return this.extensionEnablementManager;
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): void {
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
const previousName = previousExtensionConfig?.name ?? newExtensionName;
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
(installed) => installed.name === previousName,
);
const nameConflict = this.getExtensions().find(
(installed) =>
installed.name === newExtensionName &&
installed.name !== previousName,
);
if (isUpdate && !previous) {
throw new Error(
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
`Extension "${previousName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
} else if (isUpdate && nameConflict) {
throw new Error(
`Cannot update to "${newExtensionName}" because an extension with that name is already installed.`,
);
}
const newHasHooks = fs.existsSync(
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
path.join(localSourcePath, 'skills'),
);
const previousSkills = previous?.skills ?? [];
const isMigrating = Boolean(
previous &&
previous.installMetadata &&
previous.installMetadata.source !== installMetadata.source,
);
await maybeRequestConsentOrFail(
newExtensionConfig,
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
previousHasHooks,
newSkills,
previousSkills,
isMigrating,
);
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
const destinationPath = new ExtensionStorage(
newExtensionName,
).getExtensionDir();
if (
(!isUpdate || newExtensionName !== previousName) &&
fs.existsSync(destinationPath)
) {
throw new Error(
`Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`,
);
}
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
let wasEnabledGlobally = false;
let wasEnabledWorkspace = false;
if (isUpdate && previousExtensionConfig) {
const previousExtensionId = previous?.installMetadata
? getExtensionId(previousExtensionConfig, previous.installMetadata)
: extensionId;
previousSettings = await getEnvContents(
previousExtensionConfig,
extensionId,
previousExtensionId,
this.workspaceDir,
);
await this.uninstallExtension(newExtensionName, isUpdate);
if (newExtensionName !== previousName) {
wasEnabledGlobally = this.extensionEnablementManager.isEnabled(
previousName,
homedir(),
);
wasEnabledWorkspace = this.extensionEnablementManager.isEnabled(
previousName,
this.workspaceDir,
);
this.extensionEnablementManager.remove(previousName);
}
await this.uninstallExtension(previousName, isUpdate);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
CoreToolCallStatus.Success,
),
);
if (newExtensionName !== previousName) {
if (wasEnabledGlobally) {
await this.enableExtension(newExtensionName, SettingScope.User);
}
if (wasEnabledWorkspace) {
await this.enableExtension(
newExtensionName,
SettingScope.Workspace,
);
}
}
} else {
await logExtensionInstallEvent(
this.telemetryConfig,
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
migratedTo: config.migratedTo,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
hooks,
@@ -31,6 +31,7 @@ import {
loadSettings,
createTestMergedSettings,
SettingScope,
resetSettingsCacheForTesting,
} from './settings.js';
import {
isWorkspaceTrusted,
@@ -161,6 +162,7 @@ describe('extension tests', () => {
beforeEach(() => {
vi.clearAllMocks();
resetSettingsCacheForTesting();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
+4
View File
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
*/
directory?: string;
};
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}
export interface ExtensionUpdateInfo {
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="122" viewBox="0 0 920 122">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="122" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Migrating extension &quot;old-ext&quot; to a new repository, renaming to &quot;test-ext&quot;, and installing updates. </text>
<text x="0" y="36" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
<text x="0" y="53" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>
<text x="0" y="70" fill="#cdcd00" textLength="846" lengthAdjust="spacingAndGlyphs">of extensions. Please carefully inspect any extension and its source code before installing to</text>
<text x="0" y="87" fill="#cdcd00" textLength="630" lengthAdjust="spacingAndGlyphs">understand the permissions it requires and the actions it may perform.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
"Installing extension "test-ext".
This extension will run the following MCP servers:
@@ -287,6 +287,25 @@ describe('consent', () => {
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if extension is migrated', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
{ ...baseConfig, name: 'old-ext' },
false,
[],
[],
true,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
let consentString = requestConsent.mock.calls[0][0] as string;
consentString = normalizePathsForSnapshot(consentString, tempDir);
await expectConsentSnapshot(consentString);
});
it('should request consent if skills change', async () => {
const skill1Dir = path.join(tempDir, 'skill1');
const skill2Dir = path.join(tempDir, 'skill2');
+23 -1
View File
@@ -148,11 +148,30 @@ async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
skills: SkillDefinition[] = [],
previousName?: string,
wasMigrated?: boolean,
): Promise<string> {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
output.push(`Installing extension "${sanitizedConfig.name}".`);
if (wasMigrated) {
if (previousName && previousName !== sanitizedConfig.name) {
output.push(
`Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`,
);
} else {
output.push(
`Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`,
);
}
} else if (previousName && previousName !== sanitizedConfig.name) {
output.push(
`Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`,
);
} else {
output.push(`Installing extension "${sanitizedConfig.name}".`);
}
if (mcpServerEntries.length) {
output.push('This extension will run the following MCP servers:');
@@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail(
previousHasHooks?: boolean,
skills: SkillDefinition[] = [],
previousSkills: SkillDefinition[] = [],
isMigrating: boolean = false,
) {
const extensionConsent = await extensionConsentString(
extensionConfig,
hasHooks,
skills,
previousExtensionConfig?.name,
isMigrating,
);
if (previousExtensionConfig) {
const previousExtensionConsent = await extensionConsentString(
@@ -285,6 +285,23 @@ describe('github.ts', () => {
ExtensionUpdateState.NOT_UPDATABLE,
);
});
it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'new-url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');
const ext = {
path: '/path',
migratedTo: 'new-url',
installMetadata: { type: 'git', source: 'old-url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});
});
describe('downloadFromGitHubRelease', () => {
@@ -203,6 +203,24 @@ export async function checkForExtensionUpdate(
) {
return ExtensionUpdateState.NOT_UPDATABLE;
}
if (extension.migratedTo) {
const migratedState = await checkForExtensionUpdate(
{
...extension,
installMetadata: { ...installMetadata, source: extension.migratedTo },
migratedTo: undefined,
},
extensionManager,
);
if (
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
migratedState === ExtensionUpdateState.UP_TO_DATE
) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
}
try {
if (installMetadata.type === 'git') {
const git = simpleGit(extension.path);
@@ -184,6 +184,54 @@ describe('Extension Update Logic', () => {
});
});
it('should migrate source if migratedTo is set and an update is available', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
Promise.resolve({
name: 'test-extension',
version: '1.0.0',
}),
);
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
const extensionWithMigratedTo = {
...mockExtension,
migratedTo: 'https://new-source.com/repo.git',
};
await updateExtension(
extensionWithMigratedTo,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
);
expect(checkForExtensionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
installMetadata: expect.objectContaining({
source: 'https://new-source.com/repo.git',
}),
}),
mockExtensionManager,
);
expect(
mockExtensionManager.installOrUpdateExtension,
).toHaveBeenCalledWith(
expect.objectContaining({
source: 'https://new-source.com/repo.git',
}),
expect.anything(),
);
});
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
Promise.resolve({
@@ -55,6 +55,24 @@ export async function updateExtension(
});
throw new Error(`Extension is linked so does not need to be updated`);
}
if (extension.migratedTo) {
const migratedState = await checkForExtensionUpdate(
{
...extension,
installMetadata: { ...installMetadata, source: extension.migratedTo },
migratedTo: undefined,
},
extensionManager,
);
if (
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
migratedState === ExtensionUpdateState.UP_TO_DATE
) {
installMetadata.source = extension.migratedTo;
}
}
const originalVersion = extension.version;
const tempDir = await ExtensionStorage.createTmpDir();
@@ -124,4 +124,30 @@ describe('recursivelyHydrateStrings', () => {
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual(obj);
});
it('should not allow prototype pollution via __proto__', () => {
const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}');
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe(
false,
);
});
it('should not allow prototype pollution via constructor', () => {
const payload = JSON.parse(
'{"constructor": {"prototype": {"polluted": "yes"}}}',
);
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
});
it('should not allow prototype pollution via prototype', () => {
const payload = JSON.parse('{"prototype": {"polluted": "yes"}}');
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
});
});
@@ -8,6 +8,16 @@ import * as path from 'node:path';
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
import { GEMINI_DIR } from '@google/gemini-cli-core';
/**
* Represents a set of keys that will be considered invalid while unmarshalling
* JSON in recursivelyHydrateStrings.
*/
const UNMARSHALL_KEY_IGNORE_LIST: Set<string> = new Set<string>([
'__proto__',
'constructor',
'prototype',
]);
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
@@ -65,7 +75,10 @@ export function recursivelyHydrateStrings<T>(
if (typeof obj === 'object' && obj !== null) {
const newObj: Record<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (
!UNMARSHALL_KEY_IGNORE_LIST.has(key) &&
Object.prototype.hasOwnProperty.call(obj, key)
) {
newObj[key] = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
@@ -1,93 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
} from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
it('should have bindings for all commands', () => {
const commands = Object.values(Command);
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
}
});
it('should have valid key binding structures', () => {
for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
for (const binding of bindings) {
// Each binding must have a key name
expect(typeof binding.key).toBe('string');
expect(binding.key.length).toBeGreaterThan(0);
// Modifier properties should be boolean or undefined
if (binding.shift !== undefined) {
expect(typeof binding.shift).toBe('boolean');
}
if (binding.alt !== undefined) {
expect(typeof binding.alt).toBe('boolean');
}
if (binding.ctrl !== undefined) {
expect(typeof binding.ctrl).toBe('boolean');
}
if (binding.cmd !== undefined) {
expect(typeof binding.cmd).toBe('boolean');
}
}
}
});
it('should export all required types', () => {
// Basic type checks
expect(typeof Command.HOME).toBe('string');
expect(typeof Command.END).toBe('string');
// Config should be readonly
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
});
describe('command metadata', () => {
const commandValues = Object.values(Command);
it('has a description entry for every command', () => {
const describedCommands = Object.keys(commandDescriptions);
expect(describedCommands.sort()).toEqual([...commandValues].sort());
for (const command of commandValues) {
expect(typeof commandDescriptions[command]).toBe('string');
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
}
});
it('categorizes each command exactly once', () => {
const seen = new Set<Command>();
for (const category of commandCategories) {
expect(typeof category.title).toBe('string');
expect(Array.isArray(category.commands)).toBe(true);
for (const command of category.commands) {
expect(commandValues).toContain(command);
expect(seen.has(command)).toBe(false);
seen.add(command);
}
}
expect(seen.size).toBe(commandValues.length);
});
});
});
@@ -0,0 +1,186 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ProfileManager } from './profile-manager.js';
import { type LoadedSettings } from './settings.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: mockHomedir,
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: mockHomedir,
};
});
describe('ProfileManager', () => {
let tempHomeDir: string;
let profilesDir: string;
let mockSettings: LoadedSettings;
let manager: ProfileManager;
beforeEach(() => {
vi.clearAllMocks();
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-profile-test-'),
);
vi.stubEnv('GEMINI_CLI_HOME', tempHomeDir);
profilesDir = path.join(tempHomeDir, '.gemini', 'profiles');
fs.mkdirSync(profilesDir, { recursive: true });
mockSettings = {
merged: {
general: {
activeProfile: undefined,
},
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
manager = new ProfileManager(mockSettings);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should list available profiles', async () => {
fs.writeFileSync(path.join(profilesDir, 'coding.md'), '# Coding Profile');
fs.writeFileSync(path.join(profilesDir, 'writing.md'), '# Writing Profile');
fs.writeFileSync(path.join(profilesDir, 'not-a-profile.txt'), 'test');
const profiles = await manager.listProfiles();
expect(profiles.sort()).toEqual(['coding', 'writing']);
});
it('should return empty list if profiles directory does not exist', async () => {
fs.rmSync(profilesDir, { recursive: true, force: true });
const profiles = await manager.listProfiles();
expect(profiles).toEqual([]);
});
it('should ensure profiles directory exists', async () => {
fs.rmSync(profilesDir, { recursive: true, force: true });
expect(fs.existsSync(profilesDir)).toBe(false);
await manager.ensureProfilesDir();
expect(fs.existsSync(profilesDir)).toBe(true);
});
it('should get a profile with frontmatter and context', async () => {
const content = `---
name: coding
description: For coding tasks
extensions: [git, shell]
default_model: gemini-2.0-flash
---
Use these instructions for coding.`;
fs.writeFileSync(path.join(profilesDir, 'coding.md'), content);
const profile = await manager.getProfile('coding');
expect(profile).toBeDefined();
expect(profile?.name).toBe('coding');
expect(profile?.frontmatter.extensions).toEqual(['git', 'shell']);
expect(profile?.frontmatter.default_model).toBe('gemini-2.0-flash');
expect(profile?.context).toBe('Use these instructions for coding.');
});
it('should throw if profile name does not match filename', async () => {
const content = `---
name: wrong-name
extensions: []
---`;
fs.writeFileSync(path.join(profilesDir, 'test.md'), content);
await expect(manager.getProfile('test')).rejects.toThrow(
/Profile name in frontmatter \(wrong-name\) must match filename \(test\)/,
);
});
it('should handle optional extensions field', async () => {
const content = `---
name: test-no-ext
---
Body`;
fs.writeFileSync(path.join(profilesDir, 'test-no-ext.md'), content);
const profile = await manager.getProfile('test-no-ext');
expect(profile?.frontmatter.extensions).toBeUndefined();
expect(profile?.context).toBe('Body');
});
it('should throw if mandatory frontmatter is missing', async () => {
const content = `Just some text without dashes`;
fs.writeFileSync(path.join(profilesDir, 'no-fm.md'), content);
await expect(manager.getProfile('no-fm')).rejects.toThrow(
/missing mandatory YAML frontmatter/,
);
});
it('should throw if YAML is malformed', async () => {
const content = `---
name: [invalid yaml
---
Body`;
fs.writeFileSync(path.join(profilesDir, 'bad-yaml.md'), content);
await expect(manager.getProfile('bad-yaml')).rejects.toThrow(
/Failed to parse profile/,
);
});
it('should throw if validation fails (invalid slug)', async () => {
const content = `---
name: Invalid Name
---`;
fs.writeFileSync(path.join(profilesDir, 'invalid-slug.md'), content);
await expect(manager.getProfile('invalid-slug')).rejects.toThrow(
/Validation failed.*name/,
);
});
it('should return null for non-existent profile', async () => {
const profile = await manager.getProfile('ghost');
expect(profile).toBeNull();
});
it('should uninstall a profile', async () => {
const profilePath = path.join(profilesDir, 'coding.md');
fs.writeFileSync(profilePath, '---\nname: coding\nextensions: []\n---');
await manager.uninstallProfile('coding');
expect(fs.existsSync(profilePath)).toBe(false);
});
it('should disable profile before uninstalling if active', async () => {
const profilePath = path.join(profilesDir, 'active.md');
fs.writeFileSync(profilePath, '---\nname: active\nextensions: []\n---');
mockSettings.merged.general.activeProfile = 'active';
await manager.uninstallProfile('active');
expect(fs.existsSync(profilePath)).toBe(false);
expect(mockSettings.setValue).toHaveBeenCalledWith(
expect.anything(),
'general.activeProfile',
undefined,
);
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { load } from 'js-yaml';
import { z } from 'zod';
import { Storage, getErrorMessage } from '@google/gemini-cli-core';
import { type LoadedSettings, SettingScope } from './settings.js';
export const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/;
const profileFrontmatterSchema = z.object({
name: z.string().regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'),
description: z.string().optional(),
extensions: z.array(z.string()).optional(),
default_model: z.string().optional(),
});
export type ProfileFrontmatter = z.infer<typeof profileFrontmatterSchema>;
export interface Profile {
name: string;
frontmatter: ProfileFrontmatter;
context: string;
filePath: string;
}
/**
* Manages the lifecycle of user profiles.
* Profiles are stored as Markdown files with YAML frontmatter in ~/.gemini/profiles/.
*/
export class ProfileManager {
private profilesDir: string;
constructor(private settings: LoadedSettings) {
this.profilesDir = Storage.getProfilesDir();
}
/**
* Ensures the profiles directory exists.
*/
async ensureProfilesDir(): Promise<void> {
try {
if (!existsSync(this.profilesDir)) {
await fs.mkdir(this.profilesDir, { recursive: true });
}
} catch (error) {
throw new Error(
`Failed to create profiles directory at ${this.profilesDir}: ${getErrorMessage(error)}`,
);
}
}
/**
* Lists the names of all available profiles.
* @returns A list of profile names (filenames without .md extension).
*/
async listProfiles(): Promise<string[]> {
try {
if (!existsSync(this.profilesDir)) {
return [];
}
const entries = await fs.readdir(this.profilesDir, {
withFileTypes: true,
});
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => path.basename(entry.name, '.md'));
} catch (error) {
throw new Error(`Failed to list profiles: ${getErrorMessage(error)}`);
}
}
/**
* Loads and parses a profile by its name.
* @param name The name of the profile to load.
* @returns The parsed Profile object, or null if not found.
* @throws Error if the profile exists but is malformed or invalid.
*/
async getProfile(name: string): Promise<Profile | null> {
const filePath = path.join(this.profilesDir, `${name}.md`);
let content: string;
try {
content = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return null;
}
throw new Error(
`Failed to read profile "${name}": ${getErrorMessage(error)}`,
);
}
try {
const match = content.match(FRONTMATTER_REGEX);
if (!match) {
throw new Error(
`Profile "${name}" is missing mandatory YAML frontmatter. Ensure it starts and ends with "---".`,
);
}
const frontmatterStr = match[1];
const context = match[2]?.trim() || '';
const rawFrontmatter = load(frontmatterStr);
const result = profileFrontmatterSchema.safeParse(rawFrontmatter);
if (!result.success) {
// Collect and format validation errors for a better user experience
const issues = result.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join(', ');
throw new Error(`Validation failed for profile "${name}": ${issues}`);
}
const frontmatter = result.data;
if (frontmatter.name !== name) {
throw new Error(
`Profile name in frontmatter (${frontmatter.name}) must match filename (${name}).`,
);
}
return {
name,
frontmatter,
context,
filePath,
};
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Validation failed')
) {
throw error;
}
throw new Error(
`Failed to parse profile "${name}": ${getErrorMessage(error)}`,
);
}
}
/**
* Gets the name of the currently active profile from settings.
*/
getActiveProfileName(): string | undefined {
return this.settings.merged.general?.activeProfile;
}
/**
* Persistently enables a profile by updating user settings.
* @param name The name of the profile to enable.
* @throws Error if the profile does not exist.
*/
async enableProfile(name: string): Promise<void> {
const profile = await this.getProfile(name);
if (!profile) {
throw new Error(`Profile "${name}" not found. Cannot enable.`);
}
this.settings.setValue(SettingScope.User, 'general.activeProfile', name);
}
/**
* Disables the currently active profile.
*/
disableProfile(): void {
this.settings.setValue(
SettingScope.User,
'general.activeProfile',
undefined,
);
}
/**
* Uninstalls (deletes) a profile.
* If the profile is active, it will be disabled first.
* @param name The name of the profile to uninstall.
* @throws Error if the profile does not exist or deletion fails.
*/
async uninstallProfile(name: string): Promise<void> {
const filePath = path.join(this.profilesDir, `${name}.md`);
if (!existsSync(filePath)) {
throw new Error(`Profile "${name}" not found. Cannot uninstall.`);
}
if (this.getActiveProfileName() === name) {
this.disableProfile();
}
try {
await fs.rm(filePath);
} catch (error) {
throw new Error(
`Failed to delete profile file for "${name}": ${getErrorMessage(error)}`,
);
}
}
}
+253 -128
View File
@@ -13,7 +13,7 @@ vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
homedir: vi.fn(() => path.resolve('/mock/home/user')),
platform: vi.fn(() => 'linux'),
};
});
@@ -76,6 +76,7 @@ import {
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
resetSettingsCacheForTesting,
} from './settings.js';
import {
FatalConfigError,
@@ -91,7 +92,7 @@ import {
} from './settingsSchema.js';
import { createMockSettings } from '../test-utils/settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace'));
// Use the (mocked) GEMINI_DIR for consistency
const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
MOCK_WORKSPACE_DIR,
@@ -102,6 +103,10 @@ const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
// A more flexible type for test data that allows arbitrary properties.
type TestSettings = Settings & { [key: string]: unknown };
// Helper to normalize paths for test assertions, making them OS-agnostic
const normalizePath = (p: string | fs.PathOrFileDescriptor) =>
path.normalize(p.toString());
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
const actualFs = await importOriginal<typeof fs>();
@@ -174,12 +179,15 @@ describe('Settings Loading and Merging', () => {
beforeEach(() => {
vi.resetAllMocks();
resetSettingsCacheForTesting();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
vi.mocked(osActual.homedir).mockReturnValue(
path.resolve('/mock/home/user'),
);
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
@@ -224,20 +232,25 @@ describe('Settings Loading and Merging', () => {
},
])(
'should load $scope settings if only $scope file exists',
({ scope, path, content }) => {
({ scope, path: p, content }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
(pathLike: fs.PathLike) =>
path.normalize(pathLike.toString()) === path.normalize(p),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
(pathDesc: fs.PathOrFileDescriptor) => {
if (path.normalize(pathDesc.toString()) === path.normalize(p))
return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining(path.basename(p)),
'utf-8',
);
expect(
settings[scope as 'system' | 'user' | 'workspace'].settings,
).toEqual(content);
@@ -246,12 +259,14 @@ describe('Settings Loading and Merging', () => {
);
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
p === getSystemSettingsPath() ||
p === USER_SETTINGS_PATH ||
p === MOCK_WORKSPACE_SETTINGS_PATH,
);
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normP = path.normalize(p.toString());
return (
normP === path.normalize(getSystemSettingsPath()) ||
normP === path.normalize(USER_SETTINGS_PATH) ||
normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)
);
});
const systemSettingsContent = {
ui: {
theme: 'system-theme',
@@ -290,11 +305,12 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
const normP = path.normalize(p.toString());
if (normP === path.normalize(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normP === path.normalize(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -390,13 +406,13 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemDefaultsPath())
if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
return JSON.stringify(systemDefaultsContent);
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -449,11 +465,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -489,11 +505,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -523,11 +539,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -576,11 +592,12 @@ describe('Settings Loading and Merging', () => {
'should handle $description correctly',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
if (normalizePath(p) === normalizePath(path))
return JSON.stringify(content);
return '{}';
},
);
@@ -598,7 +615,8 @@ describe('Settings Loading and Merging', () => {
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = {
general: {},
@@ -611,9 +629,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -643,15 +661,16 @@ describe('Settings Loading and Merging', () => {
it('should default contextFileName to undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = { ui: { theme: 'dark' } };
const workspaceSettingsContent = { tools: { sandbox: true } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -678,11 +697,12 @@ describe('Settings Loading and Merging', () => {
'should load telemetry setting from $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
if (normalizePath(p) === normalizePath(path))
return JSON.stringify(content);
return '{}';
},
);
@@ -697,9 +717,9 @@ describe('Settings Loading and Merging', () => {
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -720,7 +740,8 @@ describe('Settings Loading and Merging', () => {
it('should merge MCP servers correctly, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = {
mcpServers: {
@@ -751,9 +772,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -822,11 +843,12 @@ describe('Settings Loading and Merging', () => {
'should handle MCP servers when only in $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
(p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
if (normalizePath(p) === normalizePath(path))
return JSON.stringify(content);
return '{}';
},
);
@@ -881,11 +903,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -932,11 +954,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -983,8 +1005,11 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userContent);
if (
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
)
return JSON.stringify(workspaceContent);
return '{}';
},
@@ -1008,9 +1033,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1038,13 +1063,13 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
if (p === getSystemDefaultsPath())
if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
return JSON.stringify(systemDefaultsContent);
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1073,14 +1098,16 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) {
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
// Simulate JSON.parse throwing for user settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw userReadError;
});
return invalidJsonContent; // Content that would cause JSON.parse to throw
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
if (
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
) {
// Simulate JSON.parse throwing for workspace settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw workspaceReadError;
@@ -1119,11 +1146,12 @@ describe('Settings Loading and Merging', () => {
someUrl: 'https://test.com/${TEST_API_KEY}',
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1149,11 +1177,12 @@ describe('Settings Loading and Merging', () => {
nested: { value: '$WORKSPACE_ENDPOINT' },
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1201,13 +1230,15 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
if (p === USER_SETTINGS_PATH) {
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
return JSON.stringify(userSettingsContent);
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
if (
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
@@ -1266,9 +1297,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1280,14 +1311,15 @@ describe('Settings Loading and Merging', () => {
it('should use user dnsResolutionOrder if workspace is not defined', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
const userSettingsContent = {
advanced: { dnsResolutionOrder: 'verbatim' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1300,11 +1332,12 @@ describe('Settings Loading and Merging', () => {
it('should leave unresolved environment variables as is', () => {
const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1326,11 +1359,12 @@ describe('Settings Loading and Merging', () => {
path: '/path/$VAR_A/${VAR_B}/end',
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1350,11 +1384,12 @@ describe('Settings Loading and Merging', () => {
list: ['$ITEM_1', '${ITEM_2}', 'literal'],
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1389,11 +1424,12 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1434,11 +1470,12 @@ describe('Settings Loading and Merging', () => {
serverAddress: '${TEST_HOST}:${TEST_PORT}/api',
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1454,7 +1491,9 @@ describe('Settings Loading and Merging', () => {
});
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve(
'/mock/env/system/settings.json',
);
beforeEach(() => {
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] =
@@ -1496,8 +1535,8 @@ describe('Settings Loading and Merging', () => {
});
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
const mockHomeDir = '/mock/home/user';
const mockSymlinkDir = '/mock/symlink/to/home';
const mockHomeDir = path.resolve('/mock/home/user');
const mockSymlinkDir = path.resolve('/mock/symlink/to/home');
const mockWorkspaceSettingsPath = path.join(
mockSymlinkDir,
GEMINI_DIR,
@@ -1541,6 +1580,79 @@ describe('Settings Loading and Merging', () => {
isWorkspaceHomeDirSpy.mockRestore();
}
});
describe('caching', () => {
it('should cache loadSettings results', () => {
const mockedRead = vi.mocked(fs.readFileSync);
mockedRead.mockClear();
mockedRead.mockReturnValue('{}');
(mockFsExistsSync as Mock).mockReturnValue(true);
const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file
expect(settings1).toBe(settings2);
});
it('should use separate cache for different workspace directories', () => {
const mockedRead = vi.mocked(fs.readFileSync);
mockedRead.mockClear();
mockedRead.mockReturnValue('{}');
(mockFsExistsSync as Mock).mockReturnValue(true);
const workspace1 = path.resolve('/mock/workspace1');
const workspace2 = path.resolve('/mock/workspace2');
const settings1 = loadSettings(workspace1);
const settings2 = loadSettings(workspace2);
expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace
expect(settings1).not.toBe(settings2);
});
it('should clear cache when saveSettings is called for user settings', () => {
const mockedRead = vi.mocked(fs.readFileSync);
mockedRead.mockClear();
mockedRead.mockReturnValue('{}');
(mockFsExistsSync as Mock).mockReturnValue(true);
const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
expect(mockedRead).toHaveBeenCalledTimes(5);
saveSettings(settings1.user);
const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk
expect(settings1).not.toBe(settings2);
});
it('should clear all caches when saveSettings is called for workspace settings', () => {
const mockedRead = vi.mocked(fs.readFileSync);
mockedRead.mockClear();
mockedRead.mockReturnValue('{}');
(mockFsExistsSync as Mock).mockReturnValue(true);
const workspace1 = path.resolve('/mock/workspace1');
const workspace2 = path.resolve('/mock/workspace2');
const settings1W1 = loadSettings(workspace1);
const settings1W2 = loadSettings(workspace2);
expect(mockedRead).toHaveBeenCalledTimes(10);
// Save settings for workspace 1
saveSettings(settings1W1.workspace);
const settings2W1 = loadSettings(workspace1);
const settings2W2 = loadSettings(workspace2);
// Both workspace caches should have been cleared and re-read from disk (+10 reads)
expect(mockedRead).toHaveBeenCalledTimes(20);
expect(settings1W1).not.toBe(settings2W1);
expect(settings1W2).not.toBe(settings2W2);
});
});
});
describe('excludedProjectEnvVars integration', () => {
@@ -1562,12 +1674,13 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1578,16 +1691,18 @@ describe('Settings Loading and Merging', () => {
loadSettings as unknown as { findEnvFile: () => string }
).findEnvFile;
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
() => '/mock/project/.env';
() => path.resolve('/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') {
if (p === path.resolve('/mock/project/.env')) {
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
if (
normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
@@ -1621,12 +1736,13 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1658,9 +1774,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1702,9 +1818,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1734,9 +1850,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1767,9 +1883,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1940,9 +2056,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1966,7 +2082,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1994,7 +2110,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2039,7 +2155,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2226,7 +2342,8 @@ describe('Settings Loading and Merging', () => {
it('should trigger migration automatically during loadSettings', () => {
mockFsExistsSync.mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
const userSettingsContent = {
general: {
@@ -2235,7 +2352,7 @@ describe('Settings Loading and Merging', () => {
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2270,10 +2387,10 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
if (p === getSystemDefaultsPath()) {
if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) {
return JSON.stringify(systemDefaultsContent);
}
return '{}';
@@ -2343,7 +2460,7 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2394,7 +2511,7 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2430,13 +2547,16 @@ describe('Settings Loading and Merging', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
settingsFile.path = '/mock/settings.json';
settingsFile.path = path.resolve('/mock/settings.json');
saveSettings(settingsFile);
expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', {
ui: { theme: 'dark' },
});
expect(mockUpdateSettings).toHaveBeenCalledWith(
path.resolve('/mock/settings.json'),
{
ui: { theme: 'dark' },
},
);
});
it('should create directory if it does not exist', () => {
@@ -2445,14 +2565,19 @@ describe('Settings Loading and Merging', () => {
mockFsExistsSync.mockReturnValue(false);
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/new/dir/settings.json';
settingsFile.path = path.resolve('/mock/new/dir/settings.json');
saveSettings(settingsFile);
expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir');
expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', {
recursive: true,
});
expect(mockFsExistsSync).toHaveBeenCalledWith(
path.resolve('/mock/new/dir'),
);
expect(mockFsMkdirSync).toHaveBeenCalledWith(
path.resolve('/mock/new/dir'),
{
recursive: true,
},
);
});
it('should emit error feedback if saving fails', () => {
@@ -2463,7 +2588,7 @@ describe('Settings Loading and Merging', () => {
});
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/settings.json';
settingsFile.path = path.resolve('/mock/settings.json');
saveSettings(settingsFile);
@@ -2491,7 +2616,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2538,7 +2663,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2579,7 +2704,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2694,7 +2819,7 @@ describe('Settings Loading and Merging', () => {
beforeEach(() => {
const emptySettingsFile: SettingsFile = {
path: '/mock/path',
path: path.resolve('/mock/path'),
settings: {},
originalSettings: {},
};
@@ -3019,7 +3144,7 @@ describe('LoadedSettings Isolation and Serializability', () => {
// Create a minimal LoadedSettings instance
const emptyScope = {
path: '/mock/settings.json',
path: path.resolve('/mock/settings.json'),
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
+28
View File
@@ -18,6 +18,7 @@ import {
coreEvents,
homedir,
type AdminControlsSettings,
createCache,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
@@ -615,6 +616,20 @@ export function loadEnvironment(
}
}
// Cache to store the results of loadSettings to avoid redundant disk I/O.
const settingsCache = createCache<string, LoadedSettings>({
storage: 'map',
defaultTtl: 10000, // 10 seconds
});
/**
* Resets the settings cache. Used exclusively for test isolation.
* @internal
*/
export function resetSettingsCacheForTesting() {
settingsCache.clear();
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
@@ -622,6 +637,16 @@ export function loadEnvironment(
export function loadSettings(
workspaceDir: string = process.cwd(),
): LoadedSettings {
const normalizedWorkspaceDir = path.resolve(workspaceDir);
return settingsCache.getOrCreate(normalizedWorkspaceDir, () =>
_doLoadSettings(normalizedWorkspaceDir),
);
}
/**
* Internal implementation of the settings loading logic.
*/
function _doLoadSettings(workspaceDir: string): LoadedSettings {
let systemSettings: Settings = {};
let systemDefaultSettings: Settings = {};
let userSettings: Settings = {};
@@ -1029,6 +1054,9 @@ export function migrateDeprecatedSettings(
}
export function saveSettings(settingsFile: SettingsFile): void {
// Clear the entire cache on any save.
settingsCache.clear();
try {
// Ensure the directory exists
const dirPath = path.dirname(settingsFile.path);
@@ -424,12 +424,10 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe(
'Enable planning features (Plan Mode and tools).',
);
expect(setting.description).toBe('Enable Plan Mode.');
});
it('should have hooksConfig.notifications setting in schema', () => {
+23 -2
View File
@@ -379,6 +379,15 @@ const SETTINGS_SCHEMA = {
},
description: 'Settings for automatic session cleanup.',
},
activeProfile: {
type: 'string',
label: 'Active Profile',
category: 'General',
requiresRestart: true,
default: undefined as string | undefined,
description: 'The name of the currently active profile.',
showInDialog: false,
},
},
},
output: {
@@ -1496,6 +1505,18 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddToPolicyByDefault: {
type: 'boolean',
label: 'Auto-add to Policy by Default',
category: 'Security',
requiresRestart: false,
default: false,
description: oneLine`
When enabled, the "Allow for all future sessions" option becomes the
default choice for low-risk tools in trusted workspaces.
`,
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -1823,8 +1844,8 @@ const SETTINGS_SCHEMA = {
label: 'Plan',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
default: true,
description: 'Enable Plan Mode.',
showInDialog: true,
},
taskTracker: {
@@ -81,6 +81,7 @@ import {
loadSettings,
USER_SETTINGS_PATH,
type LoadedSettings,
resetSettingsCacheForTesting,
} from './settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
@@ -88,6 +89,7 @@ const MOCK_WORKSPACE_DIR = '/mock/workspace';
describe('Settings Validation Warning', () => {
beforeEach(() => {
vi.clearAllMocks();
resetSettingsCacheForTesting();
(fs.readFileSync as Mock).mockReturnValue('{}');
(fs.existsSync as Mock).mockReturnValue(false);
});
+1
View File
@@ -498,6 +498,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
profile: undefined,
});
await act(async () => {
@@ -73,7 +73,17 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
}));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/chatCommand.js', () => ({
chatCommand: { name: 'chat', subCommands: [] },
chatCommand: {
name: 'chat',
subCommands: [
{ name: 'list' },
{ name: 'save' },
{ name: 'resume' },
{ name: 'delete' },
{ name: 'share' },
{ name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] },
],
},
debugCommand: { name: 'debug' },
}));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
@@ -94,7 +104,19 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} }));
vi.mock('../ui/commands/resumeCommand.js', () => ({
resumeCommand: {
name: 'resume',
subCommands: [
{ name: 'list' },
{ name: 'save' },
{ name: 'resume' },
{ name: 'delete' },
{ name: 'share' },
{ name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] },
],
},
}));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
@@ -120,6 +142,14 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({
},
}));
vi.mock('../ui/commands/upgradeCommand.js', () => ({
upgradeCommand: {
name: 'upgrade',
description: 'Upgrade command',
kind: 'BUILT_IN',
},
}));
describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
@@ -129,7 +159,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
@@ -141,6 +171,9 @@ describe('BuiltinCommandLoader', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@@ -150,6 +183,27 @@ describe('BuiltinCommandLoader', () => {
});
});
it('should include upgrade command when authType is login_with_google', async () => {
const { AuthType } = await import('@google/gemini-cli-core');
(mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
});
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const upgradeCmd = commands.find((c) => c.name === 'upgrade');
expect(upgradeCmd).toBeDefined();
});
it('should exclude upgrade command when authType is NOT login_with_google', async () => {
(mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({
authType: 'other',
});
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const upgradeCmd = commands.find((c) => c.name === 'upgrade');
expect(upgradeCmd).toBeUndefined();
});
it('should correctly pass the config object to restore command factory', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
await loader.loadCommands(new AbortController().signal);
@@ -256,7 +310,7 @@ describe('BuiltinCommandLoader', () => {
});
describe('chat debug command', () => {
it('should NOT add debug subcommand to chatCommand if not a nightly build', async () => {
it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => {
vi.mocked(isNightly).mockResolvedValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
@@ -265,9 +319,30 @@ describe('BuiltinCommandLoader', () => {
expect(chatCmd?.subCommands).toBeDefined();
const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug');
expect(hasDebug).toBe(false);
const resumeCmd = commands.find((c) => c.name === 'resume');
const resumeHasDebug =
resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false;
expect(resumeHasDebug).toBe(false);
const chatCheckpointsCmd = chatCmd?.subCommands?.find(
(c) => c.name === 'checkpoints',
);
const chatCheckpointHasDebug =
chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ??
false;
expect(chatCheckpointHasDebug).toBe(false);
const resumeCheckpointsCmd = resumeCmd?.subCommands?.find(
(c) => c.name === 'checkpoints',
);
const resumeCheckpointHasDebug =
resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ??
false;
expect(resumeCheckpointHasDebug).toBe(false);
});
it('should add debug subcommand to chatCommand if it is a nightly build', async () => {
it('should add debug subcommand to chat/resume commands if it is a nightly build', async () => {
vi.mocked(isNightly).mockResolvedValue(true);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
@@ -276,6 +351,27 @@ describe('BuiltinCommandLoader', () => {
expect(chatCmd?.subCommands).toBeDefined();
const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug');
expect(hasDebug).toBe(true);
const resumeCmd = commands.find((c) => c.name === 'resume');
const resumeHasDebug =
resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false;
expect(resumeHasDebug).toBe(true);
const chatCheckpointsCmd = chatCmd?.subCommands?.find(
(c) => c.name === 'checkpoints',
);
const chatCheckpointHasDebug =
chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ??
false;
expect(chatCheckpointHasDebug).toBe(true);
const resumeCheckpointsCmd = resumeCmd?.subCommands?.find(
(c) => c.name === 'checkpoints',
);
const resumeCheckpointHasDebug =
resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ??
false;
expect(resumeCheckpointHasDebug).toBe(true);
});
});
});
@@ -287,7 +383,7 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
@@ -300,6 +396,9 @@ describe('BuiltinCommandLoader profile', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
} as unknown as Config;
});
@@ -16,6 +16,7 @@ import {
isNightly,
startupProfiler,
getAdminErrorMessage,
AuthType,
} from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
@@ -59,6 +60,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -78,6 +80,41 @@ export class BuiltinCommandLoader implements ICommandLoader {
const handle = startupProfiler.start('load_builtin_commands');
const isNightlyBuild = await isNightly(process.cwd());
const addDebugToChatResumeSubCommands = (
subCommands: SlashCommand[] | undefined,
): SlashCommand[] | undefined => {
if (!subCommands) {
return subCommands;
}
const withNestedCompatibility = subCommands.map((subCommand) => {
if (subCommand.name !== 'checkpoints') {
return subCommand;
}
return {
...subCommand,
subCommands: addDebugToChatResumeSubCommands(subCommand.subCommands),
};
});
if (!isNightlyBuild) {
return withNestedCompatibility;
}
return withNestedCompatibility.some(
(cmd) => cmd.name === debugCommand.name,
)
? withNestedCompatibility
: [
...withNestedCompatibility,
{ ...debugCommand, suggestionGroup: 'checkpoints' },
];
};
const chatResumeSubCommands = addDebugToChatResumeSubCommands(
chatCommand.subCommands,
);
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
@@ -86,9 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
bugCommand,
{
...chatCommand,
subCommands: isNightlyBuild
? [...(chatCommand.subCommands || []), debugCommand]
: chatCommand.subCommands,
subCommands: chatResumeSubCommands,
},
clearCommand,
commandsCommand,
@@ -155,7 +190,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(isDevelopment ? [profileCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,
{
...resumeCommand,
subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands),
},
statsCommand,
themeCommand,
toolsCommand,
@@ -187,6 +225,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
...(this.config?.getContentGeneratorConfig()?.authType ===
AuthType.LOGIN_WITH_GOOGLE
? [upgradeCommand]
: []),
];
handle?.end();
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
+5 -1
View File
@@ -36,7 +36,10 @@ import {
MockShellExecutionService,
} from './MockShellExecutionService.js';
import { createMockSettings } from './settings.js';
import { type LoadedSettings } from '../config/settings.js';
import {
type LoadedSettings,
resetSettingsCacheForTesting,
} from '../config/settings.js';
import { AuthState, StreamingState } from '../ui/types.js';
import { randomUUID } from 'node:crypto';
import type {
@@ -171,6 +174,7 @@ export class AppRig {
async initialize() {
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
const approvalMode =
+8 -3
View File
@@ -119,7 +119,7 @@ import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { Command } from './key/keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
@@ -164,7 +164,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
@@ -205,6 +205,7 @@ import {
useVisibilityToggle,
APPROVAL_MODE_REVEAL_DURATION_MS,
} from './hooks/useVisibilityToggle.js';
import { useKeyMatchers } from './hooks/useKeyMatchers.js';
/**
* The fraction of the terminal width to allocate to the shell.
@@ -219,6 +220,8 @@ const SHELL_WIDTH_FRACTION = 0.89;
const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const { reset } = useOverflowActions()!;
@@ -1654,7 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1848,6 +1851,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
settings.merged.general.devtools,
showErrorDetails,
triggerExpandHint,
keyMatchers,
isHelpDismissKey,
],
);
@@ -103,7 +103,7 @@ describe('ApiAuthDialog', () => {
it.each([
{
keyName: 'return',
keyName: 'enter',
sequence: '\r',
expectedCall: onSubmit,
args: ['submitted-key'],
+3 -1
View File
@@ -13,7 +13,8 @@ import { useTextBuffer } from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ApiAuthDialogProps {
onSubmit: (apiKey: string) => void;
@@ -28,6 +29,7 @@ export function ApiAuthDialog({
error,
defaultValue = '',
}: ApiAuthDialogProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { terminalWidth } = useUIState();
const viewportWidth = terminalWidth - 8;
@@ -99,8 +99,11 @@ 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(5);
expect(chatCommand.description).toBe(
'Browse auto-saved conversations and manage chat checkpoints',
);
expect(chatCommand.autoExecute).toBe(true);
expect(chatCommand.subCommands).toHaveLength(6);
});
describe('list subcommand', () => {
@@ -158,7 +161,7 @@ describe('chatCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
content: 'Missing tag. Usage: /resume save <tag>',
});
});
@@ -252,7 +255,7 @@ describe('chatCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
content: 'Missing tag. Usage: /resume resume <tag>',
});
});
@@ -386,7 +389,7 @@ describe('chatCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
content: 'Missing tag. Usage: /resume delete <tag>',
});
});

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