Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 259bf78f65 | |||
| a220874281 | |||
| 49ea9b0457 | |||
| 556825f81c | |||
| b158c96465 | |||
| e91f86c248 | |||
| 47e4f6b13f | |||
| 94ab449e65 | |||
| 0486a1675a | |||
| f9fc83089c | |||
| 5dd2dab189 | |||
| a6b95897ad | |||
| 6ae6c810ba | |||
| 02d4451e77 | |||
| c25ff94608 | |||
| 14412c3a72 | |||
| ec7773eb7b | |||
| 4653b126f3 | |||
| 1e1e7e349d | |||
| 43eb74ac59 | |||
| 215f8f3f15 | |||
| b89944c3a3 | |||
| f88488d1f9 | |||
| 1fd42802be | |||
| ab64b15d51 | |||
| e406dcc249 | |||
| 680077631d | |||
| 9fc03a0c12 | |||
| 4f4431e4e1 | |||
| 527074b50a | |||
| a17691f0fc | |||
| d246315cea | |||
| e92ccec6c8 | |||
| b68d7bc0f9 | |||
| 4c9f9bb3e2 | |||
| e7b20c49ac | |||
| 743d05b37f | |||
| 95074a1a84 | |||
| 759575faa8 | |||
| d485e08606 | |||
| 0f1258305a | |||
| 09e99824d4 | |||
| 96b939f63a | |||
| 7837194ab5 | |||
| 35ee2a841a | |||
| a253938ac5 | |||
| 37ffd608fd | |||
| 0f019122b0 | |||
| 936f6240dd | |||
| f8dd6f4f4c | |||
| ca7ac00030 | |||
| d41735d6a9 | |||
| d012929a28 | |||
| 97dfbd4598 |
@@ -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.
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -62,3 +62,5 @@ gemini-debug.log
|
||||
.gemini-clipboard/
|
||||
.eslintcache
|
||||
evals/logs/
|
||||
|
||||
temp_agents/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
|
||||
[](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 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
|
||||
|
||||
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 120 KiB |
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, you’ll see
|
||||
a message prompting fallback to Gemini 2.5 Flash.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "old-ext" to a new repository, renaming to "test-ext", 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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||