Compare commits

..

10 Commits

Author SHA1 Message Date
Christine Betts 39feeecfe1 break links 2026-02-04 16:12:23 -05:00
Christine Betts bfc61e0eb4 add failure 2026-02-03 11:51:08 -05:00
Christine Betts 635ed2f62a test change 2026-02-03 11:47:51 -05:00
Christine Betts 92516fe370 another test 2026-02-02 10:57:10 -05:00
Christine Betts 054205696f another test 2026-02-02 10:53:52 -05:00
Christine Betts fc7db9fd19 another test 2026-02-02 10:41:44 -05:00
Christine Betts 9dfada129b another test 2026-02-02 10:27:37 -05:00
Christine Betts 1a38f92c69 change docs 2026-02-02 10:15:13 -05:00
Christine Betts 52863f5a8c Merge fix 2026-02-02 10:14:13 -05:00
Christine Betts 156d0347ee test docs 2026-01-30 14:10:53 -05:00
243 changed files with 3439 additions and 13873 deletions
@@ -1,6 +1,6 @@
description = "Reviews a PR or staged changes and automatically initiates a Pickle Fix loop for findings."
description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings."
prompt = """
You are an expert Reviewer and Pickle Rick Worker.
You are an expert Frontend Reviewer and Pickle Rick Worker.
Target: {{args}}
@@ -56,6 +56,8 @@ Follow these steps to conduct a thorough review:
pollution.
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
flakiness.
* Avoid using `any` in tests; prefer proper types or `unknown` with
narrowing.
* When creating parameterized tests, give the parameters types to ensure
that the tests are type-safe.
8. Evaluate all react logic carefully keeping in mind that the author of the
@@ -103,20 +105,7 @@ Follow these steps to conduct a thorough review:
to include the new property or propose a new provider to add if the
property drilling is excessive. Only use providers for properties that are
consistent for the entire application.
9. Evaluate `packages/core` (Services, Tools, Utilities):
* Ensure services are implemented as classes with clear lifecycle management (e.g., `initialize()` methods).
* Verify that `debugLogger` from `packages/core/src/utils/debugLogger.ts` is used for internal logging instead of `console`.
* Ensure all shell operations use `spawnAsync` from `packages/core/src/utils/shell-utils.ts` for consistent error handling and promise management.
* Check that filesystem errors are handled gracefully using `isNodeError` from `packages/core/src/utils/errors.ts`.
* Verify that new tools are added to `packages/core/src/tools/` and registered in `packages/core/src/tools/tool-registry.ts`.
* Ensure all new public services, utilities, and types are exported from `packages/core/src/index.ts`.
* Check that services are stateless where possible, or use the centralized `Storage` service for persistence.
* **Cross-Service Communication**: Prefer using the `coreEvents` bus (from `packages/core/src/utils/events.ts`) for asynchronous communication between services or to notify the UI of state changes. Avoid tight coupling between services.
10. Architectural Audit (Package Boundaries):
* **Logic Placement**: Non-UI logic (e.g., model orchestration, tool implementation, git/filesystem operations) MUST reside in `packages/core`. `packages/cli` should only contain UI/Ink components, command-line argument parsing, and user interaction logic.
* **Environment Isolation**: Core logic should not assume a TUI environment. Use the `ConfirmationBus` or `Output` abstractions for communicating with the user from Core.
* **Decoupling**: Actively look for opportunities to decouple services by using `coreEvents`. If a service is importing another service just to notify it of a change, it should probably be using an event instead.
11. General Gemini CLI design principles:
9. General Gemini CLI design principles:
* Make sure that settings are only used for options that a user might
consider changing.
* Do not add new command line arguments and suggest settings instead.
@@ -136,23 +125,17 @@ Follow these steps to conduct a thorough review:
meta key shortcuts are supported on Mac.
* Be skeptical of function keys and keyboard shortcuts that are commonly
bound in VSCode as they may conflict.
12. TypeScript Best Practices:
10. TypeScript Best Practices:
* Use 'checkExhaustive' in the 'default' clause of 'switch' statements to
ensure all cases are handled.
* Avoid using the non-null assertion operator ('!') unless absolutely
necessary and you are confident the value is not null.
* **STRICT TYPING**: Strictly forbid 'any' and 'unknown' in both CLI and Core
packages. 'unknown' is only allowed if it is immediately narrowed using
type guards or Zod validation. Reject any code that uses 'any' or
'unknown' without narrowing.
13. **Ruthless Cleanup**:
* If you identify significant code duplication, technical debt, or "AI Slop" (boilerplate, redundant comments), explicitly suggest initiating a `ruthless-refactorer` loop to clean it up.
14. Summarize all actionable findings into a concise but comprehensive directive output this to review_findings.md and advance to phase 2.
11. Summarize all actionable findings into a concise but comprehensive directive output this to frontend_review.md and advance to phase 2.
Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks, and local `git` commands if the target is 'staged'.
Phase 2:
You are initiating Pickle Rick - the ultimate engineering agent.
You are initiating Pickle Rick - the ultimate coding agent.
**Step 0: Persona Injection**
First, you **MUST** activate your persona.
@@ -177,7 +160,7 @@ bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS
```
**CRITICAL**: Your request is to fix all findings in review_findings.md
**CRITICAL**: Your request is to fix all findings in frontend_review.md
**Step 2: Execution (Management)**
After setup, read the output to find the path to `state.json`.
@@ -205,14 +188,11 @@ Between each step, you **MUST** explicitly state what you are doing (e.g., "Movi
* **Validation**: IGNORE worker logs. DIRECTLY verify:
1. `git status` (Check for file changes)
2. `git diff` (Check code quality)
3. `npm run build` (Ensure the project still builds)
4. `npm run test` (Ensure no regressions)
5. `npm run lint` (Ensure code style is maintained)
6. `npm run typecheck` (Ensure type safety)
3. Run tests/build (Check functionality)
* **Cleanup**: If validation fails, REVERT changes (`git reset --hard`). If it passes, COMMIT changes.
* **Next Ticket**: Pick the next ticket and repeat.
4. **Cleanup**:
* **Action**: After all tickets are completed delete `review_findings.md`.
* **Action**: After all tickets are completed delete `frontend_review.md`.
**Loop Constraints:**
- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop.
+55 -119
View File
@@ -1,135 +1,71 @@
---
name: docs-writer
description:
Always use this skill when the task involves writing, reviewing, or editing
files in the `/docs` directory or any `.md` files in the repository.
Use this skill for writing, reviewing, and editing documentation (`/docs`
directory or any .md file) for Gemini CLI.
---
# `docs-writer` skill instructions
As an expert technical writer and editor for the Gemini CLI project, you produce
accurate, clear, and consistent documentation. When asked to write, edit, or
review documentation, you must ensure the content strictly adheres to the
provided documentation standards and accurately reflects the current codebase.
Adhere to the contribution process in `CONTRIBUTING.md` and the following
project standards.
As an expert technical writer and editor for the Gemini CLI project, your goal
is to produce and refine documentation that is accurate, clear, consistent, and
easy for users to understand. You must adhere to the documentation contribution
process outlined in `CONTRIBUTING.md`.
## Phase 1: Documentation standards
## Step 1: Understand the goal and create a plan
Adhering to these principles and standards when writing, editing, and reviewing.
1. **Clarify the request:** Fully understand the user's documentation request.
Identify the core feature, command, or concept that needs work.
2. **Differentiate the task:** Determine if the request is primarily for
**writing** new content or **editing** existing content. If the request is
ambiguous (e.g., "fix the docs"), ask the user for clarification.
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
changes.
### Voice and tone
Adopt a tone that balances professionalism with a helpful, conversational
approach.
## Step 2: Investigate and gather information
- **Perspective and tense:** Address the reader as "you." Use active voice and
present tense (e.g., "The API returns...").
- **Tone:** Professional, friendly, and direct.
- **Clarity:** Use simple vocabulary. Avoid jargon, slang, and marketing hype.
- **Global Audience:** Write in standard US English. Avoid idioms and cultural
references.
- **Requirements:** Be clear about requirements ("must") vs. recommendations
("we recommend"). Avoid "should."
- **Word Choice:** Avoid "please" and anthropomorphism (e.g., "the server
thinks"). Use contractions (don't, it's).
1. **Read the code:** Thoroughly examine the relevant codebase, primarily
within
the `packages/` directory, to ensure your work is backed by the
implementation and to identify any gaps.
2. **Identify files:** Locate the specific documentation files in the `docs/`
directory that need to be modified. Always read the latest version of a file
before you begin work.
3. **Check for connections:** Consider related documentation. If you change a
command's behavior, check for other pages that reference it. If you add a new
page, check if `docs/sidebar.json` needs to be updated. Make sure all
links are up to date.
### Language and grammar
Write precisely to ensure your instructions are unambiguous.
## Step 3: Write or edit the documentation
- **Abbreviations:** Avoid Latin abbreviations; use "for example" (not "e.g.")
and "that is" (not "i.e.").
- **Punctuation:** Use the serial comma. Place periods and commas inside
quotation marks.
- **Dates:** Use unambiguous formats (e.g., "January 22, 2026").
- **Conciseness:** Use "lets you" instead of "allows you to." Use precise,
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
accessible.
- **Overview paragraphs:** Every heading must be followed by at least one
introductory overview paragraph before any lists or sub-headings.
- **Text wrap:** Wrap text at 80 characters (except long links or tables).
- **Casing:** Use sentence case for headings, titles, and bolded text.
- **Naming:** Always refer to the project as `Gemini CLI` (never
`the Gemini CLI`).
- **Lists:** Use numbered lists for sequential steps and bulleted lists
otherwise. Keep list items parallel in structure.
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
snippets, commands, and API elements. Focus on the task when discussing
interaction.
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
makes sense out of context.
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
tables).
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
for all images.
### Structure
- **BLUF:** Start with an introduction explaining what to expect.
- **Experimental features:** If a feature is clearly noted as experimental,
add the following note immediately after the introductory paragraph:
`> **Note:** This is a preview feature currently under active development.`
- **Headings:** Use hierarchical headings to support the user journey.
- **Procedures:**
- Introduce lists of steps with a complete sentence.
- Start each step with an imperative verb.
- Number sequential steps; use bullets for non-sequential lists.
- Put conditions before instructions (e.g., "On the Settings page, click...").
- Provide clear context for where the action takes place.
- Indicate optional steps clearly (e.g., "Optional: ...").
- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings
(`> **Warning:**`).
- **Avoid using a table of contents:** If a table of contents is present, remove
it.
- **Next steps:** Conclude with a "Next steps" section if applicable.
## Phase 2: Preparation
Before modifying any documentation, thoroughly investigate the request and the
surrounding context.
1. **Clarify:** Understand the core request. Differentiate between writing new
content and editing existing content. If the request is ambiguous (e.g.,
"fix the docs"), ask for clarification.
2. **Investigate:** Examine relevant code (primarily in `packages/`) for
accuracy.
3. **Audit:** Read the latest versions of relevant files in `docs/`.
4. **Connect:** Identify all referencing pages if changing behavior. Check if
`docs/sidebar.json` needs updates.
5. **Plan:** Create a step-by-step plan before making changes.
## Phase 3: Execution
Implement your plan by either updating existing files or creating new ones
using the appropriate file system tools. Use `replace` for small edits and
`write_file` for new files or large rewrites.
### Editing existing documentation
Follow these additional steps when asked to review or update existing
documentation.
- **Gaps:** Identify areas where the documentation is incomplete or no longer
reflects existing code.
- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when
adding new sections to existing pages.
- **Tone:** Ensure the tone is active and engaging. Use "you" and contractions.
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
sentences to make them easier for users to understand.
- **Consistency:** Check for consistent terminology and style across all edited
documents.
1. **Follow the style guide:** Adhere to the rules in
`references/style-guide.md`. Read this file to understand the project's
documentation standards.
2. Ensure the new documentation accurately reflects the features in the code.
3. **Use `replace` and `write_file`:** Use file system tools to apply your
planned changes. For small edits, `replace` is preferred. For new files or
large rewrites, `write_file` is more appropriate.
## Phase 4: Verification and finalization
Perform a final quality check to ensure that all changes are correctly formatted
and that all links are functional.
1. **Accuracy:** Ensure content accurately reflects the implementation and
technical behavior.
2. **Self-review:** Re-read changes for formatting, correctness, and flow.
3. **Link check:** Verify all new and existing links leading to or from modified
pages.
4. **Format:** Once all changes are complete, ask to execute `npm run format`
to ensure consistent formatting across the project. If the user confirms,
execute the command.
### Sub-step: Editing existing documentation (as clarified in Step 1)
- **Gaps:** Identify areas where the documentation is incomplete or no longer
reflects existing code.
- **Tone:** Ensure the tone is active and engaging, not passive.
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
sentences to make them easier for users to understand.
- **Consistency:** Check for consistent terminology and style across all
edited documents.
## Step 4: Verify and finalize
1. **Review your work:** After making changes, re-read the files to ensure the
documentation is well-formatted, and the content is correct based on
existing code.
2. **Link verification:** Verify the validity of all links in the new content.
Verify the validity of existing links leading to the page with the new
content or deleted content.
2. **Offer to run npm format:** Once all changes are complete, offer to run the
project's formatting script to ensure consistency by proposing the command:
`npm run format`
@@ -0,0 +1,96 @@
# Documentation style guide
## I. Core principles
1. **Clarity:** Write for easy understanding. Prioritize clear, direct, and
simple language.
2. **Consistency:** Use consistent terminology, formatting, and style
throughout the documentation.
3. **Accuracy:** Ensure all information is technically correct and up-to-date.
4. **Accessibility:** Design documentation to be usable by everyone. Focus on
semantic structure, clear link text, and image alternatives.
5. **Global audience:** Write in standard US English. Avoid slang, idioms, and
cultural references.
6. **Prescriptive:** Guide the reader by recommending specific actions and
paths, especially for complex tasks.
## II. Voice and tone
- **Professional yet friendly:** Maintain a helpful, knowledgeable, and
conversational tone without being frivolous.
- **Direct:** Get straight to the point. Keep paragraphs short and focused.
- **Second person:** Address the reader as "you."
- **Present tense:** Use the present tense to describe functionality (e.g., "The
API returns a JSON object.").
- **Avoid:** Jargon, slang, marketing hype, and overly casual language.
## III. Language and grammar
- **Active voice:** Prefer active voice over passive voice.
- _Example:_ "The system sends a notification." (Not: "A notification is sent
by the system.")
- **Contractions:** Use common contractions (e.g., "don't," "it's") to maintain
a natural tone.
- **Simple vocabulary:** Use common words. Define technical terms when
necessary.
- **Conciseness:** Keep sentences short and focused, but don't omit helpful
information.
- **"Please":** Avoid using the word "please."
## IV. Procedures and steps
- Start each step with an imperative verb (e.g., "Connect to the database").
- Number steps sequentially.
- Introduce lists of steps with a complete sentence.
- Put conditions before instructions, not after.
- Provide clear context for where the action takes place (e.g., "In the
administration console...").
- Indicate optional steps clearly (e.g., "Optional: ...").
## V. Formatting and punctuation
- **Text wrap:** Wrap all text at 80 characters, with exceptions for long links
or tables.
- **Headings, titles, and bold text:** Use sentence case. Structure headings
hierarchically.
- **Lists:** Use numbered lists for sequential steps and bulleted lists for all
other lists. Keep list items parallel in structure.
- **Serial comma:** Use the serial comma (e.g., "one, two, and three").
- **Punctuation:** Use standard American punctuation. Place periods inside
quotation marks.
- **Dates:** Use unambiguous date formatting (e.g., "January 22, 2026").
## VI. UI, code, and links
- **UI elements:** Put UI elements in **bold**. Focus on the task when
discussing interaction.
- **Code:** Use `code font` for filenames, code snippets, commands, and API
elements. Use code blocks for multi-line samples.
- **Links:** Use descriptive link text that indicates what the link leads to.
Avoid "click here."
## VII. Word choice and terminology
- **Consistent naming:** Use product and feature names consistently. Always
refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
- **Specific verbs:** Use precise verbs.
- **Avoid:**
- Latin abbreviations (e.g., use "for example" instead of "e.g.").
- Placeholder names like "foo" and "bar" in examples; use meaningful names
instead.
- Anthropomorphism (e.g., "The server thinks...").
- "Should": Be clear about requirements ("must") vs. recommendations ("we
recommend").
## VIII. Files and media
- **Filenames:** Use lowercase letters, separate words with hyphens (-), and use
standard ASCII characters.
- **Images:** Provide descriptive alt text for all images. Provide
high-resolution or vector images when practical.
## IX. Accessibility quick check
- Provide descriptive alt text for images.
- Ensure link text makes sense out of context.
- Use semantic HTML elements correctly (headings, lists, tables).
+1 -1
View File
@@ -45,7 +45,7 @@ The process for contributing code is as follows:
`🔒Maintainers only`, this means it is reserved for project maintainers. We
will not accept pull requests related to these issues. In the near future,
we will explicitly mark issues looking for contributions using the
`help-wanted` label. If you believe an issue is a good candidate for
`help wanted` label. If you believe an issue is a good candidate for
community contribution, please leave a comment on the issue. A maintainer
will review it and apply the `help-wanted` label if appropriate. Only
maintainers should attempt to add the `help-wanted` label to an issue.
-29
View File
@@ -124,8 +124,6 @@ npm install -g @google/gemini-cli@nightly
### Advanced Capabilities
- **Automated Iterative Loops**: Use [Ralph Wiggum mode](./docs/ralph-wiggum.md)
to repeatedly execute prompts until a goal is met (e.g., fixing tests).
- Ground your queries with built-in
[Google Search](https://ai.google.dev/gemini-api/docs/grounding) for real-time
information
@@ -258,33 +256,6 @@ use `--output-format stream-json` to get newline-delimited JSON events:
gemini -p "Run tests and deploy" --output-format stream-json
```
### Ralph Wiggum Mode (Iterative Automation)
Ralph Wiggum mode is an advanced automation feature that allows Gemini CLI to
repeatedly execute a prompt in a loop until a specific goal is achieved. This is
ideal for tasks like fixing failing tests or complex refactoring.
To use Ralph Wiggum mode, provide a prompt and a **completion promise** (a
string to look for in the output). The CLI will:
1. Enter **YOLO mode** to auto-approve all tool calls.
2. Run the prompt and check the response for your completion string.
3. If not found, it repeats the process up to a specified **max iterations**.
4. **Persistent Context**: It uses a **memory file** (`memories.md` by default)
to pass notes between iterations. **Note:** Use a unique `--memory-file` for
different tasks in the same directory to ensure context isolation.
```bash
gemini -p "Fix the failing tests in this repo" \
--ralph-wiggum \
--completion-promise "ALL TESTS PASSED" \
--max-iterations 5 \
--memory-file "task-fix-tests.md"
```
At the end of the run, a summary table displays the result of each iteration and
extracted test statistics.
### Quick Examples
#### Start a new project
-18
View File
@@ -35,9 +35,6 @@ and parameters.
| `--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-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--ralph-wiggum` | - | boolean | `false` | Enable Ralph Wiggum iterative loop mode |
| `--completion-promise` | - | string | - | String to look for to signal completion in Ralph Wiggum mode |
| `--max-iterations` | - | number | `10` | Maximum loop iterations for Ralph Wiggum 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` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
@@ -102,18 +99,3 @@ See [Extensions Documentation](../extensions/index.md) for more details.
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
See [MCP Server Integration](../tools/mcp-server.md) for more details.
## Skills management
| Command | Description | Example |
| -------------------------------- | ------------------------------------- | ------------------------------------------------- |
| `gemini skills list` | List all discovered agent skills | `gemini skills list` |
| `gemini skills install <source>` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` |
| `gemini skills link <path>` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` |
| `gemini skills uninstall <name>` | Uninstall an agent skill | `gemini skills uninstall my-skill` |
| `gemini skills enable <name>` | Enable an agent skill | `gemini skills enable my-skill` |
| `gemini skills disable <name>` | Disable an agent skill | `gemini skills disable my-skill` |
| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` |
| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` |
See [Agent Skills Documentation](./skills.md) for more details.
+1 -1
View File
@@ -228,7 +228,7 @@ Slash commands provide meta-level control over the CLI itself.
[settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
- [**`/rewind`**](./rewind.md)
- **`/rewind`**
- **Description:** Navigates backward through the conversation history,
allowing you to review past interactions and potentially revert to a
previous state. This feature helps in managing complex or branched
-17
View File
@@ -203,23 +203,6 @@ with the actual Gemini CLI process, which inherits the environment variable.
This makes it significantly more difficult for a user to bypass the enforced
settings.
## User isolation in shared environments
In shared compute environments (like ML experiment runners or shared build
servers), you can isolate Gemini CLI state by overriding the user's home
directory.
By default, Gemini CLI stores configuration and history in `~/.gemini`. You can
use the `GEMINI_CLI_HOME` environment variable to point to a unique directory
for a specific user or job. The CLI will create a `.gemini` folder inside the
specified path.
```bash
# Isolate state for a specific job
export GEMINI_CLI_HOME="/tmp/gemini-job-123"
gemini
```
## Restricting tool access
You can significantly enhance security by controlling which tools the Gemini
+26 -29
View File
@@ -39,33 +39,31 @@ they appear in the UI.
### UI
| UI Label | Setting | Description | Default |
| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
@@ -79,7 +77,6 @@ they appear in the UI.
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context
@@ -115,7 +112,7 @@ they appear in the UI.
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `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` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Experimental
+4 -19
View File
@@ -15,7 +15,7 @@ as security auditing, cloud deployments, or codebase migrations—without
cluttering the model's immediate context window.
Gemini autonomously decides when to employ a skill based on your request and the
skill's description. When a relevant skill is identified, the model "pulls in"
skill's description. When a relevant skill is identifieddd, the model "pulls in"
the full instructions and resources required to complete the task using the
`activate_skill` tool.
@@ -52,7 +52,6 @@ locations override lower ones: **Workspace > User > Extension**.
Use the `/skills` slash command to view and manage available expertise:
- `/skills list` (default): Shows all discovered skills and their status.
- `/skills link <path>`: Links agent skills from a local directory via symlink.
- `/skills disable <name>`: Prevents a specific skill from being used.
- `/skills enable <name>`: Re-enables a disabled skill.
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
@@ -68,13 +67,6 @@ The `gemini skills` command provides management utilities:
# List all discovered skills
gemini skills list
# Link agent skills from a local directory via symlink
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills (user)
gemini skills link /path/to/my-skills-repo
# Link to the workspace scope (.gemini/skills)
gemini skills link /path/to/my-skills-repo --scope workspace
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
# Uses the user scope by default (~/.gemini/skills)
gemini skills install https://github.com/user/repo.git
@@ -97,7 +89,7 @@ gemini skills enable my-expertise
gemini skills disable my-expertise --scope workspace
```
## How it Works
## How it Works (Security & Privacy)
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
tiers and injects the name and description of all enabled skills into the
@@ -114,14 +106,7 @@ gemini skills disable my-expertise --scope workspace
5. **Execution**: The model proceeds with the specialized expertise active. It
is instructed to prioritize the skill's procedural guidance within reason.
### Skill activation
Once a skill is activated (typically by Gemini identifying a task that matches
the skill's description and your approval), its specialized instructions and
resources are loaded into the agent's context. A skill remains active and its
guidance is prioritized for the duration of the session.
## Creating your own skills
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
guide.
To create your own skills, see see the
[Create Agent Skills](./creating-skills.md) guide.
+3 -3
View File
@@ -116,7 +116,7 @@ description: Specialized in finding security vulnerabilities in code.
kind: local
tools:
- read_file
- grep_search
- search_file_content
model: gemini-2.5-pro
temperature: 0.2
max_turns: 10
@@ -146,8 +146,8 @@ it yourself; just report it.
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. |
| `timeout_mins` | number | No | Maximum execution time in minutes. |
### Optimizing your sub-agent
+1 -1
View File
@@ -264,7 +264,7 @@ primary ways of releasing extensions are via a Git repository or through GitHub
Releases. Using a public Git repository is the simplest method.
For detailed instructions on both methods, please refer to the
[Extension Releasing Guide](./releasing.md).
[Extension Releasing Guide](./broken-link/releasing.md).
## Conclusion
+1 -23
View File
@@ -170,15 +170,6 @@ their corresponding top-level category object in your `settings.json` file.
available options.
- **Default:** `undefined`
- **`ui.autoThemeSwitching`** (boolean):
- **Description:** Automatically switch between default light and dark themes
based on terminal background color.
- **Default:** `true`
- **`ui.terminalBackgroundPollingInterval`** (number):
- **Description:** Interval in seconds to poll the terminal background color.
- **Default:** `60`
- **`ui.customThemes`** (object):
- **Description:** Custom theme definitions.
- **Default:** `{}`
@@ -335,12 +326,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `0.5`
- **Requires restart:** Yes
- **`model.disableLoopDetection`** (boolean):
- **Description:** Disable automatic detection and prevention of infinite
loops.
- **Default:** `false`
- **Requires restart:** Yes
- **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check.
- **Default:** `true`
@@ -792,7 +777,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`security.environmentVariableRedaction.allowed`** (array):
@@ -1176,13 +1161,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
- By default, this is the user's system home directory. The CLI will create a
`.gemini` folder inside this directory.
- Useful for shared compute environments or keeping CLI state isolated.
- Example: `export GEMINI_CLI_HOME="/path/to/user/config"`
- **`GOOGLE_API_KEY`**:
- Your Google Cloud API key.
- Required for using Vertex AI in express mode.
+2 -2
View File
@@ -167,8 +167,8 @@ case is response validation and automatic retries.
- `reason`: Required if denied. This text is sent **to the agent as a new
prompt** to request a correction.
- `continue`: Set to `false` to **stop the session** without retrying.
- `hookSpecificOutput.clearContext`: If `true`, clears conversation history
(LLM memory) while preserving UI display.
- `clearContext`: If `true`, clears conversation history (LLM memory) while
preserving UI display.
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
turn using `stderr` as the feedback prompt.
+139 -113
View File
@@ -1,123 +1,149 @@
# Gemini CLI documentation
# Welcome to Gemini CLI documentation
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
into your terminal. It is designed to be a terminal-first, extensible, and
powerful tool for developers, engineers, SREs, and beyond.
This documentation provides a comprehensive guide to installing, using, and
developing Gemini CLI, a tool that lets you interact with Gemini models through
a command-line interface.
Gemini CLI integrates with your local environment. It can read and edit files,
execute shell commands, and search the web, all while maintaining your project
context.
## Gemini CLI overview
## Get started
Gemini CLI brings the capabilities of Gemini models to your terminal in an
interactive Read-Eval-Print Loop (REPL) environment. Gemini CLI consists of a
client-side application (`packages/cli`) that communicates with a local server
(`packages/core`), which in turn manages requests to the Gemini API and its AI
models. Gemini CLI also contains a variety of tools for tasks such as performing
file system operations, running shells, and web fetching, which are managed by
`packages/core`.
Begin your journey with Gemini CLI by setting up your environment and learning
the basics.
## Navigating the documentation
- **[Quickstart](./get-started/index.md):** A streamlined guide to get you
chatting in minutes.
- **[Installation](./get-started/installation.md):** Instructions for macOS,
Linux, and Windows.
- **[Authentication](./get-started/authentication.md):** Set up access using
Google OAuth, API keys, or Vertex AI.
- **[Examples](./get-started/examples.md):** View common usage scenarios to
inspire your own workflows.
This documentation is organized into the following sections:
## Use Gemini CLI
### Overview
Master the core capabilities that let Gemini CLI interact with your system
safely and effectively.
- **[Architecture overview](./architecture.md):** Understand the high-level
design of Gemini CLI, including its components and how they interact.
- **[Contribution guide](../CONTRIBUTING.md):** Information for contributors and
developers, including setup, building, testing, and coding conventions.
- **[Using the CLI](./cli/index.md):** Learn the basics of the command-line
interface.
- **[File management](./tools/file-system.md):** Grant the model the ability to
read code and apply changes directly to your files.
- **[Shell commands](./tools/shell.md):** Allow the model to run builds, tests,
and git commands.
- **[Memory management](./tools/memory.md):** Teach Gemini CLI facts about your
project and preferences that persist across sessions.
- **[Project context](./cli/gemini-md.md):** Use `GEMINI.md` files to provide
persistent context for your projects.
- **[Web search and fetch](./tools/web-search.md):** Enable the model to fetch
real-time information from the internet.
- **[Session management](./cli/session-management.md):** Save, resume, and
organize your chat sessions.
### Get started
## Configuration
Customize Gemini CLI to match your workflow and preferences.
- **[Settings](./cli/settings.md):** Control response creativity, output
verbosity, and more.
- **[Model selection](./cli/model.md):** Choose the best Gemini model for your
specific task.
- **[Ignore files](./cli/gemini-ignore.md):** Use `.geminiignore` to keep
sensitive files out of the model's context.
- **[Trusted folders](./cli/trusted-folders.md):** Define security boundaries
for file access and execution.
- **[Token caching](./cli/token-caching.md):** Optimize performance and cost by
caching context.
- **[Themes](./cli/themes.md):** Personalize the visual appearance of the CLI.
## Advanced features
Explore powerful features for complex workflows and enterprise environments.
- **[Headless mode](./cli/headless.md):** Run Gemini CLI in scripts or CI/CD
pipelines for automated reasoning.
- **[Sandboxing](./cli/sandbox.md):** Execute untrusted code or tools in a
secure, isolated container.
- **[Checkpointing](./cli/checkpointing.md):** Save and restore workspace state
to recover from experimental changes.
- **[Custom commands](./cli/custom-commands.md):** Create shortcuts for
frequently used prompts.
- **[System prompt override](./cli/system-prompt.md):** Customize the core
instructions given to the model.
- **[Telemetry](./cli/telemetry.md):** Understand how usage data is collected
and managed.
- **[Enterprise](./cli/enterprise.md):** Manage configurations and policies for
large teams.
## Extensions
Extend Gemini CLI's capabilities with new tools and behaviors using extensions.
- **[Introduction](./extensions/index.md):** Learn about the extension system
and how to manage extensions.
- **[Writing extensions](./extensions/writing-extensions.md):** Learn how to
create your first extension.
- **[Extensions reference](./extensions/reference.md):** Deeply understand the
extension format, commands, and configuration.
- **[Best practices](./extensions/best-practices.md):** Learn strategies for
building great extensions.
- **[Extensions releasing](./extensions/releasing.md):** Learn how to share your
extensions with the world.
## Ecosystem and extensibility
Connect Gemini CLI to external services and other development tools.
- **[MCP servers](./tools/mcp-server.md):** Connect to external services using
the Model Context Protocol.
- **[IDE integration](./ide-integration/index.md):** Use Gemini CLI alongside VS
Code.
- **[Hooks](./hooks/index.md):** (Preview) Write scripts that run on specific
CLI events.
- **[Agent skills](./cli/skills.md):** (Preview) Add specialized expertise and
workflows.
- **[Sub-agents](./core/subagents.md):** (Preview) Delegate tasks to specialized
agents.
## Development and reference
Deep dive into the architecture and contribute to the project.
- **[Architecture](./architecture.md):** Understand the technical design of
- **[Gemini CLI quickstart](./get-started/index.md):** Let's get started with
Gemini CLI.
- **[Command reference](./cli/commands.md):** A complete list of available
commands.
- **[Local development](./local-development.md):** Set up your environment to
contribute to Gemini CLI.
- **[Contributing](../CONTRIBUTING.md):** Learn how to submit pull requests and
report issues.
- **[FAQ](./faq.md):** Answers to common questions.
- **[Troubleshooting](./troubleshooting.md):** Solutions for common issues.
- **[Gemini 3 Pro on Gemini CLI](./get-started/gemini-3.md):** Learn how to
enable and use Gemini 3.
- **[Authentication](./get-started/authentication.md):** Authenticate to Gemini
CLI.
- **[Configuration](./get-started/configuration.md):** Learn how to configure
the CLI.
- **[Installation](./get-started/installation.md):** Install and run Gemini CLI.
- **[Examples](./get-started/examples.md):** Example usage of Gemini CLI.
### CLI
- **[Introduction: Gemini CLI](./cli/index.md):** Overview of the command-line
interface.
- **[Commands](./cli/commands.md):** Description of available CLI commands.
- **[Checkpointing](./cli/checkpointing.md):** Documentation for the
checkpointing feature.
- **[Custom commands](./cli/custom-commands.md):** Create your own commands and
shortcuts for frequently used prompts.
- **[Enterprise](./cli/enterprise.md):** Gemini CLI for enterprise.
- **[Headless mode](./cli/headless.md):** Use Gemini CLI programmatically for
scripting and automation.
- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** A reference for all
keyboard shortcuts to improve your workflow.
- **[Model selection](./cli/model.md):** Select the model used to process your
commands with `/model`.
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
containerized environment.
- **[Agent Skills](./cli/skills.md):** Extend the CLI with specialized expertise
and procedural workflows.
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
behavior and appearance with `/settings`.
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
- **[Themes](./cli/themes.md):** Themes for Gemini CLI.
- **[Token caching](./cli/token-caching.md):** Token caching and optimization.
- **[Trusted Folders](./cli/trusted-folders.md):** An overview of the Trusted
Folders security feature.
- **[Tutorials](./cli/tutorials.md):** Tutorials for Gemini CLI.
- **[Uninstall](./cli/uninstall.md):** Methods for uninstalling the Gemini CLI.
### Core
- **[Introduction: Gemini CLI core](./core/index.md):** Information about Gemini
CLI core.
- **[Memport](./core/memport.md):** Using the Memory Import Processor.
- **[Tools API](./core/tools-api.md):** Information on how the core manages and
exposes tools.
- **[System Prompt Override](./cli/system-prompt.md):** Replace built-in system
instructions using `GEMINI_SYSTEM_MD`.
- **[Policy Engine](./core/policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
### Tools
- **[Introduction: Gemini CLI tools](./tools/index.md):** Information about
Gemini CLI's tools.
- **[File system tools](./tools/file-system.md):** Documentation for the
`read_file` and `write_file` tools.
- **[Shell tool](./tools/shell.md):** Documentation for the `run_shell_command`
tool.
- **[Web fetch tool](./tools/web-fetch.md):** Documentation for the `web_fetch`
tool.
- **[Web search tool](./tools/web-search.md):** Documentation for the
`google_web_search` tool.
- **[Memory tool](./tools/memory.md):** Documentation for the `save_memory`
tool.
- **[Todo tool](./tools/todos.md):** Documentation for the `write_todos` tool.
- **[MCP servers](./tools/mcp-server.md):** Using MCP servers with Gemini CLI.
### Extensions
- **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI
with new functionality.
- **[Writing extensions](./extensions/writing-extensions.md):** Learn how to
build your own extension.
- **[Extension releasing](./extensions/releasing.md):** How to release Gemini
CLI extensions.
### Hooks
- **[Hooks](./hooks/index.md):** Intercept and customize Gemini CLI behavior at
key lifecycle points.
- **[Writing Hooks](./hooks/writing-hooks.md):** Learn how to create your first
hook with a comprehensive example.
- **[Best Practices](./hooks/best-practices.md):** Security, performance, and
debugging guidelines for hooks.
### IDE integration
- **[Introduction to IDE integration](./ide-integration/index.md):** Connect the
CLI to your editor.
- **[IDE companion extension spec](./ide-integration/ide-companion-spec.md):**
Spec for building IDE companion extensions.
### Development
- **[NPM](./npm.md):** Details on how the project's packages are structured.
- **[Releases](./releases.md):** Information on the project's releases and
deployment cadence.
- **[Changelog](./changelogs/index.md):** Highlights and notable changes to
Gemini CLI.
- **[Integration tests](./integration-tests.md):** Information about the
integration testing framework used in this project.
- **[Issue and PR automation](./issue-and-pr-automation.md):** A detailed
overview of the automated processes we use to manage and triage issues and
pull requests.
### Support
- **[FAQ](./faq.md):** Frequently asked questions.
- **[Troubleshooting guide](./troubleshooting.md):** Find solutions to common
problems.
- **[Quota and pricing](./quota-and-pricing.md):** Learn about the free tier and
paid options.
- **[Terms of service and privacy notice](./tos-privacy.md):** Information on
the terms of service and privacy notices applicable to your use of Gemini CLI.
We hope this documentation helps you make the most of Gemini CLI!
-141
View File
@@ -1,141 +0,0 @@
# Ralph Wiggum mode
Ralph Wiggum mode is an iterative automation technique that lets Gemini CLI
repeatedly execute a prompt until a specific goal is met. This mode is designed
for tasks that benefit from persistent refinement, such as fixing failing tests
or performing complex refactoring.
> **Note:** This is a preview feature currently under active development.
## Overview
Inspired by the "Ralph Wiggum" technique, this mode treats failures as data and
uses a feedback loop to reach a successful state. When you enable Ralph Wiggum
mode, Gemini CLI enters YOLO (auto-approval) mode and continues to process the
provided prompt until it detects your specified completion string in the model's
output or reaches the maximum number of iterations.
## Usage
To use Ralph Wiggum mode, you must provide a prompt using the `-p` or `--prompt`
flag. You then configure the loop behavior using the following flags:
| Flag | Description |
| :--------------------- | :--------------------------------------------------------- |
| `--ralph-wiggum` | Enables the Ralph Wiggum iterative loop mode. |
| `--completion-promise` | The string to look for in the output to signal completion. |
| `--max-iterations` | The maximum number of times to run the loop (default: 10). |
| `--memory-file` | Task-specific memory file (default: `memories.md`). |
### Example
The following command attempts to fix tests by running the loop up to 5 times
until the string "TESTS PASSED" appears in the output, using a specific memory
file for this task:
```bash
gemini -p "Fix the tests in packages/core" \
--ralph-wiggum \
--completion-promise "TESTS PASSED" \
--max-iterations 5 \
--memory-file "fix-core-tests.md"
```
## How it works
When you run Gemini CLI with the `--ralph-wiggum` flag, the following process
occurs:
1. **Enforces YOLO mode:** The tool automatically sets the approval mode to
`yolo`. This ensures that tool calls (like writing files or running shell
commands) are approved automatically to allow the automation to proceed
without human intervention.
2. **Iterative execution:** The CLI executes the provided prompt in a loop.
3. **Completion check:** After each iteration, the CLI scans the full text of
the assistant's response for the string provided in `--completion-promise`.
4. **Loop termination:**
- If the completion string is found, the loop exits successfully.
- If the completion string is not found, the CLI starts a new iteration
using the same initial prompt.
- If the number of iterations reaches the `--max-iterations` limit, the loop
stops.
## Persistent context (Memories)
To help the agent learn from previous attempts, Ralph Wiggum mode uses a
`memories.md` file in your current working directory.
- **Automatic creation:** If the file doesn't exist, the CLI creates it with a
default header.
- **Context injection:** At the start of each iteration, the content of
`memories.md` is read and prepended to your prompt.
- **Usage:** You (or the agent, via tool use) can write notes, error logs, or
successful patterns into this file. This allows the agent to "remember" what
failed in iteration 1 and avoid repeating the same mistake in iteration 2.
## Summary statistics
At the end of the execution, Ralph Wiggum mode provides a summary table in the
terminal. This table details the performance of each iteration, including:
- **Iteration number:** The sequence of the run.
- **Status:** Whether the iteration met the completion promise ("Success") or
failed to do so ("Failed").
- **Tests Passed/Failed:** If the output contains recognizable test runner
patterns (such as those from Vitest, Jest, or Mocha), the CLI extracts and
displays the number of passing and failing tests.
### Example summary table
```text
--- Ralph Wiggum Mode Summary ---
| Iteration | Status | Tests Passed | Tests Failed |
|-----------|---------|--------------|--------------|
| 1 | Failed | 2 | 10 |
| 2 | Failed | 8 | 4 |
| 3 | Success | 12 | 0 |
---------------------------------
```
## Best practices
To get the most out of Ralph Wiggum mode, we recommend the following:
- **Clear completion criteria:** Ensure your prompt instructs the model to emit
a specific, unique string (like "ALL TESTS PASSED") only when the task is
truly complete.
- **Incremental goals:** Use prompts that encourage the model to make small,
verifiable changes in each iteration.
- **Safety nets:** Always set a reasonable `--max-iterations` limit to prevent
unintended long-running processes.
## Development and rebuilding
If you're modifying Ralph Wiggum mode or enabling it in a development
environment, you must recompile the TypeScript source code.
### Full rebuild
To build all packages in the monorepo, run the following command from the root
directory:
```bash
npm run build
```
### Fast CLI rebuild
If you've already performed a full build and are only making changes to the CLI
package, you can run a targeted build:
```bash
npm run build -w @google/gemini-cli
```
### Running in development
After rebuilding, test your changes using the `npm run start` script:
```bash
npm run start -- -p "Your task" --ralph-wiggum --completion-promise "SUCCESS"
```
-1
View File
@@ -45,7 +45,6 @@
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
{ "label": "Ralph Wiggum mode", "slug": "docs/ralph-wiggum" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
+6 -5
View File
@@ -117,13 +117,14 @@ directories) will be created.
`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)
## 5. `search_file_content` (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
lines containing matches, along with their file paths and line numbers.
`search_file_content` searches for a regular expression pattern within the
content of files in a specified directory. Can filter files by a glob pattern.
Returns the lines containing matches, along with their file paths and line
numbers.
- **Tool name:** `grep_search`
- **Tool name:** `search_file_content`
- **Display name:** SearchText
- **File:** `grep.ts`
- **Parameters:**
-170
View File
@@ -1,170 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Automated tool use', () => {
/**
* Tests that the agent always utilizes --fix when calling eslint.
* We provide a 'lint' script in the package.json, which helps elicit
* a repro by guiding the agent into using the existing deficient script.
*/
evalTest('USUALLY_PASSES', {
name: 'should use automated tools (eslint --fix) to fix code style issues',
files: {
'package.json': JSON.stringify(
{
name: 'typescript-project',
version: '1.0.0',
type: 'module',
scripts: {
lint: 'eslint .',
},
devDependencies: {
eslint: '^9.0.0',
globals: '^15.0.0',
typescript: '^5.0.0',
'typescript-eslint': '^8.0.0',
'@eslint/js': '^9.0.0',
},
},
null,
2,
),
'eslint.config.js': `
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{
files: ["**/*.{js,mjs,cjs,ts}"],
languageOptions: {
globals: globals.node
}
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"prefer-const": "error",
"@typescript-eslint/no-unused-vars": "off"
}
}
];
`,
'src/app.ts': `
export function main() {
let count = 10;
console.log(count);
}
`,
},
prompt:
'Fix the linter errors in this project. Make sure to avoid interactive commands.',
assert: async (rig) => {
// Check if run_shell_command was used with --fix
const toolCalls = rig.readToolLogs();
const shellCommands = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasFixCommand = shellCommands.some((call) => {
let args = call.toolRequest.args;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
return false;
}
}
const cmd = (args as any)['command'];
return (
cmd &&
(cmd.includes('eslint') || cmd.includes('npm run lint')) &&
cmd.includes('--fix')
);
});
expect(
hasFixCommand,
'Expected agent to use eslint --fix via run_shell_command',
).toBe(true);
},
});
/**
* Tests that the agent uses prettier --write to fix formatting issues in files
* instead of trying to edit the files itself.
*/
evalTest('USUALLY_PASSES', {
name: 'should use automated tools (prettier --write) to fix formatting issues',
files: {
'package.json': JSON.stringify(
{
name: 'typescript-project',
version: '1.0.0',
type: 'module',
scripts: {},
devDependencies: {
prettier: '^3.0.0',
typescript: '^5.0.0',
},
},
null,
2,
),
'.prettierrc': JSON.stringify(
{
semi: true,
singleQuote: true,
},
null,
2,
),
'src/app.ts': `
export function main() {
const data={ name:'test',
val:123
}
console.log(data)
}
`,
},
prompt:
'Fix the formatting errors in this project. Make sure to avoid interactive commands.',
assert: async (rig) => {
// Check if run_shell_command was used with --write
const toolCalls = rig.readToolLogs();
const shellCommands = toolCalls.filter(
(call) => call.toolRequest.name === 'run_shell_command',
);
const hasFixCommand = shellCommands.some((call) => {
let args = call.toolRequest.args;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
return false;
}
}
const cmd = (args as any)['command'];
return (
cmd &&
cmd.includes('prettier') &&
(cmd.includes('--write') || cmd.includes('-w'))
);
});
expect(
hasFixCommand,
'Expected agent to use prettier --write via run_shell_command',
).toBe(true);
},
});
});
-47
View File
@@ -1,47 +0,0 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('interactive_commands', () => {
/**
* Validates that the agent does not use interactive commands unprompted.
* Interactive commands block the progress of the agent, requiring user
* intervention.
*/
evalTest('USUALLY_PASSES', {
name: 'should not use interactive commands',
prompt: 'Execute tests.',
files: {
'package.json': JSON.stringify(
{
name: 'example',
type: 'module',
devDependencies: {
vitest: 'latest',
},
},
null,
2,
),
'example.test.js': `
import { test, expect } from 'vitest';
test('it works', () => {
expect(1 + 1).toBe(2);
});
`,
},
assert: async (rig, result) => {
const logs = rig.readToolLogs();
const vitestCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
l.toolRequest.args.toLowerCase().includes('vitest'),
);
expect(vitestCall, 'Agent should have called vitest').toBeDefined();
expect(
vitestCall?.toolRequest.args,
'Agent should have passed run arg',
).toMatch(/\b(run|--run)\b/);
},
});
});
+1 -65
View File
@@ -7,13 +7,9 @@
import { it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execSync } from 'node:child_process';
import { TestRig } from '@google/gemini-cli-test-utils';
import {
createUnauthorizedToolError,
parseAgentMarkdown,
} from '@google/gemini-cli-core';
import { createUnauthorizedToolError } from '@google/gemini-cli-core';
export * from '@google/gemini-cli-test-utils';
@@ -45,64 +41,11 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
try {
rig.setup(evalCase.name, evalCase.params);
// Symlink node modules to reduce the amount of time needed to
// bootstrap test projects.
const rootNodeModules = path.join(process.cwd(), 'node_modules');
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
if (fs.existsSync(rootNodeModules)) {
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
}
if (evalCase.files) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(rig.testDir!, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
// If it's an agent file, calculate hash for acknowledgement
if (
filePath.startsWith('.gemini/agents/') &&
filePath.endsWith('.md')
) {
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
// Write acknowledged_agents.json to the home directory
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(
ackPath,
JSON.stringify(acknowledgedAgents, null, 2),
);
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
@@ -123,7 +66,6 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
const result = await rig.run({
args: evalCase.prompt,
approvalMode: evalCase.approvalMode ?? 'yolo',
timeout: evalCase.timeout,
env: {
GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile,
},
@@ -146,11 +88,6 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
});
}
if (rig._lastRunStderr) {
const stderrFile = path.join(logDir, `${sanitizedName}.stderr.log`);
await fs.promises.writeFile(stderrFile, rig._lastRunStderr);
}
await fs.promises.writeFile(
logFile,
JSON.stringify(rig.readToolLogs(), null, 2),
@@ -177,7 +114,6 @@ export interface EvalCase {
name: string;
params?: Record<string, any>;
prompt: string;
timeout?: number;
files?: Record<string, string>;
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
assert: (rig: TestRig, result: string) => Promise<void>;
-163
View File
@@ -1,163 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { spawn, ChildProcess } from 'node:child_process';
import { join, resolve } from 'node:path';
import { writeFileSync, mkdirSync } from 'node:fs';
import { Writable, Readable } from 'node:stream';
import { env } from 'node:process';
import * as acp from '@agentclientprotocol/sdk';
const sandboxEnv = env['GEMINI_SANDBOX'];
const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it;
class MockClient implements acp.Client {
updates: acp.SessionNotification[] = [];
sessionUpdate = async (params: acp.SessionNotification) => {
this.updates.push(params);
};
requestPermission = async (): Promise<acp.RequestPermissionResponse> => {
throw new Error('unexpected');
};
}
describe('ACP Environment and Auth', () => {
let rig: TestRig;
let child: ChildProcess | undefined;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
child?.kill();
child = undefined;
await rig.cleanup();
});
itMaybe(
'should load .env from project directory and use the provided API key',
async () => {
rig.setup('acp-env-loading');
// Create a project directory with a .env file containing a recognizable invalid key
const projectDir = resolve(join(rig.testDir!, 'project'));
mkdirSync(projectDir, { recursive: true });
writeFileSync(
join(projectDir, '.env'),
'GEMINI_API_KEY=test-key-from-env\n',
);
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
});
const input = Writable.toWeb(child.stdin!);
const output = Readable.toWeb(
child.stdout!,
) as ReadableStream<Uint8Array>;
const testClient = new MockClient();
const stream = acp.ndJsonStream(input, output);
const connection = new acp.ClientSideConnection(() => testClient, stream);
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
},
});
// 1. newSession should succeed because it finds the key in .env
const { sessionId } = await connection.newSession({
cwd: projectDir,
mcpServers: [],
});
expect(sessionId).toBeDefined();
// 2. prompt should fail because the key is invalid,
// but the error should come from the API, not the internal auth check.
await expect(
connection.prompt({
sessionId,
prompt: [{ type: 'text', text: 'hello' }],
}),
).rejects.toSatisfy((error: unknown) => {
const acpError = error as acp.RequestError;
const errorData = acpError.data as
| { error?: { message?: string } }
| undefined;
const message = String(errorData?.error?.message || acpError.message);
// It should NOT be our internal "Authentication required" message
expect(message).not.toContain('Authentication required');
// It SHOULD be an API error mentioning the invalid key
expect(message).toContain('API key not valid');
return true;
});
child.stdin!.end();
},
);
itMaybe(
'should fail with authRequired when no API key is found',
async () => {
rig.setup('acp-auth-failure');
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_API_KEY: undefined,
VERBOSE: 'true',
},
});
const input = Writable.toWeb(child.stdin!);
const output = Readable.toWeb(
child.stdout!,
) as ReadableStream<Uint8Array>;
const testClient = new MockClient();
const stream = acp.ndJsonStream(input, output);
const connection = new acp.ClientSideConnection(() => testClient, stream);
await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
},
});
await expect(
connection.newSession({
cwd: resolve(rig.testDir!),
mcpServers: [],
}),
).rejects.toMatchObject({
message: expect.stringContaining(
'Gemini API key is missing or not configured.',
),
});
child.stdin!.end();
},
);
});
-4
View File
@@ -28,10 +28,6 @@ class MockConfig {
return true;
}
getFileFilteringRespectGitIgnore() {
return true;
}
getFileFilteringRespectGeminiIgnore() {
return true;
}
+8 -31
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"workspaces": [
"packages/*"
],
@@ -2251,7 +2251,6 @@
"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",
@@ -2432,7 +2431,6 @@
"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"
}
@@ -2466,7 +2464,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2835,7 +2832,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2869,7 +2865,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2922,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4128,7 +4122,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4406,7 +4399,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5399,7 +5391,6 @@
"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"
},
@@ -8409,7 +8400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8950,7 +8940,6 @@
"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",
@@ -10552,7 +10541,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14311,7 +14299,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14322,7 +14309,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16559,7 +16545,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16783,8 +16768,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16792,7 +16776,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16965,7 +16948,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17173,7 +17155,6 @@
"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",
@@ -17287,7 +17268,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17300,7 +17280,6 @@
"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",
@@ -18005,7 +17984,6 @@
"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"
}
@@ -18021,7 +17999,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18077,7 +18055,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18164,7 +18142,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18300,7 +18278,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18323,7 +18300,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18340,7 +18317,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+5 -142
View File
@@ -11,11 +11,6 @@ import type { Settings } from './settings.js';
import {
type ExtensionLoader,
FileDiscoveryService,
getCodeAssistServer,
Config,
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -24,23 +19,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
initialize: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: false,
},
},
}),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
}),
Config: vi.fn().mockImplementation((params) => ({
initialize: vi.fn(),
refreshAuth: vi.fn(),
...params, // Expose params for assertion
})),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
@@ -48,11 +31,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
flush: vi.fn(),
},
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
};
});
@@ -78,121 +56,6 @@ describe('loadConfig', () => {
delete process.env['GEMINI_API_KEY'];
});
describe('admin settings overrides', () => {
it('should not fetch admin controls if experiment is disabled', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
});
describe('when admin controls experiment is enabled', () => {
beforeEach(() => {
// We need to cast to any here to modify the mock implementation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Config as any).mockImplementation((params: unknown) => {
const mockConfig = {
...(params as object),
initialize: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: true,
},
},
}),
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
});
});
it('should fetch admin controls and apply them', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: false,
},
cliFeatureSetting: {
extensionsSetting: {
extensionsEnabled: false,
},
},
strictModeDisabled: false,
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled:
mockAdminSettings.cliFeatureSetting?.extensionsSetting
?.extensionsEnabled,
}),
);
});
it('should treat unset admin settings as false when admin settings are passed', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !false,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
it('should not pass default unset admin settings when no admin settings are present', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
});
it('should fetch admin controls using the code assist server when available', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
strictModeDisabled: true,
};
const mockCodeAssistServer = { projectId: 'test-project' };
vi.mocked(getCodeAssistServer).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCodeAssistServer as any,
);
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
mockCodeAssistServer,
true,
);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
});
});
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
+25 -71
View File
@@ -24,9 +24,6 @@ import {
PREVIEW_GEMINI_MODEL,
homedir,
GitService,
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -127,50 +124,37 @@ export async function loadConfig(
configParams.userMemory = memoryContent;
configParams.geminiMdFileCount = fileCount;
configParams.geminiMdFilePaths = filePaths;
// Set an initial config to use to get a code assist server.
// This is needed to fetch admin controls.
const initialConfig = new Config({
const config = new Config({
...configParams,
});
const codeAssistServer = getCodeAssistServer(initialConfig);
const adminControlsEnabled =
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
?.boolValue ?? false;
// Initialize final config parameters to the previous parameters.
// If no admin controls are needed, these will be used as-is for the final
// config.
const finalConfigParams = { ...configParams };
if (adminControlsEnabled) {
const adminSettings = await fetchAdminControlsOnce(
codeAssistServer,
adminControlsEnabled,
);
// Admin settings are able to be undefined if unset, but if any are present,
// we should initialize them all.
// If any are present, undefined settings should be treated as if they were
// set to false.
// If NONE are present, disregard admin settings entirely, and pass the
// final config as is.
if (Object.keys(adminSettings).length !== 0) {
finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
finalConfigParams.extensionsEnabled =
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
}
}
const config = new Config(finalConfigParams);
// Needed to initialize ToolRegistry, and git checkpointing if enabled
await config.initialize();
startupProfiler.flush(config);
await refreshAuthentication(config, adcFilePath, 'Config');
if (process.env['USE_CCPA']) {
logger.info('[Config] Using CCPA Auth:');
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[Config] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
logger.info(
`[Config] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
} else if (process.env['GEMINI_API_KEY']) {
logger.info('[Config] Using Gemini API Key');
await config.refreshAuth(AuthType.USE_GEMINI);
} else {
const errorMessage =
'[Config] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.';
logger.error(errorMessage);
throw new Error(errorMessage);
}
return config;
}
@@ -238,33 +222,3 @@ function findEnvFile(startDir: string): string | null {
currentDir = parentDir;
}
}
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
} else if (process.env['GEMINI_API_KEY']) {
logger.info(`[${logPrefix}] Using Gemini API Key`);
await config.refreshAuth(AuthType.USE_GEMINI);
} else {
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.28.0-nightly.20260128.adc8e11bb",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+1 -16
View File
@@ -8,10 +8,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { loadSettings } from '../../config/settings.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import {
debugLogger,
type ResolvedExtensionSetting,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
export async function getExtensionManager() {
const workspaceDir = process.cwd();
@@ -38,15 +35,3 @@ export async function getExtensionAndManager(name: string) {
return { extension, extensionManager };
}
export function getFormattedSettingValue(
setting: ResolvedExtensionSetting,
): string {
if (!setting.value) {
return '[not set]';
}
if (setting.sensitive) {
return '***';
}
return setting.value;
}
@@ -21,17 +21,6 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs,
existsSync: vi.fn(actualFs.existsSync),
readFileSync: vi.fn(actualFs.readFileSync),
writeFileSync: vi.fn(actualFs.writeFileSync),
mkdirSync: vi.fn(actualFs.mkdirSync),
};
});
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
@@ -41,14 +30,6 @@ vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(() => ({
isTrusted: true,
source: undefined,
})),
isFolderTrustEnabled: vi.fn(() => false),
}));
describe('mcp remove command', () => {
describe('unit tests with mocks', () => {
let parser: Argv;
-2
View File
@@ -9,7 +9,6 @@ import { listCommand } from './skills/list.js';
import { enableCommand } from './skills/enable.js';
import { disableCommand } from './skills/disable.js';
import { installCommand } from './skills/install.js';
import { linkCommand } from './skills/link.js';
import { uninstallCommand } from './skills/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
@@ -28,7 +27,6 @@ export const skillsCommand: CommandModule = {
.command(defer(enableCommand, 'skills'))
.command(defer(disableCommand, 'skills'))
.command(defer(installCommand, 'skills'))
.command(defer(linkCommand, 'skills'))
.command(defer(uninstallCommand, 'skills'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
@@ -1,69 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleLink, linkCommand } from './link.js';
const mockLinkSkill = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
vi.mock('../../utils/skillUtils.js', () => ({
linkSkill: mockLinkSkill,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: { log: vi.fn(), error: vi.fn() },
}));
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
skillsConsentString: mockSkillsConsentString,
}));
import { debugLogger } from '@google/gemini-cli-core';
describe('skills link command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});
describe('linkCommand', () => {
it('should have correct command and describe', () => {
expect(linkCommand.command).toBe('link <path>');
expect(linkCommand.describe).toContain('Links an agent skill');
});
});
it('should call linkSkill with correct arguments', async () => {
const sourcePath = '/source/path';
mockLinkSkill.mockResolvedValue([
{ name: 'test-skill', location: '/dest/path' },
]);
await handleLink({ path: sourcePath, scope: 'user' });
expect(mockLinkSkill).toHaveBeenCalledWith(
sourcePath,
'user',
expect.any(Function),
expect.any(Function),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Successfully linked skills'),
);
});
it('should handle linkSkill failure', async () => {
mockLinkSkill.mockRejectedValue(new Error('Link failed'));
await handleLink({ path: '/some/path' });
expect(debugLogger.error).toHaveBeenCalledWith('Link failed');
expect(process.exit).toHaveBeenCalledWith(1);
});
});
-93
View File
@@ -1,93 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { getErrorMessage } from '../../utils/errors.js';
import { exitCli } from '../utils.js';
import {
requestConsentNonInteractive,
skillsConsentString,
} from '../../config/extensions/consent.js';
import { linkSkill } from '../../utils/skillUtils.js';
interface LinkArgs {
path: string;
scope?: 'user' | 'workspace';
consent?: boolean;
}
export async function handleLink(args: LinkArgs) {
try {
const { scope = 'user', consent } = args;
await linkSkill(
args.path,
scope,
(msg) => debugLogger.log(msg),
async (skills, targetDir) => {
const consentString = await skillsConsentString(
skills,
args.path,
targetDir,
true,
);
if (consent) {
debugLogger.log('You have consented to the following:');
debugLogger.log(consentString);
return true;
}
return requestConsentNonInteractive(consentString);
},
);
debugLogger.log(chalk.green('\nSuccessfully linked skills.'));
} catch (error) {
debugLogger.error(getErrorMessage(error));
await exitCli(1);
}
}
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
'Links an agent skill from a local path. Updates to the source will be reflected immediately.',
builder: (yargs) =>
yargs
.positional('path', {
describe: 'The local path of the skill to link.',
type: 'string',
demandOption: true,
})
.option('scope', {
describe:
'The scope to link the skill into. Defaults to "user" (global).',
choices: ['user', 'workspace'],
default: 'user',
})
.option('consent', {
describe:
'Acknowledge the security risks of linking a skill and skip the confirmation prompt.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.path) {
throw new Error('The path argument must be provided.');
}
return true;
}),
handler: async (argv) => {
await handleLink({
path: argv['path'] as string,
scope: argv['scope'] as 'user' | 'workspace',
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
},
};
+1 -1
View File
@@ -8,7 +8,7 @@ import { AuthType } from '@google/gemini-cli-core';
import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null {
loadEnvironment(loadSettings().merged, process.cwd());
loadEnvironment(loadSettings().merged);
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.COMPUTE_ADC
+6 -20
View File
@@ -14,7 +14,6 @@ import {
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
type ExtensionLoader,
debugLogger,
ApprovalMode,
@@ -1015,9 +1014,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set([...defaultExcludes, ASK_USER_TOOL_NAME]),
);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => {
@@ -1101,7 +1098,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(SHELL_TOOL_NAME);
expect(excludedTools).toContain(EDIT_TOOL_NAME);
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
@@ -1122,7 +1118,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(SHELL_TOOL_NAME);
expect(excludedTools).toContain(EDIT_TOOL_NAME);
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
@@ -1143,10 +1138,9 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(SHELL_TOOL_NAME);
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => {
it('should exclude no interactive tools in non-interactive mode with yolo approval mode', async () => {
process.argv = [
'node',
'script.js',
@@ -1164,7 +1158,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => {
@@ -1189,10 +1182,9 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(SHELL_TOOL_NAME);
expect(excludedTools).toContain(EDIT_TOOL_NAME);
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should exclude only ask_user in non-interactive mode with legacy yolo flag', async () => {
it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
@@ -1203,7 +1195,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => {
@@ -1228,7 +1219,6 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).not.toContain(ASK_USER_TOOL_NAME);
}
});
@@ -1566,12 +1556,12 @@ describe('loadCliConfig folderTrust', () => {
expect(config.getFolderTrust()).toBe(true);
});
it('should be true by default', async () => {
it('should be false by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getFolderTrust()).toBe(true);
expect(config.getFolderTrust()).toBe(false);
});
});
@@ -1787,7 +1777,6 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getExcludeTools()).not.toContain('ask_user');
});
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
@@ -1802,7 +1791,6 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getExcludeTools()).not.toContain('ask_user');
});
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
@@ -1817,10 +1805,9 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).toContain('run_shell_command');
expect(config.getExcludeTools()).toContain('replace');
expect(config.getExcludeTools()).toContain('write_file');
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude only ask_user in non-interactive mode with YOLO', async () => {
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
const argv = await parseArguments(createTestMergedSettings());
@@ -1832,7 +1819,6 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
+1 -37
View File
@@ -31,7 +31,6 @@ import {
debugLogger,
loadServerHierarchicalMemory,
WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HookDefinition,
@@ -70,11 +69,6 @@ export interface CliArgs {
prompt: string | undefined;
promptInteractive: string | undefined;
ralphWiggum: boolean | undefined;
completionPromise: string | undefined;
maxIterations: number | undefined;
memoryFile: string | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
allowedMcpServerNames: string[] | undefined;
@@ -146,31 +140,6 @@ export async function parseArguments(
description: 'Run in sandbox?',
})
.option('ralph-wiggum', {
alias: 'ralphWiggum',
type: 'boolean',
description:
'Enable Ralph Wiggum mode (iterative loop with YOLO mode).',
})
.option('completion-promise', {
alias: 'completionPromise',
type: 'string',
description:
'The string to look for in the output to signal completion in Ralph Wiggum mode.',
})
.option('max-iterations', {
alias: 'maxIterations',
type: 'number',
description: 'Maximum number of iterations for Ralph Wiggum mode.',
})
.option('memory-file', {
alias: 'memoryFile',
type: 'string',
description:
'Task-specific memory file for Ralph Wiggum mode (defaults to memories.md).',
default: 'memories.md',
})
.option('yolo', {
alias: 'y',
type: 'boolean',
@@ -464,7 +433,7 @@ export async function loadCliConfig(
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
@@ -627,10 +596,6 @@ export async function loadCliConfig(
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
// ask_user requires user interaction and must be excluded in all
// non-interactive modes, regardless of the approval mode.
extraExcludes.push(ASK_USER_TOOL_NAME);
const defaultExcludes = [
SHELL_TOOL_NAME,
EDIT_TOOL_NAME,
@@ -797,7 +762,6 @@ export async function loadCliConfig(
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
disableLoopDetection: settings.model?.disableLoopDetection,
compressionThreshold: settings.model?.compressionThreshold,
folderTrust,
interactive,
@@ -108,7 +108,6 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -147,7 +146,6 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -184,7 +182,6 @@ describe('ExtensionManager Settings Scope', () => {
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
security: { folderTrust: { enabled: false } },
}),
});
@@ -198,7 +195,7 @@ describe('ExtensionManager Settings Scope', () => {
(s) => s.envVar === 'TEST_SETTING',
);
expect(setting).toBeDefined();
expect(setting?.value).toBeUndefined();
expect(setting?.value).toBe('[not set]');
expect(setting?.scope).toBeUndefined();
// Verify output string does not contain scope
@@ -133,7 +133,6 @@ describe('ExtensionManager theme loading', () => {
}),
isTrustedFolder: () => true,
getImportFormat: () => 'tree',
reloadSkills: vi.fn(),
} as unknown as Config;
await extensionManager.start(mockConfig);
@@ -209,7 +208,6 @@ describe('ExtensionManager theme loading', () => {
getAgentRegistry: () => ({
reload: vi.fn().mockResolvedValue(undefined),
}),
reloadSkills: vi.fn(),
} as unknown as Config;
await extensionManager.start(mockConfig);
+7 -3
View File
@@ -70,7 +70,6 @@ import {
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { themeManager } from '../ui/themes/theme-manager.js';
import { getFormattedSettingValue } from '../commands/extensions/utils.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -649,7 +648,12 @@ Would you like to attempt to install via "git clone" instead?`,
resolvedSettings.push({
name: setting.name,
envVar: setting.envVar,
value,
value:
value === undefined
? '[not set]'
: setting.sensitive
? '***'
: value,
sensitive: setting.sensitive ?? false,
scope,
source,
@@ -937,7 +941,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
scope += ')';
}
output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`;
output += `\n ${setting.name}: ${setting.value} ${scope}`;
});
}
return output;
@@ -28,19 +28,14 @@ export async function skillsConsentString(
skills: SkillDefinition[],
source: string,
targetDir?: string,
isLink = false,
): Promise<string> {
const action = isLink ? 'Linking' : 'Installing';
const output: string[] = [];
output.push(`${action} agent skill(s) from "${source}".`);
output.push(
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
);
output.push(`Installing agent skill(s) from "${source}".`);
output.push('\nThe following agent skill(s) will be installed:\n');
output.push(...(await renderSkillsList(skills)));
if (targetDir) {
const destLabel = isLink ? 'Link' : 'Install';
output.push(`${destLabel} Destination: ${targetDir}`);
output.push(`Install Destination: ${targetDir}`);
}
output.push('\n' + SKILLS_WARNING_MESSAGE);
@@ -786,23 +786,6 @@ describe('extensionSettings', () => {
expect(await userKeychain.getSecret('VAR2')).toBeNull();
});
it('should delete a non-sensitive setting if the new value is empty', async () => {
mockRequestSetting.mockResolvedValue('');
await updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).not.toContain('VAR1=');
});
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
mockRequestSetting.mockResolvedValue('');
// Ensure it doesn't exist first
@@ -251,11 +251,7 @@ export async function updateSetting(
}
const parsedEnv = dotenv.parse(envContent);
if (!newValue) {
delete parsedEnv[settingToUpdate.envVar];
} else {
parsedEnv[settingToUpdate.envVar] = newValue;
}
parsedEnv[settingToUpdate.envVar] = newValue;
// We only want to write back the variables that are not sensitive.
const nonSensitiveSettings: Record<string, string> = {};
+190 -556
View File
@@ -29,11 +29,10 @@ vi.mock('./settings.js', async (importActual) => {
});
// Mock trustedFolders
import * as trustedFolders from './trustedFolders.js';
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
isFolderTrustEnabled: vi.fn(),
loadTrustedFolders: vi.fn(),
isWorkspaceTrusted: vi
.fn()
.mockReturnValue({ isTrusted: true, source: 'file' }),
}));
vi.mock('./settingsSchema.js', async (importOriginal) => {
@@ -67,14 +66,13 @@ import {
getSystemSettingsPath,
getSystemDefaultsPath,
type Settings,
type SettingsFile,
saveSettings,
type SettingsFile,
getDefaultsFromSchema,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
LoadedSettings,
sanitizeEnvVar,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -83,7 +81,6 @@ import {
MergeStrategy,
type SettingsSchema,
} from './settingsSchema.js';
import { createMockSettings } from '../test-utils/settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) GEMINI_DIR for consistency
@@ -107,7 +104,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: vi.fn((p: string) => p),
realpathSync: (p: string) => p,
};
});
@@ -121,11 +118,9 @@ const mockCoreEvents = vi.hoisted(() => ({
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const os = await import('node:os');
return {
...actual,
coreEvents: mockCoreEvents,
homedir: vi.fn(() => os.homedir()),
};
});
@@ -156,7 +151,7 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
@@ -1464,44 +1459,6 @@ 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 mockWorkspaceSettingsPath = path.join(
mockSymlinkDir,
GEMINI_DIR,
'settings.json',
);
vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir);
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
const resolved = path.resolve(pStr);
if (
resolved === path.resolve(mockSymlinkDir) ||
resolved === path.resolve(mockHomeDir)
) {
return mockHomeDir;
}
return pStr;
});
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
const settings = loadSettings(mockSymlinkDir);
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
});
});
describe('excludedProjectEnvVars integration', () => {
@@ -1678,7 +1635,7 @@ describe('Settings Loading and Merging', () => {
});
it('should NOT merge workspace settings when workspace is not trusted', () => {
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
@@ -1709,61 +1666,23 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
});
it('should NOT merge workspace settings when workspace trust is undefined', () => {
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: undefined,
source: undefined,
});
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
ui: { theme: 'dark' },
tools: { sandbox: false },
context: { fileName: 'USER.md' },
};
const workspaceSettingsContent = {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.sandbox).toBe(false); // User setting
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
});
});
describe('loadEnvironment', () => {
function setup({
isFolderTrustEnabled = true,
isWorkspaceTrustedValue = true as boolean | undefined,
isWorkspaceTrustedValue = true,
}) {
delete process.env['GEMINI_API_KEY']; // reset
delete process.env['TESTTEST']; // reset
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
);
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
source: 'file',
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normalizedP = path.resolve(p.toString());
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
normalizedP,
);
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
const userSettingsContent: Settings = {
ui: {
theme: 'dark',
@@ -1779,11 +1698,9 @@ describe('Settings Loading and Merging', () => {
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
if (p === geminiEnvPath) return 'TESTTEST=1234';
return '{}';
},
);
@@ -1791,68 +1708,17 @@ describe('Settings Loading and Merging', () => {
it('sets environment variables from .env files', () => {
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
const settings = {
security: { folderTrust: { enabled: false } },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does not load env files when trust is undefined', () => {
delete process.env['TESTTEST'];
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
const settings = {
security: { folderTrust: { enabled: true } },
} as Settings;
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
// TESTTEST is NOT in the whitelist, so it should be blocked.
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => {
const originalArgv = [...process.argv];
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
} finally {
process.argv = originalArgv;
}
});
});
describe('migrateDeprecatedSettings', () => {
@@ -1865,7 +1731,7 @@ describe('Settings Loading and Merging', () => {
mockFsExistsSync.mockReturnValue(true);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockFsReadFileSync.mockReturnValue('{}');
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
@@ -2010,7 +1876,29 @@ describe('Settings Loading and Merging', () => {
},
};
const loadedSettings = createMockSettings(userSettingsContent);
const loadedSettings = new LoadedSettings(
{
path: getSystemSettingsPath(),
settings: {},
originalSettings: {},
},
{
path: getSystemDefaultsPath(),
settings: {},
originalSettings: {},
},
{
path: USER_SETTINGS_PATH,
settings: userSettingsContent as unknown as Settings,
originalSettings: userSettingsContent as unknown as Settings,
},
{
path: MOCK_WORKSPACE_SETTINGS_PATH,
settings: {},
originalSettings: {},
},
true,
);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
@@ -2179,8 +2067,11 @@ describe('Settings Loading and Merging', () => {
describe('saveSettings', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
settingsFile.path = '/mock/settings.json';
const settingsFile = {
path: '/mock/settings.json',
settings: { ui: { theme: 'dark' } },
originalSettings: { ui: { theme: 'dark' } },
} as unknown as SettingsFile;
saveSettings(settingsFile);
@@ -2194,8 +2085,11 @@ describe('Settings Loading and Merging', () => {
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockFsExistsSync.mockReturnValue(false);
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/new/dir/settings.json';
const settingsFile = {
path: '/mock/new/dir/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
saveSettings(settingsFile);
@@ -2212,8 +2106,11 @@ describe('Settings Loading and Merging', () => {
throw error;
});
const settingsFile = createMockSettings({}).user;
settingsFile.path = '/mock/settings.json';
const settingsFile = {
path: '/mock/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
saveSettings(settingsFile);
@@ -2260,11 +2157,8 @@ describe('Settings Loading and Merging', () => {
// 2. Now, set remote admin settings.
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
cliFeatureSetting: {
extensionsSetting: { extensionsEnabled: false },
unmanagedCapabilitiesEnabled: false,
},
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
});
// 3. Verify that remote admin settings take precedence.
@@ -2304,11 +2198,8 @@ describe('Settings Loading and Merging', () => {
const newRemoteSettings = {
strictModeDisabled: false,
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
cliFeatureSetting: {
extensionsSetting: { extensionsEnabled: false },
unmanagedCapabilitiesEnabled: false,
},
mcpSetting: { mcpEnabled: false },
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
};
loadedSettings.setRemoteAdminSettings(newRemoteSettings);
@@ -2319,6 +2210,13 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Non-admin settings should remain untouched
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
// and missing properties revert to schema defaults.
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
});
it('should correctly handle undefined remote admin settings', () => {
@@ -2350,6 +2248,84 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
});
it('should correctly handle missing properties in remote admin settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = {
admin: {
secureModeEnabled: true,
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure initial state from defaults (as file-based admin settings are ignored)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: false,
});
// Verify secureModeEnabled is updated, others default to false
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Set remote settings with only mcpSetting.mcpEnabled
loadedSettings.setRemoteAdminSettings({
mcpSetting: { mcpEnabled: false },
});
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
});
// Verify extensionsEnabled is updated, others remain defaults
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
// Verify that missing strictModeDisabled falls back to secureModeEnabled
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
loadedSettings.setRemoteAdminSettings({
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
// Verify strictModeDisabled takes precedence over secureModeEnabled
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: false,
secureModeEnabled: false,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
loadedSettings.setRemoteAdminSettings({
strictModeDisabled: true,
secureModeEnabled: true,
});
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
});
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
const loadedSettings = loadSettings();
loadedSettings.setRemoteAdminSettings({
@@ -2367,6 +2343,51 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
});
it('should default mcp.enabled to false if mcpSetting is present but mcpEnabled is undefined', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
loadedSettings.setRemoteAdminSettings({
mcpSetting: {},
});
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
});
it('should default extensions.enabled to false if extensionsSetting is present but extensionsEnabled is undefined', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: {
extensionsSetting: {},
},
});
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
});
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
// Mock schema to have secureModeEnabled default to false to verify the override
const originalSchema = getSettingsSchema();
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
modifiedSchema.admin.properties.secureModeEnabled.default = false;
}
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
try {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(() => '{}');
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
// Pass a non-empty object that doesn't have strictModeDisabled
loadedSettings.setRemoteAdminSettings({
mcpSetting: {},
});
// It should be forced to true by the logic (default secure), overriding the mock default of false
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
} finally {
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
}
});
it('should handle completely empty remote admin settings response', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
@@ -2416,391 +2437,4 @@ describe('Settings Loading and Merging', () => {
});
});
});
describe('Security and Sandbox', () => {
let originalArgv: string[];
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalArgv = [...process.argv];
originalEnv = { ...process.env };
// Clear relevant env vars
delete process.env['GEMINI_API_KEY'];
delete process.env['GOOGLE_API_KEY'];
delete process.env['GOOGLE_CLOUD_PROJECT'];
delete process.env['GOOGLE_CLOUD_LOCATION'];
delete process.env['CLOUD_SHELL'];
delete process.env['MALICIOUS_VAR'];
delete process.env['FOO'];
vi.resetAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
});
describe('sandbox detection', () => {
it('should detect sandbox when -s is a real flag', () => {
process.argv = ['node', 'gemini', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'FOO=bar\nGEMINI_API_KEY=secret',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be.
expect(process.env['FOO']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should detect sandbox when --sandbox is a real flag', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret');
});
it('should ignore sandbox flags if they appear after --', () => {
process.argv = ['node', 'gemini', '--', '-s', 'some prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should NOT be tricked by positional arguments that look like flags', () => {
process.argv = ['node', 'gemini', 'my -s prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
describe('env var sanitization', () => {
it('should strictly enforce whitelist in untrusted/sandboxed mode', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=secret-key
MALICIOUS_VAR=should-be-ignored
GOOGLE_API_KEY=another-secret
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('secret-key');
expect(process.env['GOOGLE_API_KEY']).toBe('another-secret');
expect(process.env['MALICIOUS_VAR']).toBeUndefined();
});
it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${maliciousPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '')
expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-');
});
it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => {
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
const complexPayload = 'secret-123/path.to/somewhere;rm -rf /';
vi.mocked(fs.readFileSync).mockReturnValue(
`GEMINI_API_KEY=${complexPayload}`,
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe(
'secret-123/path.to/somewhererm-rf/',
);
});
it('should NOT sanitize variables from trusted sources', () => {
process.argv = ['node', 'gemini', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)');
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
// Trusted source, no sanitization
expect(process.env['FOO']).toBe('$(bar)');
});
it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => {
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(fs.existsSync).mockImplementation((path) =>
path.toString().endsWith('.env'),
);
vi.mocked(fs.readFileSync).mockReturnValue(`
GEMINI_API_KEY=un-sanitized;key!
MALICIOUS_VAR=allowed-because-trusted
`);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!');
expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted');
});
it('should sanitize value in sanitizeEnvVar helper', () => {
expect(sanitizeEnvVar('$(calc)')).toBe('calc');
expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/');
expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123');
expect(sanitizeEnvVar('us-central1')).toBe('us-central1');
});
});
describe('Cloud Shell security', () => {
it('should handle Cloud Shell special defaults securely when untrusted', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
// No .env file
vi.mocked(fs.existsSync).mockReturnValue(false);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
});
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'GOOGLE_CLOUD_PROJECT=attacker-project;inject',
);
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
);
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe(
'attacker-projectinject',
);
});
});
});
});
describe('LoadedSettings Isolation and Serializability', () => {
let loadedSettings: LoadedSettings;
interface TestData {
a: {
b: number;
};
}
beforeEach(() => {
vi.resetAllMocks();
// Create a minimal LoadedSettings instance
const emptyScope = {
path: '/mock/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
loadedSettings = new LoadedSettings(
emptyScope, // system
emptyScope, // systemDefaults
{ ...emptyScope }, // user
emptyScope, // workspace
true, // isTrusted
);
});
describe('setValue Isolation', () => {
it('should isolate state between settings and originalSettings', () => {
const complexValue: TestData = { a: { b: 1 } };
loadedSettings.setValue(SettingScope.User, 'test', complexValue);
const userSettings = loadedSettings.forScope(SettingScope.User);
const settingsValue = (userSettings.settings as Record<string, unknown>)[
'test'
] as TestData;
const originalValue = (
userSettings.originalSettings as Record<string, unknown>
)['test'] as TestData;
// Verify they are equal but different references
expect(settingsValue).toEqual(complexValue);
expect(originalValue).toEqual(complexValue);
expect(settingsValue).not.toBe(complexValue);
expect(originalValue).not.toBe(complexValue);
expect(settingsValue).not.toBe(originalValue);
// Modify the in-memory setting object
settingsValue.a.b = 2;
// originalSettings should NOT be affected
expect(originalValue.a.b).toBe(1);
});
it('should not share references between settings and originalSettings (original servers test)', () => {
const mcpServers = {
'test-server': { command: 'echo' },
};
loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers);
// Modify the original object
delete (mcpServers as Record<string, unknown>)['test-server'];
// The settings in LoadedSettings should still have the server
const userSettings = loadedSettings.forScope(SettingScope.User);
expect(
(userSettings.settings.mcpServers as Record<string, unknown>)[
'test-server'
],
).toBeDefined();
expect(
(userSettings.originalSettings.mcpServers as Record<string, unknown>)[
'test-server'
],
).toBeDefined();
// They should also be different objects from each other
expect(userSettings.settings.mcpServers).not.toBe(
userSettings.originalSettings.mcpServers,
);
});
});
describe('setValue Serializability', () => {
it('should preserve Map/Set types (via structuredClone)', () => {
const mapValue = { myMap: new Map([['key', 'value']]) };
loadedSettings.setValue(SettingScope.User, 'test', mapValue);
const userSettings = loadedSettings.forScope(SettingScope.User);
const settingsValue = (userSettings.settings as Record<string, unknown>)[
'test'
] as { myMap: Map<string, string> };
// Map is preserved by structuredClone
expect(settingsValue.myMap).toBeInstanceOf(Map);
expect(settingsValue.myMap.get('key')).toBe('value');
// But it should be a different reference
expect(settingsValue.myMap).not.toBe(mapValue.myMap);
});
it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => {
const circular: Record<string, unknown> = { a: 1 };
circular['self'] = circular;
// structuredClone(circular) works, but LoadedSettings.setValue calls
// computeMergedSettings() -> customDeepMerge() which blows up on circularity.
expect(() => {
loadedSettings.setValue(SettingScope.User, 'test', circular);
}).toThrow(/Maximum call stack size exceeded/);
});
});
});
+149 -197
View File
@@ -16,7 +16,7 @@ import {
Storage,
coreEvents,
homedir,
type AdminControlsSettings,
type FetchAdminControlsResponse,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
@@ -50,9 +50,7 @@ import {
formatValidationError,
} from './settings-validation.js';
export function getMergeStrategyForPath(
path: string[],
): MergeStrategy | undefined {
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
let parent: SettingDefinition | undefined = undefined;
@@ -77,21 +75,6 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const AUTH_ENV_VAR_WHITELIST = [
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
];
/**
* Sanitizes an environment variable value to prevent shell injection.
* Restricts values to a safe character set: alphanumeric, -, _, ., /
*/
export function sanitizeEnvVar(value: string): string {
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
}
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -292,11 +275,8 @@ export class LoadedSettings {
this.system = system;
this.systemDefaults = systemDefaults;
this.user = user;
this._workspaceFile = workspace;
this.workspace = workspace;
this.isTrusted = isTrusted;
this.workspace = isTrusted
? workspace
: this.createEmptyWorkspace(workspace);
this.errors = errors;
this._merged = this.computeMergedSettings();
}
@@ -304,11 +284,10 @@ export class LoadedSettings {
readonly system: SettingsFile;
readonly systemDefaults: SettingsFile;
readonly user: SettingsFile;
workspace: SettingsFile;
isTrusted: boolean;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
readonly errors: SettingsError[];
private _workspaceFile: SettingsFile;
private _merged: MergedSettings;
private _remoteAdminSettings: Partial<Settings> | undefined;
@@ -316,26 +295,6 @@ export class LoadedSettings {
return this._merged;
}
setTrusted(isTrusted: boolean): void {
if (this.isTrusted === isTrusted) {
return;
}
this.isTrusted = isTrusted;
this.workspace = isTrusted
? this._workspaceFile
: this.createEmptyWorkspace(this._workspaceFile);
this._merged = this.computeMergedSettings();
coreEvents.emitSettingsChanged();
}
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
return {
...workspace,
settings: {},
originalSettings: {},
};
}
private computeMergedSettings(): MergedSettings {
const merged = mergeSettings(
this.system.settings,
@@ -380,30 +339,21 @@ export class LoadedSettings {
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
const settingsFile = this.forScope(scope);
// Clone value to prevent reference sharing between settings and originalSettings
const valueToSet =
typeof value === 'object' && value !== null
? structuredClone(value)
: value;
setNestedProperty(settingsFile.settings, key, valueToSet);
// Use a fresh clone for originalSettings to ensure total independence
setNestedProperty(
settingsFile.originalSettings,
key,
structuredClone(valueToSet),
);
setNestedProperty(settingsFile.settings, key, value);
setNestedProperty(settingsFile.originalSettings, key, value);
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
coreEvents.emitSettingsChanged();
}
setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void {
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
const admin: Settings['admin'] = {};
const { strictModeDisabled, mcpSetting, cliFeatureSetting } =
remoteSettings;
const {
secureModeEnabled,
strictModeDisabled,
mcpSetting,
cliFeatureSetting,
} = remoteSettings;
if (Object.keys(remoteSettings).length === 0) {
this._remoteAdminSettings = { admin };
@@ -411,13 +361,19 @@ export class LoadedSettings {
return;
}
admin.secureModeEnabled = !strictModeDisabled;
admin.mcp = { enabled: mcpSetting?.mcpEnabled };
if (strictModeDisabled !== undefined) {
admin.secureModeEnabled = !strictModeDisabled;
} else if (secureModeEnabled !== undefined) {
admin.secureModeEnabled = secureModeEnabled;
} else {
admin.secureModeEnabled = true;
}
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
admin.extensions = {
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
};
admin.skills = {
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled,
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled ?? false,
};
this._remoteAdminSettings = { admin };
@@ -454,63 +410,38 @@ function findEnvFile(startDir: string): string | null {
}
}
export function setUpCloudShellEnvironment(
envFilePath: string | null,
isTrusted: boolean,
isSandboxed: boolean,
): void {
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
// set by the user using "gcloud config set project" we do not want to
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
// one of the .env files, we set the Cloud Shell-specific default here.
let value = 'cloudshell-gca';
if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
// .env file takes precedence in Cloud Shell
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
if (!isTrusted && isSandboxed) {
value = sanitizeEnvVar(value);
}
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
} else {
// If not in .env, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
} else {
// If no .env file, set to default and override global
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
process.env['GOOGLE_CLOUD_PROJECT'] = value;
}
export function loadEnvironment(
settings: Settings,
workspaceDir: string,
isWorkspaceTrustedFn = isWorkspaceTrusted,
): void {
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
export function loadEnvironment(settings: Settings): void {
const envFilePath = findEnvFile(process.cwd());
const isTrusted = trustResult.isTrusted ?? false;
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
// that happens early in the CLI lifecycle. It is designed to detect the
// sandbox flag before the full command-line parser is initialized to ensure
// security constraints are applied when loading environment variables.
const args = process.argv.slice(2);
const doubleDashIndex = args.indexOf('--');
const relevantArgs =
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
const isSandboxed =
!!settings.tools?.sandbox ||
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
if (!isWorkspaceTrusted(settings).isTrusted) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
setUpCloudShellEnvironment(envFilePath);
}
if (envFilePath) {
@@ -526,16 +457,6 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
// Sanitize the value for untrusted sources
value = sanitizeEnvVar(value);
}
// If it's a project .env file, skip loading excluded variables.
if (isProjectEnvFile && excludedVars.includes(key)) {
continue;
@@ -543,7 +464,7 @@ export function loadEnvironment(
// Load variable only if it's not already set in the environment.
if (!Object.hasOwn(process.env, key)) {
process.env[key] = value;
process.env[key] = parsedEnv[key];
}
}
}
@@ -674,14 +595,12 @@ export function loadSettings(
// For the initial trust check, we can only use user and system settings.
const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
systemDefaultSettings,
userSettings,
{},
systemSettings,
userSettings,
);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
.isTrusted ?? false;
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
// Create a temporary merged settings object to pass to loadEnvironment.
const tempMergedSettings = mergeSettings(
@@ -694,7 +613,7 @@ export function loadSettings(
// loadEnvironment depends on settings so we have to create a temp version of
// the settings to avoid a cycle
loadEnvironment(tempMergedSettings, workspaceDir);
loadEnvironment(tempMergedSettings);
// Check for any fatal errors before proceeding
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
@@ -755,55 +674,57 @@ export function migrateDeprecatedSettings(
removeDeprecated = false,
): boolean {
let anyModified = false;
const migrateBoolean = (
settings: Record<string, unknown>,
oldKey: string,
newKey: string,
): boolean => {
let modified = false;
const oldValue = settings[oldKey];
const newValue = settings[newKey];
if (typeof oldValue === 'boolean') {
if (typeof newValue === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete settings[oldKey];
modified = true;
}
} else {
// Only old exists, migrate to new (inverted)
settings[newKey] = !oldValue;
if (removeDeprecated) {
delete settings[oldKey];
}
modified = true;
}
}
return modified;
};
const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
// Migrate general settings
// Migrate inverted boolean settings (disableX -> enableX)
// These settings were renamed and their boolean logic inverted
const generalSettings = settings.general as
| Record<string, unknown>
| undefined;
const uiSettings = settings.ui as Record<string, unknown> | undefined;
const contextSettings = settings.context as
| Record<string, unknown>
| undefined;
// Migrate general settings (disableAutoUpdate, disableUpdateNag)
if (generalSettings) {
const newGeneral = { ...generalSettings };
const newGeneral: Record<string, unknown> = { ...generalSettings };
let modified = false;
modified =
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
modified;
modified =
migrateBoolean(
newGeneral,
'disableUpdateNag',
'enableAutoUpdateNotification',
) || modified;
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') {
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newGeneral['disableAutoUpdate'];
modified = true;
}
} else {
const oldValue = newGeneral['disableAutoUpdate'];
newGeneral['enableAutoUpdate'] = !oldValue;
if (removeDeprecated) {
delete newGeneral['disableAutoUpdate'];
}
modified = true;
}
}
if (typeof newGeneral['disableUpdateNag'] === 'boolean') {
if (typeof newGeneral['enableAutoUpdateNotification'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newGeneral['disableUpdateNag'];
modified = true;
}
} else {
const oldValue = newGeneral['disableUpdateNag'];
newGeneral['enableAutoUpdateNotification'] = !oldValue;
if (removeDeprecated) {
delete newGeneral['disableUpdateNag'];
}
modified = true;
}
}
if (modified) {
loadedSettings.setValue(scope, 'general', newGeneral);
@@ -812,63 +733,94 @@ export function migrateDeprecatedSettings(
}
// Migrate ui settings
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) {
const newUi = { ...uiSettings };
const newUi: Record<string, unknown> = { ...uiSettings };
let modified = false;
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown>
| undefined;
if (accessibilitySettings) {
const newAccessibility = { ...accessibilitySettings };
if (
accessibilitySettings &&
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
) {
const newAccessibility: Record<string, unknown> = {
...accessibilitySettings,
};
if (
migrateBoolean(
newAccessibility,
'disableLoadingPhrases',
'enableLoadingPhrases',
)
typeof accessibilitySettings['enableLoadingPhrases'] === 'boolean'
) {
// Both exist, trust the new one
if (removeDeprecated) {
delete newAccessibility['disableLoadingPhrases'];
newUi['accessibility'] = newAccessibility;
modified = true;
}
} else {
const oldValue = accessibilitySettings['disableLoadingPhrases'];
newAccessibility['enableLoadingPhrases'] = !oldValue;
if (removeDeprecated) {
delete newAccessibility['disableLoadingPhrases'];
}
newUi['accessibility'] = newAccessibility;
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
modified = true;
}
}
if (modified) {
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
}
}
// Migrate context settings
const contextSettings = settings.context as
| Record<string, unknown>
| undefined;
if (contextSettings) {
const newContext = { ...contextSettings };
const newContext: Record<string, unknown> = { ...contextSettings };
let modified = false;
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown>
| undefined;
if (fileFilteringSettings) {
const newFileFiltering = { ...fileFilteringSettings };
if (
migrateBoolean(
newFileFiltering,
'disableFuzzySearch',
'enableFuzzySearch',
)
) {
if (
fileFilteringSettings &&
typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean'
) {
const newFileFiltering: Record<string, unknown> = {
...fileFilteringSettings,
};
if (typeof fileFilteringSettings['enableFuzzySearch'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newFileFiltering['disableFuzzySearch'];
newContext['fileFiltering'] = newFileFiltering;
modified = true;
}
} else {
const oldValue = fileFilteringSettings['disableFuzzySearch'];
newFileFiltering['enableFuzzySearch'] = !oldValue;
if (removeDeprecated) {
delete newFileFiltering['disableFuzzySearch'];
}
newContext['fileFiltering'] = newFileFiltering;
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
modified = true;
}
}
if (modified) {
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
}
}
// Migrate experimental agent settings
anyModified =
migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
) || anyModified;
anyModified ||= migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
);
};
processScope(SettingScope.User);
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.default,
).toBe(true);
).toBe(false);
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled
.showInDialog,
+1 -31
View File
@@ -351,26 +351,6 @@ const SETTINGS_SCHEMA = {
'The color theme for the UI. See the CLI themes guide for available options.',
showInDialog: false,
},
autoThemeSwitching: {
type: 'boolean',
label: 'Auto Theme Switching',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Automatically switch between default light and dark themes based on terminal background color.',
showInDialog: true,
},
terminalBackgroundPollingInterval: {
type: 'number',
label: 'Terminal Background Polling Interval',
category: 'UI',
requiresRestart: false,
default: 60,
description:
'Interval in seconds to poll the terminal background color.',
showInDialog: true,
},
customThemes: {
type: 'object',
label: 'Custom Themes',
@@ -759,16 +739,6 @@ const SETTINGS_SCHEMA = {
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
},
disableLoopDetection: {
type: 'boolean',
label: 'Disable Loop Detection',
category: 'Model',
requiresRestart: true,
default: false,
description:
'Disable automatic detection and prevention of infinite loops.',
showInDialog: true,
},
skipNextSpeakerCheck: {
type: 'boolean',
label: 'Skip Next Speaker Check',
@@ -1312,7 +1282,7 @@ const SETTINGS_SCHEMA = {
label: 'Folder Trust',
category: 'Security',
requiresRestart: true,
default: true,
default: false,
description: 'Setting to track whether Folder trust is enabled.',
showInDialog: true,
},
+66 -400
View File
@@ -5,11 +5,7 @@
*/
import * as osActual from 'node:os';
import {
FatalConfigError,
ideContextStore,
AuthType,
} from '@google/gemini-cli-core';
import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core';
import {
describe,
it,
@@ -30,9 +26,6 @@ import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
} from './trustedFolders.js';
import { loadEnvironment, getSettingsSchema } from './settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import { validateAuthMethod } from './auth.js';
import type { Settings } from './settings.js';
vi.mock('os', async (importOriginal) => {
@@ -60,7 +53,6 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: vi.fn().mockImplementation((p) => p),
};
});
vi.mock('strip-json-comments', () => ({
@@ -68,23 +60,22 @@ vi.mock('strip-json-comments', () => ({
}));
describe('Trusted Folders Loading', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.readFileSync).mockReturnValue('{}');
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
p.toString(),
);
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}');
});
afterEach(() => {
@@ -99,16 +90,13 @@ describe('Trusted Folders Loading', () => {
describe('isPathTrusted', () => {
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
);
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(config);
return '{}';
},
(mockFsExistsSync as Mock).mockImplementation(
(p) => p === getTrustedFoldersPath(),
);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
return '{}';
});
const folders = loadTrustedFolders();
@@ -136,62 +124,26 @@ describe('Trusted Folders Loading', () => {
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
// No explicit rule covers this file
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
});
it('prioritizes the longest matching path (precedence)', () => {
const { folders } = setup({
config: {
'/a': TrustLevel.TRUST_FOLDER,
'/a/b': TrustLevel.DO_NOT_TRUST,
'/a/b/c': TrustLevel.TRUST_FOLDER,
'/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent
'/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST,
},
});
// /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6).
// /a/b/c wins (TRUST_FOLDER).
expect(folders.isPathTrusted('/a/b/c/d')).toBe(true);
// /a/b/x matches /a (len 2), /a/b (len 4).
// /a/b wins (DO_NOT_TRUST).
expect(folders.isPathTrusted('/a/b/x')).toBe(false);
// /a/x matches /a (len 2).
// /a wins (TRUST_FOLDER).
expect(folders.isPathTrusted('/a/x')).toBe(true);
// Overlap with TRUST_PARENT
// /parent/trustme/butnotthis/file matches:
// - /parent/trustme (len 15, TRUST_PARENT -> effective /parent)
// - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST)
// /parent/trustme/butnotthis wins.
expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe(
false,
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
undefined,
);
// /parent/other matches /parent/trustme (len 15, effective /parent)
expect(folders.isPathTrusted('/parent/other')).toBe(true);
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
undefined,
);
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
});
});
it('should load user rules if only user file exists', () => {
const userPath = getTrustedFoldersPath();
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === userPath,
);
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
const userContent = {
'/user/folder': TrustLevel.TRUST_FOLDER,
};
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === userPath) return JSON.stringify(userContent);
return '{}';
},
);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return JSON.stringify(userContent);
return '{}';
});
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
@@ -202,15 +154,11 @@ describe('Trusted Folders Loading', () => {
it('should handle JSON parsing errors gracefully', () => {
const userPath = getTrustedFoldersPath();
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === userPath,
);
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === userPath) return 'invalid json';
return '{}';
},
);
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return 'invalid json';
return '{}';
});
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([]);
@@ -223,18 +171,14 @@ describe('Trusted Folders Loading', () => {
const customPath = '/custom/path/to/trusted_folders.json';
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === customPath,
);
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
const userContent = {
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
};
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === customPath) return JSON.stringify(userContent);
return '{}';
},
);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === customPath) return JSON.stringify(userContent);
return '{}';
});
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
@@ -277,16 +221,14 @@ describe('isWorkspaceTrusted', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
},
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
});
vi.spyOn(fs, 'existsSync').mockImplementation(
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
(p) => p === getTrustedFoldersPath(),
);
});
@@ -299,14 +241,12 @@ describe('isWorkspaceTrusted', () => {
it('should throw a fatal error if the config is malformed', () => {
mockCwd = '/home/user/projectA';
// This mock needs to be specific to this test to override the one in beforeEach
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
}
return '{}';
},
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
}
return '{}';
});
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/Please fix the configuration file/,
@@ -315,14 +255,12 @@ describe('isWorkspaceTrusted', () => {
it('should throw a fatal error if the config is not a JSON object', () => {
mockCwd = '/home/user/projectA';
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return 'null';
}
return '{}';
},
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return 'null';
}
return '{}';
});
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/not a valid JSON object/,
@@ -365,10 +303,10 @@ describe('isWorkspaceTrusted', () => {
});
});
it('should return false for a child of an untrusted folder', () => {
it('should return undefined for a child of an untrusted folder', () => {
mockCwd = '/home/user/untrusted/src';
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
});
it('should return undefined when no rules match', () => {
@@ -378,24 +316,11 @@ describe('isWorkspaceTrusted', () => {
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
});
it('should prioritize specific distrust over parent trust', () => {
it('should prioritize trust over distrust', () => {
mockCwd = '/home/user/projectA/untrusted';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: false,
source: 'file',
});
});
it('should use workspaceDir instead of process.cwd() when provided', () => {
mockCwd = '/home/user/untrusted';
const workspaceDir = '/home/user/projectA';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
// process.cwd() is untrusted, but workspaceDir is trusted
expect(isWorkspaceTrusted(mockSettings, workspaceDir)).toEqual({
isTrusted: true,
source: 'file',
});
@@ -413,19 +338,6 @@ describe('isWorkspaceTrusted', () => {
});
describe('isWorkspaceTrusted with IDE override', () => {
const mockCwd = '/home/user/projectA';
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) =>
p.toString().endsWith('trustedFolders.json') ? false : true,
);
});
afterEach(() => {
vi.clearAllMocks();
ideContextStore.clear();
@@ -465,15 +377,10 @@ describe('isWorkspaceTrusted with IDE override', () => {
});
it('should fall back to config when ideTrust is undefined', () => {
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
}
return '{}';
});
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
source: 'file',
@@ -499,11 +406,8 @@ describe('isWorkspaceTrusted with IDE override', () => {
describe('Trusted Folders Caching', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('{}');
});
afterEach(() => {
@@ -537,20 +441,14 @@ describe('invalid trust levels', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
},
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
});
vi.spyOn(fs, 'existsSync').mockImplementation(
(p: fs.PathLike) =>
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
(p) => p === getTrustedFoldersPath(),
);
});
@@ -584,235 +482,3 @@ describe('invalid trust levels', () => {
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
});
});
describe('Verification: Auth and Trust Interaction', () => {
let mockCwd: string;
const mockRules: Record<string, TrustLevel> = {};
beforeEach(() => {
vi.stubEnv('GEMINI_API_KEY', '');
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
if (p === path.resolve(mockCwd, '.env')) {
return 'GEMINI_API_KEY=shhh-secret';
}
return '{}';
});
vi.spyOn(fs, 'existsSync').mockImplementation(
(p) =>
p === getTrustedFoldersPath() || p === path.resolve(mockCwd, '.env'),
);
});
afterEach(() => {
vi.unstubAllEnvs();
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
});
it('should verify loadEnvironment returns early and validateAuthMethod fails when untrusted', () => {
// 1. Mock untrusted workspace
mockCwd = '/home/user/untrusted';
mockRules[mockCwd] = TrustLevel.DO_NOT_TRUST;
// 2. Load environment (should return early)
const settings = createMockSettings({
security: { folderTrust: { enabled: true } },
});
loadEnvironment(settings.merged, mockCwd);
// 3. Verify env var NOT loaded
expect(process.env['GEMINI_API_KEY']).toBe('');
// 4. Verify validateAuthMethod fails
const result = validateAuthMethod(AuthType.USE_GEMINI);
expect(result).toContain(
'you must specify the GEMINI_API_KEY environment variable',
);
});
it('should identify if sandbox flag is available in Settings', () => {
const schema = getSettingsSchema();
expect(schema.tools.properties).toBeDefined();
expect('sandbox' in schema.tools.properties).toBe(true);
});
});
describe('Trusted Folders realpath caching', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should only call fs.realpathSync once for the same path', () => {
const mockPath = '/some/path';
const mockRealPath = '/real/path';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockReturnValue(mockRealPath);
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
[mockPath]: TrustLevel.TRUST_FOLDER,
'/another/path': TrustLevel.TRUST_FOLDER,
}),
);
const folders = loadTrustedFolders();
// Call isPathTrusted multiple times with the same path
folders.isPathTrusted(mockPath);
folders.isPathTrusted(mockPath);
folders.isPathTrusted(mockPath);
// fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted)
// And once for each rule in the config (if they are different)
// Let's check calls for mockPath
const mockPathCalls = realpathSpy.mock.calls.filter(
(call) => call[0] === mockPath,
);
expect(mockPathCalls.length).toBe(1);
});
it('should cache results for rule paths in the loop', () => {
const rulePath = '/rule/path';
const locationPath = '/location/path';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
[rulePath]: TrustLevel.TRUST_FOLDER,
}),
);
const folders = loadTrustedFolders();
// First call
folders.isPathTrusted(locationPath);
const firstCallCount = realpathSpy.mock.calls.length;
expect(firstCallCount).toBe(2); // locationPath and rulePath
// Second call with same location and same config
folders.isPathTrusted(locationPath);
const secondCallCount = realpathSpy.mock.calls.length;
// Should still be 2 because both were cached
expect(secondCallCount).toBe(2);
});
});
describe('isWorkspaceTrusted with Symlinks', () => {
const mockSettings: Settings = {
security: {
folderTrust: {
enabled: true,
},
},
};
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should trust a folder even if CWD is a symlink and rule is realpath', () => {
const symlinkPath = '/var/folders/project';
const realPath = '/private/var/folders/project';
vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath);
// Mock fs.existsSync to return true for trust config and both paths
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === getTrustedFoldersPath()) return true;
if (pathStr === symlinkPath) return true;
if (pathStr === realPath) return true;
return false;
});
// Mock realpathSync to resolve symlink to realpath
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === symlinkPath) return realPath;
if (pathStr === realPath) return realPath;
return pathStr;
});
// Rule is saved with realpath
const mockRules = {
[realPath]: TrustLevel.TRUST_FOLDER,
};
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(mockRules);
return '{}';
},
);
// Should be trusted because both resolve to the same realpath
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
});
it('should trust a folder even if CWD is realpath and rule is a symlink', () => {
const symlinkPath = '/var/folders/project';
const realPath = '/private/var/folders/project';
vi.spyOn(process, 'cwd').mockReturnValue(realPath);
// Mock fs.existsSync
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === getTrustedFoldersPath()) return true;
if (pathStr === symlinkPath) return true;
if (pathStr === realPath) return true;
return false;
});
// Mock realpathSync
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === symlinkPath) return realPath;
if (pathStr === realPath) return realPath;
return pathStr;
});
// Rule is saved with symlink path
const mockRules = {
[symlinkPath]: TrustLevel.TRUST_FOLDER,
};
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(mockRules);
return '{}';
},
);
// Should be trusted because both resolve to the same realpath
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
});
});
+36 -66
View File
@@ -36,9 +36,7 @@ export enum TrustLevel {
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
export function isTrustLevel(value: unknown): value is TrustLevel {
return (
typeof value === 'string' &&
Object.values(TrustLevel).includes(value as TrustLevel)
@@ -65,32 +63,6 @@ export interface TrustResult {
source: 'ide' | 'file' | undefined;
}
const realPathCache = new Map<string, string>();
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
@@ -116,36 +88,39 @@ export class LoadedTrustedFolders {
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
const trustedPaths: string[] = [];
const untrustedPaths: string[] = [];
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
if (isWithinRoot(realLocation, realEffectivePath)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
for (const rule of Object.entries(configToUse).map(
([path, trustLevel]) => ({ path, trustLevel }),
)) {
switch (rule.trustLevel) {
case TrustLevel.TRUST_FOLDER:
trustedPaths.push(rule.path);
break;
case TrustLevel.TRUST_PARENT:
trustedPaths.push(path.dirname(rule.path));
break;
case TrustLevel.DO_NOT_TRUST:
untrustedPaths.push(rule.path);
break;
default:
// Do nothing for unknown trust levels.
break;
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
)
return true;
for (const trustedPath of trustedPaths) {
if (isWithinRoot(location, trustedPath)) {
return true;
}
}
for (const untrustedPath of untrustedPaths) {
if (path.normalize(location) === path.normalize(untrustedPath)) {
return false;
}
}
return undefined;
}
@@ -175,7 +150,6 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
@@ -187,13 +161,11 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath();
// Load user trusted folders
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
const parsed: unknown = JSON.parse(stripJsonComments(content));
if (
typeof parsed !== 'object' ||
@@ -218,7 +190,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
}
}
}
} catch (error) {
} catch (error: unknown) {
errors.push({
message: getErrorMessage(error),
path: userPath,
@@ -250,12 +222,11 @@ export function saveTrustedFolders(
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false;
return folderTrustSetting;
}
function getWorkspaceTrustFromLocalConfig(
workspaceDir: string,
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
const folders = loadTrustedFolders();
@@ -270,7 +241,7 @@ function getWorkspaceTrustFromLocalConfig(
);
}
const isTrusted = folders.isPathTrusted(workspaceDir, configToUse);
const isTrusted = folders.isPathTrusted(process.cwd(), configToUse);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
@@ -279,7 +250,6 @@ function getWorkspaceTrustFromLocalConfig(
export function isWorkspaceTrusted(
settings: Settings,
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
): TrustResult {
if (!isFolderTrustEnabled(settings)) {
@@ -292,5 +262,5 @@ export function isWorkspaceTrusted(
}
// Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig);
return getWorkspaceTrustFromLocalConfig(trustConfig);
}
+18 -27
View File
@@ -13,7 +13,7 @@ import {
} from './deferred.js';
import { ExitCodes } from '@google/gemini-cli-core';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import { createMockSettings } from './test-utils/settings.js';
import type { MergedSettings } from './config/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
@@ -46,9 +46,14 @@ describe('deferred', () => {
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
});
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
({
admin: adminSettings,
}) as unknown as MergedSettings;
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings().merged);
await runDeferredCommand(createMockSettings());
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
@@ -61,9 +66,7 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: true } } },
}).merged;
const settings = createMockSettings({ mcp: { enabled: true } });
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockRunExitCleanup).toHaveBeenCalled();
@@ -77,9 +80,7 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
const settings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -97,9 +98,7 @@ describe('deferred', () => {
commandName: 'extensions',
});
const settings = createMockSettings({
merged: { admin: { extensions: { enabled: false } } },
}).merged;
const settings = createMockSettings({ extensions: { enabled: false } });
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -117,9 +116,7 @@ describe('deferred', () => {
commandName: 'skills',
});
const settings = createMockSettings({
merged: { admin: { skills: { enabled: false } } },
}).merged;
const settings = createMockSettings({ skills: { enabled: false } });
await runDeferredCommand(settings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -138,7 +135,7 @@ describe('deferred', () => {
commandName: 'mcp',
});
const settings = createMockSettings({}).merged; // No admin settings
const settings = createMockSettings({}); // No admin settings
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
@@ -166,7 +163,7 @@ describe('deferred', () => {
expect(originalHandler).not.toHaveBeenCalled();
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings().merged);
await runDeferredCommand(createMockSettings());
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
@@ -184,9 +181,7 @@ describe('deferred', () => {
const deferredMcp = defer(commandModule, 'mcp');
await deferredMcp.handler({} as ArgumentsCamelCase);
const mcpSettings = createMockSettings({
merged: { admin: { mcp: { enabled: false } } },
}).merged;
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(mcpSettings);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
@@ -210,14 +205,10 @@ describe('deferred', () => {
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
// and defaulted to 'unknown' (or something else safe).
const settings = createMockSettings({
merged: {
admin: {
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
},
},
}).merged;
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
});
await runDeferredCommand(settings);
File diff suppressed because it is too large Load Diff
+23 -39
View File
@@ -25,7 +25,6 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { runRalphWiggum } from './ralphWiggum.js';
import {
cleanupCheckpoints,
registerCleanup,
@@ -68,7 +67,7 @@ import {
getVersion,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
type FetchAdminControlsResponse,
} from '@google/gemini-cli-core';
import {
initializeApp,
@@ -99,7 +98,6 @@ import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
@@ -230,21 +228,19 @@ export async function startInteractiveUI(
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</ScrollProvider>
</TerminalProvider>
<ScrollProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</SettingsContext.Provider>
@@ -741,25 +737,13 @@ export async function main() {
initializeOutputListenersAndFlush();
if (argv.ralphWiggum) {
await runRalphWiggum({
config,
settings,
input,
prompt_id,
resumedSessionData,
completionPromise: argv.completionPromise,
maxIterations: argv.maxIterations,
});
} else {
await runNonInteractive({
config,
settings,
input,
prompt_id,
resumedSessionData,
});
}
await runNonInteractive({
config,
settings,
input,
prompt_id,
resumedSessionData,
});
// Call cleanup before process.exit, which causes cleanup to not run
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
@@ -822,13 +806,13 @@ export function initializeOutputListenersAndFlush() {
}
function setupAdminControlsListener() {
let pendingSettings: AdminControlsSettings | undefined;
let pendingSettings: FetchAdminControlsResponse | undefined;
let config: Config | undefined;
const messageHandler = (msg: unknown) => {
const message = msg as {
type?: string;
settings?: AdminControlsSettings;
settings?: FetchAdminControlsResponse;
};
if (message?.type === 'admin-settings' && message.settings) {
if (config) {
+6 -19
View File
@@ -47,7 +47,7 @@ import {
} from './utils/errors.js';
import { TextOutput } from './ui/utils/textOutput.js';
export interface RunNonInteractiveParams {
interface RunNonInteractiveParams {
config: Config;
settings: LoadedSettings;
input: string;
@@ -55,15 +55,13 @@ export interface RunNonInteractiveParams {
resumedSessionData?: ResumedSessionData;
}
// Moved to ralphWiggum.ts
export async function runNonInteractive({
config,
settings,
input,
prompt_id,
resumedSessionData,
}: RunNonInteractiveParams): Promise<string> {
}: RunNonInteractiveParams): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
@@ -183,9 +181,6 @@ export async function runNonInteractive({
}
};
// Store accumulated response text to return
let fullResponseText = '';
let errorToHandle: unknown | undefined;
try {
consolePatcher.patch();
@@ -321,13 +316,6 @@ export async function runNonInteractive({
const isRaw =
config.getRawOutput() || config.getAcceptRawOutputRisk();
const output = isRaw ? event.value : stripAnsi(event.value);
// Accumulate full response
if (event.value) {
fullResponseText += event.value;
responseText += output;
}
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
@@ -337,7 +325,7 @@ export async function runNonInteractive({
delta: true,
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
// responseText is already updated
responseText += output;
} else {
if (event.value) {
textOutput.write(output);
@@ -393,7 +381,7 @@ export async function runNonInteractive({
),
});
}
return fullResponseText;
return;
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
@@ -500,7 +488,7 @@ export async function runNonInteractive({
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return fullResponseText;
return;
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
@@ -524,7 +512,7 @@ export async function runNonInteractive({
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return fullResponseText;
return;
}
}
} catch (error) {
@@ -540,6 +528,5 @@ export async function runNonInteractive({
if (errorToHandle) {
handleError(errorToHandle, config);
}
return fullResponseText;
});
}
-67
View File
@@ -1,67 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { runRalphWiggum } from './ralphWiggum.js';
import * as nonInteractiveCli from './nonInteractiveCli.js';
import fs from 'node:fs';
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
import type { LoadedSettings } from './config/settings.js';
// Mock dependencies
vi.mock('node:fs');
vi.mock('./nonInteractiveCli.js');
describe('runRalphWiggum', () => {
const mockConfig = {} as unknown as Config;
const mockSettings = {} as unknown as LoadedSettings;
const mockInput = 'Fix bugs';
const mockPromptId = 'prompt-123';
const mockResumedSessionData = {
conversation: { messages: [], sessionId: 'session-123' },
filePath: 'session.json',
} as unknown as ResumedSessionData;
beforeEach(() => {
vi.resetAllMocks();
// Default mock implementation for fs
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
vi.mocked(fs.readFileSync).mockReturnValue('');
});
it('should preserve resumedSessionData in second iteration', async () => {
// Setup runNonInteractive to return "Failed" first, then "Success"
const runNonInteractiveMock = vi.mocked(
nonInteractiveCli.runNonInteractive,
);
runNonInteractiveMock
.mockResolvedValueOnce('Test failed')
.mockResolvedValueOnce('Test Success');
await runRalphWiggum({
config: mockConfig,
settings: mockSettings,
input: mockInput,
prompt_id: mockPromptId,
resumedSessionData: mockResumedSessionData,
completionPromise: 'Success',
maxIterations: 2,
});
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
// First call should have resumedSessionData
expect(runNonInteractiveMock.mock.calls[0][0].resumedSessionData).toBe(
mockResumedSessionData,
);
// Second call should have resumedSessionData (FIXED)
expect(runNonInteractiveMock.mock.calls[1][0].resumedSessionData).toBe(
mockResumedSessionData,
);
});
});
-180
View File
@@ -1,180 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import {
runNonInteractive,
type RunNonInteractiveParams,
} from './nonInteractiveCli.js';
interface IterationResult {
iteration: number;
status: 'Success' | 'Failed';
testsPassed?: number;
testsFailed?: number;
testsTotal?: number;
}
function extractTestStats(output: string): {
passed?: number;
failed?: number;
total?: number;
} {
// Common patterns for test runners (Vitest, Jest, Mocha, etc.)
const patterns = [
// Vitest/Jest: "Tests: 3 passed, 1 failed, 4 total"
/Tests:\s*(?:(\d+)\s+passed)?(?:,\s*)?(?:(\d+)\s+failed)?(?:,\s*)?(?:(\d+)\s+total)?/i,
// Mocha: "3 passing (10ms)"
/(\d+)\s+passing/i,
// Mocha: "1 failing"
/(\d+)\s+failing/i,
// Generic: "Passed: 3, Failed: 1"
/Passed:\s*(\d+)/i,
/Failed:\s*(\d+)/i,
];
let passed: number | undefined;
let failed: number | undefined;
let total: number | undefined;
// Try Vitest/Jest pattern first as it is most comprehensive
const vitestMatch = output.match(patterns[0]);
if (vitestMatch && (vitestMatch[1] || vitestMatch[2] || vitestMatch[3])) {
passed = vitestMatch[1] ? parseInt(vitestMatch[1], 10) : 0;
failed = vitestMatch[2] ? parseInt(vitestMatch[2], 10) : 0;
total = vitestMatch[3] ? parseInt(vitestMatch[3], 10) : 0;
return { passed, failed, total };
}
// Fallback to individual patterns
const passingMatch = output.match(patterns[1]);
if (passingMatch) {
passed = parseInt(passingMatch[1], 10);
} else {
const passedMatch = output.match(patterns[3]);
if (passedMatch) passed = parseInt(passedMatch[1], 10);
}
const failingMatch = output.match(patterns[2]);
if (failingMatch) {
failed = parseInt(failingMatch[1], 10);
} else {
const failedMatch = output.match(patterns[4]);
if (failedMatch) failed = parseInt(failedMatch[1], 10);
}
return { passed, failed, total };
}
function printSummary(results: IterationResult[]) {
process.stderr.write('\n--- Ralph Wiggum Mode Summary ---\n');
process.stderr.write(
'| Iteration | Status | Tests Passed | Tests Failed |\n',
);
process.stderr.write(
'|-----------|---------|--------------|--------------|\n',
);
for (const result of results) {
const passed = result.testsPassed !== undefined ? result.testsPassed : '-';
const failed = result.testsFailed !== undefined ? result.testsFailed : '-';
process.stderr.write(
`| ${result.iteration.toString().padEnd(9)} | ${result.status.padEnd(7)} | ${passed.toString().padEnd(12)} | ${failed.toString().padEnd(12)} |\n`,
);
}
process.stderr.write('---------------------------------\n\n');
}
export async function runRalphWiggum({
config,
settings,
input,
prompt_id,
resumedSessionData,
completionPromise,
maxIterations,
memoryFile,
}: RunNonInteractiveParams & {
completionPromise?: string;
maxIterations?: number;
memoryFile?: string;
}): Promise<void> {
const effectiveMaxIterations = maxIterations ?? 10;
let iterations = 0;
const currentResumedSessionData = resumedSessionData;
const results: IterationResult[] = [];
const effectiveMemoryFile = memoryFile || 'memories.md';
const memoriesPath = path.join(process.cwd(), effectiveMemoryFile);
if (!fs.existsSync(memoriesPath)) {
fs.writeFileSync(
memoriesPath,
`# Ralph Wiggum Memories\n\nTask: ${input}\n\nUse this file (${effectiveMemoryFile}) to store notes on what worked and what didn't work across iterations. The agent will read this at the start of each run.\n\n`,
);
}
process.stderr.write(
`[Ralph Wiggum] Starting loop. Max iterations: ${effectiveMaxIterations}\n`,
);
while (iterations < effectiveMaxIterations) {
iterations++;
process.stderr.write(
`[Ralph Wiggum] Iteration ${iterations}/${effectiveMaxIterations}\n`,
);
let currentInput = input;
try {
if (fs.existsSync(memoriesPath)) {
const memories = fs.readFileSync(memoriesPath, 'utf-8');
if (memories.trim()) {
currentInput = `Context from previous iterations (${effectiveMemoryFile}):\n${memories}\n\nTask:\n${input}`;
process.stderr.write(
`[Ralph Wiggum] Loaded context from ${effectiveMemoryFile}\n`,
);
}
}
} catch (error) {
process.stderr.write(
`[Ralph Wiggum] Failed to read ${effectiveMemoryFile}: ${error}\n`,
);
}
const output = await runNonInteractive({
config,
settings,
input: currentInput,
prompt_id,
resumedSessionData: currentResumedSessionData,
});
const stats = extractTestStats(output);
const success =
completionPromise && output.includes(completionPromise) ? true : false;
results.push({
iteration: iterations,
status: success ? 'Success' : 'Failed',
testsPassed: stats.passed,
testsFailed: stats.failed,
testsTotal: stats.total,
});
if (success) {
process.stderr.write(
`[Ralph Wiggum] Completion promise "${completionPromise}" met. Exiting.\n`,
);
printSummary(results);
return;
}
// currentResumedSessionData = undefined; // Fixed: Keep resumedSessionData for subsequent iterations
}
process.stderr.write(
`[Ralph Wiggum] Max iterations reached without meeting completion promise.\n`,
);
printSummary(results);
}
@@ -98,17 +98,6 @@ vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
vi.mock('../ui/commands/skillsCommand.js', () => ({
skillsCommand: { name: 'skills' },
}));
vi.mock('../ui/commands/planCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
planCommand: {
name: 'plan',
description: 'Plan command',
kind: CommandKind.BUILT_IN,
},
};
});
vi.mock('../ui/commands/mcpCommand.js', () => ({
mcpCommand: {
name: 'mcp',
@@ -126,7 +115,6 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
@@ -228,22 +216,6 @@ describe('BuiltinCommandLoader', () => {
expect(agentsCmd).toBeDefined();
});
it('should include plan command when plan mode is enabled', async () => {
(mockConfig.isPlanEnabled as Mock).mockReturnValue(true);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const planCmd = commands.find((c) => c.name === 'plan');
expect(planCmd).toBeDefined();
});
it('should exclude plan command when plan mode is disabled', async () => {
(mockConfig.isPlanEnabled as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const planCmd = commands.find((c) => c.name === 'plan');
expect(planCmd).toBeUndefined();
});
it('should exclude agents command when agents are disabled', async () => {
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
@@ -284,7 +256,6 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
@@ -40,9 +40,8 @@ import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { oncallCommand } from '../ui/commands/oncallCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
import { policiesCommand } from '../ui/commands/policiesCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { policiesCommand } from '../ui/commands/policiesCommand.js';
import { profileCommand } from '../ui/commands/profileCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
@@ -143,9 +142,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
...(this.config?.isPlanEnabled() ? [planCommand] : []),
policiesCommand,
privacyCommand,
policiesCommand,
...(isDevelopment ? [profileCommand] : []),
quitCommand,
restoreCommand(this.config),
-178
View File
@@ -1,178 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings, Settings } from '../config/settings.js';
import { createTestMergedSettings } from '../config/settings.js';
/**
* Creates a mocked Config object with default values and allows overrides.
*/
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
isInteractive: vi.fn(() => false),
setTerminalBackground: vi.fn(),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
getDebugMode: vi.fn(() => false),
getProjectRoot: vi.fn(() => '/'),
refreshAuth: vi.fn().mockResolvedValue(undefined),
getRemoteAdminSettings: vi.fn(() => undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getPolicyEngine: vi.fn(() => ({})),
getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })),
getHookSystem: vi.fn(() => ({
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
getToolRegistry: vi.fn().mockReturnValue({
getTools: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
}),
getAgentRegistry: vi.fn().mockReturnValue({}),
getPromptRegistry: vi.fn().mockReturnValue({}),
getResourceRegistry: vi.fn().mockReturnValue({}),
getSkillManager: vi.fn().mockReturnValue({
isAdminEnabled: vi.fn().mockReturnValue(false),
}),
getFileService: vi.fn().mockReturnValue({}),
getGitService: vi.fn().mockResolvedValue({}),
getUserMemory: vi.fn().mockReturnValue(''),
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
getShowMemoryUsage: vi.fn().mockReturnValue(false),
getAccessibility: vi.fn().mockReturnValue({}),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
getTelemetryTarget: vi.fn().mockReturnValue(''),
getTelemetryOutfile: vi.fn().mockReturnValue(undefined),
getTelemetryUseCollector: vi.fn().mockReturnValue(false),
getTelemetryUseCliAuth: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined),
getModelRouterService: vi.fn().mockReturnValue({}),
getModelAvailabilityService: vi.fn().mockReturnValue({}),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true),
getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true),
getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true),
getFileFilteringOptions: vi.fn().mockReturnValue({}),
getCustomExcludes: vi.fn().mockReturnValue([]),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getProxy: vi.fn().mockReturnValue(undefined),
getBugCommand: vi.fn().mockReturnValue(undefined),
getExtensionManagement: vi.fn().mockReturnValue(true),
getExtensionLoader: vi.fn().mockReturnValue({}),
getEnabledExtensions: vi.fn().mockReturnValue([]),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getDisableLLMCorrection: vi.fn().mockReturnValue(false),
getNoBrowser: vi.fn().mockReturnValue(false),
getAgentsSettings: vi.fn().mockReturnValue({}),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getIdeMode: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true),
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
reloadSkills: vi.fn().mockResolvedValue(undefined),
reloadAgents: vi.fn().mockResolvedValue(undefined),
getUseRipgrep: vi.fn().mockReturnValue(false),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
setShellExecutionConfig: vi.fn(),
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
getUseWriteTodos: vi.fn().mockReturnValue(false),
getFileExclusions: vi.fn().mockReturnValue({}),
getEnableHooks: vi.fn().mockReturnValue(true),
getEnableHooksUI: vi.fn().mockReturnValue(true),
getMcpClientManager: vi.fn().mockReturnValue({
getMcpInstructions: vi.fn().mockReturnValue(''),
getMcpServers: vi.fn().mockReturnValue({}),
}),
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
getMaxSessionTurns: vi.fn().mockReturnValue(100),
getExcludeTools: vi.fn().mockReturnValue(new Set()),
getAllowedMcpServers: vi.fn().mockReturnValue([]),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getExperiments: vi.fn().mockReturnValue(undefined),
getPreviewFeatures: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
/**
* Creates a mocked LoadedSettings object for tests.
*/
export function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
(overrides['merged'] as Partial<Settings>) || {},
);
return {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
errors: [],
...overrides,
merged,
} as unknown as LoadedSettings;
}
+25 -15
View File
@@ -10,7 +10,7 @@ import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings } from '../config/settings.js';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
@@ -27,12 +27,10 @@ import {
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
export const persistentStateMock = new FakePersistentState();
@@ -136,6 +134,20 @@ export const mockSettings = new LoadedSettings(
[],
);
export const createMockSettings = (
overrides: Partial<Settings>,
): LoadedSettings => {
const settings = overrides as Settings;
return new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
};
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
@@ -305,18 +317,16 @@ export const renderWithProviders = (
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
-79
View File
@@ -1,79 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
LoadedSettings,
createTestMergedSettings,
type SettingsError,
} from '../config/settings.js';
export interface MockSettingsFile {
settings: any;
originalSettings: any;
path: string;
}
interface CreateMockSettingsOptions {
system?: MockSettingsFile;
systemDefaults?: MockSettingsFile;
user?: MockSettingsFile;
workspace?: MockSettingsFile;
isTrusted?: boolean;
errors?: SettingsError[];
merged?: any;
[key: string]: any;
}
/**
* Creates a mock LoadedSettings object for testing.
*
* @param overrides - Partial settings or LoadedSettings properties to override.
* If 'merged' is provided, it overrides the computed merged settings.
* Any functions in overrides are assigned directly to the LoadedSettings instance.
*/
export const createMockSettings = (
overrides: CreateMockSettingsOptions = {},
): LoadedSettings => {
const {
system,
systemDefaults,
user,
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
const loaded = new LoadedSettings(
(system as any) || { path: '', settings: {}, originalSettings: {} },
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
);
if (mergedOverride) {
// @ts-expect-error - overriding private field for testing
loaded._merged = createTestMergedSettings(mergedOverride);
}
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
(loaded as any)[key] = overrides[key];
}
}
return loaded;
};
+169 -8
View File
@@ -21,6 +21,7 @@ import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
import { MessageType } from './types.js';
import {
type Config,
makeFakeConfig,
@@ -28,6 +29,8 @@ import {
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
UserAccountManager,
type ContentGeneratorConfig,
type AgentDefinition,
} from '@google/gemini-cli-core';
@@ -44,6 +47,11 @@ const mockIdeClient = vi.hoisted(() => ({
getInstance: vi.fn().mockReturnValue(new Promise(() => {})),
}));
// Mock UserAccountManager
const mockUserAccountManager = vi.hoisted(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue(null),
}));
// Mock stdout
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
@@ -73,6 +81,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
UserAccountManager: vi
.fn()
.mockImplementation(() => mockUserAccountManager),
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
@@ -146,12 +157,6 @@ vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/useHookDisplayState.js');
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -180,6 +185,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -254,7 +260,6 @@ describe('AppContainer State Management', () => {
const mockedUseKeypress = useKeypress as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -383,7 +388,6 @@ describe('AppContainer State Management', () => {
currentLoadingPhrase: '',
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
// Mock Config
mockConfig = makeFakeConfig();
@@ -417,6 +421,7 @@ describe('AppContainer State Management', () => {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
showUserIdentity: true,
},
useAlternateBuffer: false,
},
@@ -488,6 +493,162 @@ describe('AppContainer State Management', () => {
});
});
describe('Authentication Check', () => {
it('displays correct message for LOGIN_WITH_GOOGLE auth type', async () => {
// Explicitly mock implementation to ensure we control the instance
(UserAccountManager as unknown as Mock).mockImplementation(
() => mockUserAccountManager,
);
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
'test@example.com',
);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
// Explicitly enable showUserIdentity
mockSettings.merged.ui = {
...mockSettings.merged.ui,
showUserIdentity: true,
};
// Need to ensure config.getContentGeneratorConfig() returns appropriate authType
const authConfig = makeFakeConfig();
// Mock getTargetDir as well since makeFakeConfig might not set it up fully for the component
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
} as unknown as ContentGeneratorConfig);
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
await waitFor(() => {
expect(UserAccountManager).toHaveBeenCalled();
expect(
mockUserAccountManager.getCachedGoogleAccount,
).toHaveBeenCalled();
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Logged in with Google: test@example.com (Plan: Standard Tier)',
}),
);
});
await act(async () => {
unmount!();
});
});
it('displays correct message for USE_GEMINI auth type', async () => {
// Explicitly mock implementation to ensure we control the instance
(UserAccountManager as unknown as Mock).mockImplementation(
() => mockUserAccountManager,
);
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(null);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
const authConfig = makeFakeConfig();
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
} as unknown as ContentGeneratorConfig);
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('Authenticated with gemini-api-key'),
}),
);
});
await act(async () => {
unmount!();
});
});
it('does not display authentication message if showUserIdentity is false', async () => {
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
'test@example.com',
);
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
mockSettings.merged.ui = {
...mockSettings.merged.ui,
showUserIdentity: false,
};
const authConfig = makeFakeConfig();
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
} as unknown as ContentGeneratorConfig);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ config: authConfig });
unmount = result.unmount;
});
// Give it some time to potentially call addItem
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
}),
);
await act(async () => {
unmount!();
});
});
});
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
let unmount: () => void;
+47 -5
View File
@@ -44,6 +44,7 @@ import {
getErrorMessage,
getAllGeminiMdFilenames,
AuthType,
UserAccountManager,
clearCachedCredentialFile,
type ResumedSessionData,
recordExitFail,
@@ -140,7 +141,6 @@ import {
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -190,6 +190,51 @@ export const AppContainer = (props: AppContainerProps) => {
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
const { addItem } = historyManager;
const authCheckPerformed = useRef(false);
useEffect(() => {
if (authCheckPerformed.current) return;
authCheckPerformed.current = true;
if (resumedSessionData || settings.merged.ui.showUserIdentity === false) {
return;
}
const authType = config.getContentGeneratorConfig()?.authType;
// Run this asynchronously to avoid blocking the event loop.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
try {
const userAccountManager = new UserAccountManager();
const email = userAccountManager.getCachedGoogleAccount();
const tierName = config.getUserTierName();
if (authType) {
let message =
authType === AuthType.LOGIN_WITH_GOOGLE
? email
? `Logged in with Google: ${email}`
: 'Logged in with Google'
: `Authenticated with ${authType}`;
if (tierName) {
message += ` (Plan: ${tierName})`;
}
addItem({
type: MessageType.INFO,
text: message,
});
}
} catch (_e) {
// Ignore errors during initial auth check
}
})();
}, [
config,
resumedSessionData,
settings.merged.ui.showUserIdentity,
addItem,
]);
useMemoryMonitor(historyManager);
const isAlternateBuffer = useAlternateBuffer();
@@ -224,7 +269,7 @@ export const AppContainer = (props: AppContainerProps) => {
const activeHooks = useHookDisplayState();
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
() => isWorkspaceTrusted(settings.merged).isTrusted,
isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
@@ -556,9 +601,6 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError,
);
// Poll for terminal background color changes to auto-switch theme
useTerminalTheme(handleThemeSelect, config);
const {
authState,
setAuthState,
@@ -86,11 +86,6 @@ describe('directoryCommand', () => {
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
security: {
folderTrust: {
enabled: false,
},
},
},
},
},
@@ -10,7 +10,6 @@ import {
type OpenCustomDialogActionReturn,
} from './types.js';
import { TriageDuplicates } from '../components/triage/TriageDuplicates.js';
import { TriageIssues } from '../components/triage/TriageIssues.js';
export const oncallCommand: SlashCommand = {
name: 'oncall',
@@ -50,63 +49,5 @@ export const oncallCommand: SlashCommand = {
};
},
},
{
name: 'audit',
description: 'Triage issues labeled as status/need-triage',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args): Promise<OpenCustomDialogActionReturn> => {
const { config } = context.services;
if (!config) {
throw new Error('Config not available');
}
let limit = 100;
let until: string | undefined;
if (args && args.trim().length > 0) {
const argArray = args.trim().split(/\s+/);
for (let i = 0; i < argArray.length; i++) {
const arg = argArray[i];
if (arg === '--until') {
if (i + 1 >= argArray.length) {
throw new Error('Flag --until requires a value (YYYY-MM-DD).');
}
const val = argArray[i + 1];
if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) {
throw new Error(
`Invalid date format for --until: "${val}". Expected YYYY-MM-DD.`,
);
}
until = val;
i++;
} else if (arg.startsWith('--')) {
throw new Error(`Unknown flag: ${arg}`);
} else {
const parsedLimit = parseInt(arg, 10);
if (!isNaN(parsedLimit) && parsedLimit > 0) {
limit = parsedLimit;
} else {
throw new Error(
`Invalid argument: "${arg}". Expected a positive number or --until flag.`,
);
}
}
}
}
return {
type: 'custom_dialog',
component: (
<TriageIssues
config={config}
initialLimit={limit}
until={until}
onExit={() => context.ui.removeComponent()}
/>
),
};
},
},
],
};
@@ -1,118 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { planCommand } from './planCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import {
ApprovalMode,
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
partToString: vi.fn((val) => val),
};
});
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
};
});
describe('planCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
isPlanEnabled: vi.fn(),
setApprovalMode: vi.fn(),
getApprovedPlanPath: vi.fn(),
getApprovalMode: vi.fn(),
getFileSystemService: vi.fn(),
storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
},
},
},
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should have the correct name and description', () => {
expect(planCommand.name).toBe('plan');
expect(planCommand.description).toBe(
'Switch to Plan Mode and view current plan',
);
});
it('should switch to plan mode if enabled', async () => {
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
undefined,
);
if (!planCommand.action) throw new Error('Action missing');
await planCommand.action(mockContext, '');
expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Switched to Plan Mode.',
);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
mockPlanPath,
);
vi.mocked(processSingleFileContent).mockResolvedValue({
llmContent: '# Approved Plan Content',
returnDisplay: '# Approved Plan Content',
} as ProcessedFileReadResult);
if (!planCommand.action) throw new Error('Action missing');
await planCommand.action(mockContext, '');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Approved Plan: approved-plan.md',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.GEMINI,
text: '# Approved Plan Content',
});
});
});
@@ -1,65 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan command: config is not available in context');
return;
}
const previousApprovalMode = config.getApprovalMode();
config.setApprovalMode(ApprovalMode.PLAN);
if (previousApprovalMode !== ApprovalMode.PLAN) {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
return;
}
try {
const content = await processSingleFileContent(
approvedPlanPath,
config.storage.getProjectTempPlansDir(),
config.getFileSystemService(),
);
const fileName = path.basename(approvedPlanPath);
coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`);
context.ui.addItem({
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
}
},
};
@@ -41,8 +41,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual.coreEvents,
emitFeedback: vi.fn(),
},
logRewind: vi.fn(),
RewindEvent: class {},
};
});
+6 -11
View File
@@ -14,16 +14,14 @@ import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { Content } from '@google/genai';
import {
checkExhaustive,
coreEvents,
debugLogger,
logRewind,
RewindEvent,
type ChatRecordingService,
type GeminiClient,
import type {
ChatRecordingService,
GeminiClient,
} from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
/**
* Helper function to handle the core logic of rewinding a conversation.
@@ -146,9 +144,6 @@ export const rewindCommand: SlashCommand = {
context.ui.removeComponent();
}}
onRewind={async (messageId, newText, outcome) => {
if (outcome !== RewindOutcome.Cancel) {
logRewind(config, new RewindEvent(outcome));
}
switch (outcome) {
case RewindOutcome.Cancel:
context.ui.removeComponent();
@@ -17,27 +17,6 @@ import {
type MergedSettings,
} from '../../config/settings.js';
vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/skillUtils.js')>();
return {
...actual,
linkSkill: vi.fn(),
};
});
vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extensions/consent.js')>();
return {
...actual,
requestConsentInteractive: vi.fn().mockResolvedValue(true),
skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'),
};
});
import { linkSkill } from '../../utils/skillUtils.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
@@ -206,80 +185,6 @@ describe('skillsCommand', () => {
expect(lastCall.skills).toHaveLength(2);
});
describe('link', () => {
it('should link a skill successfully', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockResolvedValue([
{ name: 'test-skill', location: '/path' },
]);
await linkCmd.action!(context, '/some/path');
expect(linkSkill).toHaveBeenCalledWith(
'/some/path',
'user',
expect.any(Function),
expect.any(Function),
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Successfully linked skills from "/some/path" (user).',
}),
);
});
it('should link a skill with workspace scope', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockResolvedValue([
{ name: 'test-skill', location: '/path' },
]);
await linkCmd.action!(context, '/some/path --scope workspace');
expect(linkSkill).toHaveBeenCalledWith(
'/some/path',
'workspace',
expect.any(Function),
expect.any(Function),
);
});
it('should show error if link fails', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed'));
await linkCmd.action!(context, '/some/path');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to link skills: Link failed',
}),
);
});
it('should show error if path is missing', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
await linkCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Usage: /skills link <path> [--scope user|workspace]',
}),
);
});
});
describe('disable/enable', () => {
beforeEach(() => {
(
+1 -79
View File
@@ -16,18 +16,10 @@ import {
MessageType,
} from '../types.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import {
linkSkill,
renderSkillActionFeedback,
} from '../../utils/skillUtils.js';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
import { SettingScope } from '../../config/settings.js';
import {
requestConsentInteractive,
skillsConsentString,
} from '../../config/extensions/consent.js';
async function listAction(
context: CommandContext,
@@ -76,69 +68,6 @@ async function listAction(
context.ui.addItem(skillsListItem);
}
async function linkAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const parts = args.trim().split(/\s+/);
const sourcePath = parts[0];
if (!sourcePath) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /skills link <path> [--scope user|workspace]',
});
return;
}
let scopeArg = 'user';
if (parts.length >= 3 && parts[1] === '--scope') {
scopeArg = parts[2];
} else if (parts.length >= 2 && parts[1].startsWith('--scope=')) {
scopeArg = parts[1].split('=')[1];
}
const scope = scopeArg === 'workspace' ? 'workspace' : 'user';
try {
await linkSkill(
sourcePath,
scope,
(msg) =>
context.ui.addItem({
type: MessageType.INFO,
text: msg,
}),
async (skills, targetDir) => {
const consentString = await skillsConsentString(
skills,
sourcePath,
targetDir,
true,
);
return requestConsentInteractive(
consentString,
context.ui.setConfirmationRequest.bind(context.ui),
);
},
);
context.ui.addItem({
type: MessageType.INFO,
text: `Successfully linked skills from "${sourcePath}" (${scope}).`,
});
if (context.services.config) {
await context.services.config.reloadSkills();
}
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to link skills: ${getErrorMessage(error)}`,
});
}
}
async function disableAction(
context: CommandContext,
args: string,
@@ -372,13 +301,6 @@ export const skillsCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: listAction,
},
{
name: 'link',
description:
'Link an agent skill from a local path. Usage: /skills link <path> [--scope user|workspace]',
kind: CommandKind.BUILT_IN,
action: linkAction,
},
{
name: 'disable',
description: 'Disable a skill by name. Usage: /skills disable <name>',
-6
View File
@@ -83,12 +83,6 @@ export interface CommandContext {
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
/**
* Sets a confirmation request to be displayed to the user.
*
* @param value The confirmation request details.
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
};
@@ -7,7 +7,6 @@
import { Box } from 'ink';
import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { UserIdentity } from './UserIdentity.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -41,9 +40,6 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
)}
</>
)}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
</Box>
@@ -10,7 +10,6 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Helper to write to stdin with proper act() wrapping
@@ -942,125 +941,6 @@ describe('AskUserDialog', () => {
});
});
describe('Markdown rendering', () => {
it('auto-bolds plain single-line questions', async () => {
const questions: Question[] = [
{
question: 'Which option do you prefer?',
header: 'Test',
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={40}
/>,
{ width: 120 },
);
await waitFor(() => {
const frame = lastFrame();
// Plain text should be rendered as bold
expect(frame).toContain(chalk.bold('Which option do you prefer?'));
});
});
it('does not auto-bold questions that already have markdown', async () => {
const questions: Question[] = [
{
question: 'Is **this** working?',
header: 'Test',
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={40}
/>,
{ width: 120 },
);
await waitFor(() => {
const frame = lastFrame();
// Should NOT have double-bold (the whole question bolded AND "this" bolded)
// "Is " should not be bold, only "this" should be bold
expect(frame).toContain('Is ');
expect(frame).toContain(chalk.bold('this'));
expect(frame).not.toContain('**this**');
});
});
it('renders bold markdown in question', async () => {
const questions: Question[] = [
{
question: 'Is **this** working?',
header: 'Test',
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={40}
/>,
{ width: 120 },
);
await waitFor(() => {
const frame = lastFrame();
// Check for chalk.bold('this') - asterisks should be gone, text should be bold
expect(frame).toContain(chalk.bold('this'));
expect(frame).not.toContain('**this**');
});
});
it('renders inline code markdown in question', async () => {
const questions: Question[] = [
{
question: 'Run `npm start`?',
header: 'Test',
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={40}
/>,
{ width: 120 },
);
await waitFor(() => {
const frame = lastFrame();
// Backticks should be removed
expect(frame).toContain('npm start');
expect(frame).not.toContain('`npm start`');
});
});
});
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
const questions: Question[] = [
{
@@ -1128,71 +1008,4 @@ describe('AskUserDialog', () => {
// Should contain the full long question (or at least its parts)
expect(lastFrame()).toContain('This is a very long question');
});
describe('Choice question placeholder', () => {
it('uses placeholder for "Other" option when provided', async () => {
const questions: Question[] = [
{
question: 'Select your preferred language:',
header: 'Language',
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
],
placeholder: 'Type another language...',
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>,
{ width: 80 },
);
// Navigate to the "Other" option
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('uses default placeholder when not provided', async () => {
const questions: Question[] = [
{
question: 'Select your preferred language:',
header: 'Language',
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
],
multiSelect: false,
},
];
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>,
{ width: 80 },
);
// Navigate to the "Other" option
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
});
});
@@ -21,66 +21,16 @@ import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
/** Padding for dialog content to prevent text from touching edges. */
const DIALOG_PADDING = 4;
/**
* Checks if text is a single line without markdown identifiers.
*/
function isPlainSingleLine(text: string): boolean {
// Must be a single line (no newlines)
if (text.includes('\n') || text.includes('\r')) {
return false;
}
// Check for common markdown identifiers
const markdownPatterns = [
/^#{1,6}\s/, // Headers
/^[`~]{3,}/, // Code fences
/^[-*+]\s/, // Unordered lists
/^\d+\.\s/, // Ordered lists
/^[-*_]{3,}$/, // Horizontal rules
/\|/, // Tables
/\*\*|__/, // Bold
/(?<!\*)\*(?!\*)/, // Italic (single asterisk not part of bold)
/(?<!_)_(?!_)/, // Italic (single underscore not part of bold)
/`[^`]+`/, // Inline code
/\[.*?\]\(.*?\)/, // Links
/!\[/, // Images
];
for (const pattern of markdownPatterns) {
if (pattern.test(text)) {
return false;
}
}
return true;
}
/**
* Auto-bolds plain single-line text by wrapping in **.
* Returns the text unchanged if it already contains markdown.
*/
function autoBoldIfPlain(text: string): string {
if (isPlainSingleLine(text)) {
return `**${text}**`;
}
return text;
}
interface AskUserDialogState {
answers: { [key: string]: string };
isEditingCustomOption: boolean;
@@ -353,11 +303,9 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
maxWidth={availableWidth}
overflowDirection="bottom"
>
<MarkdownDisplay
text={autoBoldIfPlain(question.question)}
terminalWidth={availableWidth - DIALOG_PADDING}
isPending={false}
/>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
</MaxSizedBox>
</Box>
@@ -786,7 +734,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
: undefined;
const questionHeight =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
? Math.min(15, Math.max(1, listHeight - 4))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
@@ -802,18 +750,15 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
maxWidth={availableWidth}
overflowDirection="bottom"
>
<Box flexDirection="column">
<MarkdownDisplay
text={autoBoldIfPlain(question.question)}
terminalWidth={availableWidth - DIALOG_PADDING}
isPending={false}
/>
<Text bold color={theme.text.primary}>
{question.question}
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
</Box>
</Text>
</MaxSizedBox>
</Box>
@@ -835,7 +780,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
// Render inline text input for custom option
if (optionItem.type === 'other') {
const placeholder = question.placeholder || 'Enter a custom value';
const placeholder = 'Enter a custom value';
return (
<Box flexDirection="row">
{showCheck && (
@@ -888,10 +833,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{optionItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
<RenderInline
text={optionItem.description}
defaultColor={theme.text.secondary}
/>
{optionItem.description}
</Text>
)}
</Box>
@@ -7,7 +7,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { checkExhaustive } from '../../utils/checks.js';
export type ChecklistStatus =
| 'pending'
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { CliSpinner } from './CliSpinner.js';
import { debugState } from '../debug.js';
import { describe, it, expect, beforeEach } from 'vitest';
@@ -15,7 +15,6 @@ import {
} from '../contexts/UIActionsContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
@@ -25,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { mergeSettings } from '../../config/settings.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -168,6 +168,21 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
return {
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
hideFooter: false,
showMemoryUsage: false,
...merged,
},
},
};
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
@@ -192,7 +207,7 @@ describe('Composer', () => {
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: false } });
const settings = createMockSettings({ hideFooter: false });
const { lastFrame } = renderComposer(uiState, settings);
@@ -201,7 +216,7 @@ describe('Composer', () => {
it('does NOT render Footer when hideFooter is true', () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: true } });
const settings = createMockSettings({ hideFooter: true });
const { lastFrame } = renderComposer(uiState, settings);
@@ -230,10 +245,8 @@ describe('Composer', () => {
getDebugMode: vi.fn(() => true),
});
const settings = createMockSettings({
ui: {
hideFooter: false,
showMemoryUsage: true,
},
hideFooter: false,
showMemoryUsage: true,
});
// Mock vim mode for this test
const { useVimMode } = await import('../contexts/VimModeContext.js');
@@ -1,535 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import {
ApprovalMode,
validatePlanContent,
processSingleFileContent,
type FileSystemService,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
validatePlanPath: vi.fn(async () => null),
validatePlanContent: vi.fn(async () => null),
processSingleFileContent: vi.fn(),
};
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
existsSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
describe('ExitPlanModeDialog', () => {
const mockTargetDir = '/mock/project';
const mockPlansDir = '/mock/project/plans';
const mockPlanFullPath = '/mock/project/plans/test-plan.md';
const samplePlanContent = `## Overview
Add user authentication to the CLI application.
## Implementation Steps
1. Create \`src/auth/AuthService.ts\` with login/logout methods
2. Add session storage in \`src/storage/SessionStore.ts\`
3. Update \`src/commands/index.ts\` to check auth status
4. Add tests in \`src/auth/__tests__/\`
## Files to Modify
- \`src/index.ts\` - Add auth middleware
- \`src/config.ts\` - Add auth configuration options`;
const longPlanContent = `## Overview
Implement a comprehensive authentication system with multiple providers.
## Implementation Steps
1. Create \`src/auth/AuthService.ts\` with login/logout methods
2. Add session storage in \`src/storage/SessionStore.ts\`
3. Update \`src/commands/index.ts\` to check auth status
4. Add OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
9. Implement session timeout handling in \`src/auth/SessionManager.ts\`
10. Add audit logging for auth events in \`src/auth/AuditLogger.ts\`
11. Create user profile management in \`src/auth/UserProfile.ts\`
12. Add role-based access control in \`src/auth/RBACService.ts\`
13. Implement password policy enforcement in \`src/auth/PasswordPolicy.ts\`
14. Add brute force protection in \`src/auth/BruteForceGuard.ts\`
15. Create secure cookie handling in \`src/auth/CookieManager.ts\`
## Files to Modify
- \`src/index.ts\` - Add auth middleware
- \`src/config.ts\` - Add auth configuration options
- \`src/routes/api.ts\` - Add auth endpoints
- \`src/middleware/cors.ts\` - Update CORS for auth headers
- \`src/utils/crypto.ts\` - Add encryption utilities
## Testing Strategy
- Unit tests for each auth provider
- Integration tests for full auth flows
- Security penetration testing
- Load testing for session management`;
let onApprove: ReturnType<typeof vi.fn>;
let onFeedback: ReturnType<typeof vi.fn>;
let onCancel: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(processSingleFileContent).mockResolvedValue({
llmContent: samplePlanContent,
returnDisplay: 'Read file',
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
onApprove = vi.fn();
onFeedback = vi.fn();
onCancel = vi.fn();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
const renderDialog = (options?: { useAlternateBuffer?: boolean }) =>
renderWithProviders(
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
width={80}
availableHeight={24}
/>,
{
...options,
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getProjectTempPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
} as unknown as import('@google/gemini-cli-core').Config,
},
);
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
'useAlternateBuffer: $useAlternateBuffer',
({ useAlternateBuffer }) => {
it('renders correctly with plan content', async () => {
const { lastFrame } = renderDialog({ useAlternateBuffer });
// Advance timers to pass the debounce period
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
await waitFor(() => {
expect(processSingleFileContent).toHaveBeenCalledWith(
mockPlanFullPath,
mockPlansDir,
expect.anything(),
);
});
expect(lastFrame()).toMatchSnapshot();
});
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
});
});
it('calls onApprove with DEFAULT when second option is selected', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
});
});
it('calls onFeedback when feedback is typed and submitted', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Navigate to feedback option
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r'); // Select to focus input
// Type feedback
for (const char of 'Add tests') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onFeedback).toHaveBeenCalledWith('Add tests');
});
});
it('calls onCancel when Esc is pressed', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
writeKey(stdin, '\x1b'); // Escape
await act(async () => {
vi.runAllTimers();
});
expect(onCancel).toHaveBeenCalled();
});
it('displays error state when file read fails', async () => {
vi.mocked(processSingleFileContent).mockResolvedValue({
llmContent: '',
returnDisplay: '',
error: 'File not found',
});
const { lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Error reading plan: File not found');
});
expect(lastFrame()).toMatchSnapshot();
});
it('displays error state when plan file is empty', async () => {
vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.');
const { lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain(
'Error reading plan: Plan file is empty.',
);
});
});
it('handles long plan content appropriately', async () => {
vi.mocked(processSingleFileContent).mockResolvedValue({
llmContent: longPlanContent,
returnDisplay: 'Read file',
});
const { lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain(
'Implement a comprehensive authentication system',
);
});
expect(lastFrame()).toMatchSnapshot();
});
it('allows number key quick selection', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Press '2' to select second option directly
writeKey(stdin, '2');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
});
});
it('clears feedback text when Ctrl+C is pressed while editing', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Navigate to feedback option and start typing
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r'); // Select to focus input
// Type some feedback
for (const char of 'test feedback') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toContain('test feedback');
});
// Press Ctrl+C to clear
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
expect(lastFrame()).not.toContain('test feedback');
expect(lastFrame()).toContain('Type your feedback...');
});
// Dialog should still be open (not cancelled)
expect(onCancel).not.toHaveBeenCalled();
});
it('bubbles up Ctrl+C when feedback is empty while editing', async () => {
const onBubbledQuit = vi.fn();
const BubbleListener = ({
children,
}: {
children: React.ReactNode;
}) => {
useKeypress(
(key) => {
if (keyMatchers[Command.QUIT](key)) {
onBubbledQuit();
}
return false;
},
{ isActive: true },
);
return <>{children}</>;
};
const { stdin, lastFrame } = renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
useAlternateBuffer,
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getProjectTempPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
} as unknown as import('@google/gemini-cli-core').Config,
},
);
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Navigate to feedback option
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
// Type some feedback
for (const char of 'test') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toContain('test');
});
// First Ctrl+C to clear text
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
expect(onBubbledQuit).not.toHaveBeenCalled();
// Second Ctrl+C to exit (should bubble)
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
expect(onBubbledQuit).toHaveBeenCalled();
});
expect(onCancel).not.toHaveBeenCalled();
});
it('does not submit empty feedback when Enter is pressed', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Navigate to feedback option
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
// Press Enter without typing anything
writeKey(stdin, '\r');
// Wait a bit to ensure no callback was triggered
await act(async () => {
vi.advanceTimersByTime(50);
});
expect(onFeedback).not.toHaveBeenCalled();
expect(onApprove).not.toHaveBeenCalled();
});
it('allows arrow navigation while typing feedback to change selection', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(lastFrame()).toContain('Add user authentication');
});
// Navigate to feedback option and start typing
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r'); // Select to focus input
// Type some feedback
for (const char of 'test') {
writeKey(stdin, char);
}
// Now use up arrow to navigate back to a different option
writeKey(stdin, '\x1b[A'); // Up arrow
// Press Enter to select the second option (manually accept edits)
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
});
expect(onFeedback).not.toHaveBeenCalled();
});
},
);
});
@@ -1,226 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import {
ApprovalMode,
validatePlanPath,
validatePlanContent,
QuestionType,
type Config,
processSingleFileContent,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
export interface ExitPlanModeDialogProps {
planPath: string;
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
width: number;
availableHeight?: number;
}
enum PlanStatus {
Loading = 'loading',
Loaded = 'loaded',
Error = 'error',
}
interface PlanContentState {
status: PlanStatus;
content?: string;
error?: string;
}
enum ApprovalOption {
Auto = 'Yes, automatically accept edits',
Manual = 'Yes, manually accept edits',
}
/**
* A tiny component for loading and error states with consistent styling.
*/
const StatusMessage: React.FC<{
children: React.ReactNode;
}> = ({ children }) => <Box paddingX={1}>{children}</Box>;
function usePlanContent(planPath: string, config: Config): PlanContentState {
const [state, setState] = useState<PlanContentState>({
status: PlanStatus.Loading,
});
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
const load = async () => {
try {
const pathError = await validatePlanPath(
planPath,
config.storage.getProjectTempPlansDir(),
config.getTargetDir(),
);
if (ignore) return;
if (pathError) {
setState({ status: PlanStatus.Error, error: pathError });
return;
}
const contentError = await validatePlanContent(planPath);
if (ignore) return;
if (contentError) {
setState({ status: PlanStatus.Error, error: contentError });
return;
}
const result = await processSingleFileContent(
planPath,
config.storage.getProjectTempPlansDir(),
config.getFileSystemService(),
);
if (ignore) return;
if (result.error) {
setState({ status: PlanStatus.Error, error: result.error });
return;
}
if (typeof result.llmContent !== 'string') {
setState({
status: PlanStatus.Error,
error: 'Plan file format not supported (binary or image).',
});
return;
}
const content = result.llmContent;
if (!content) {
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
return;
}
setState({ status: PlanStatus.Loaded, content });
} catch (err: unknown) {
if (ignore) return;
const errorMessage = err instanceof Error ? err.message : String(err);
setState({ status: PlanStatus.Error, error: errorMessage });
}
};
void load();
return () => {
ignore = true;
};
}, [planPath, config]);
return state;
}
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
planPath,
onApprove,
onFeedback,
onCancel,
width,
availableHeight,
}) => {
const config = useConfig();
const planState = usePlanContent(planPath, config);
const [showLoading, setShowLoading] = useState(false);
useEffect(() => {
if (planState.status !== PlanStatus.Loading) {
setShowLoading(false);
return;
}
const timer = setTimeout(() => {
setShowLoading(true);
}, 200);
return () => clearTimeout(timer);
}, [planState.status]);
if (planState.status === PlanStatus.Loading) {
if (!showLoading) {
return null;
}
return (
<StatusMessage>
<Text color={theme.text.secondary} italic>
Loading plan...
</Text>
</StatusMessage>
);
}
if (planState.status === PlanStatus.Error) {
return (
<StatusMessage>
<Text color={theme.status.error}>
Error reading plan: {planState.error}
</Text>
</StatusMessage>
);
}
const planContent = planState.content?.trim();
if (!planContent) {
return (
<StatusMessage>
<Text color={theme.status.error}>Error: Plan content is empty.</Text>
</StatusMessage>
);
}
return (
<Box flexDirection="column" width={width}>
<AskUserDialog
questions={[
{
type: QuestionType.CHOICE,
header: 'Approval',
question: planContent,
options: [
{
label: ApprovalOption.Auto,
description:
'Approves plan and allows tools to run automatically',
},
{
label: ApprovalOption.Manual,
description:
'Approves plan but requires confirmation for each tool',
},
],
placeholder: 'Type your feedback...',
multiSelect: false,
},
]}
onSubmit={(answers) => {
const answer = answers['0'];
if (answer === ApprovalOption.Auto) {
onApprove(ApprovalMode.AUTO_EDIT);
} else if (answer === ApprovalOption.Manual) {
onApprove(ApprovalMode.DEFAULT);
} else if (answer) {
onFeedback(answer);
}
}}
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
/>
</Box>
);
};
@@ -32,12 +32,11 @@ vi.mock('node:process', async () => {
describe('FolderTrustDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockedCwd.mockReturnValue('/home/user/project');
});
it('should render the dialog with title and description', () => {
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
@@ -45,12 +44,11 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.',
);
unmount();
});
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = renderWithProviders(
const { lastFrame, stdin } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
@@ -69,27 +67,24 @@ describe('FolderTrustDialog', () => {
);
});
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should display restart message when isRestarting is true', () => {
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
expect(lastFrame()).toContain('Gemini CLI is restarting');
unmount();
});
it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
const { unmount } = renderWithProviders(
renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).toHaveBeenCalled();
unmount();
vi.useRealTimers();
});
@@ -101,7 +96,9 @@ describe('FolderTrustDialog', () => {
);
// Unmount immediately (before 250ms)
unmount();
act(() => {
unmount();
});
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).not.toHaveBeenCalled();
@@ -109,7 +106,7 @@ describe('FolderTrustDialog', () => {
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
const { stdin, unmount } = renderWithProviders(
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
@@ -120,35 +117,31 @@ describe('FolderTrustDialog', () => {
await waitFor(() => {
expect(mockedExit).not.toHaveBeenCalled();
});
unmount();
});
describe('directory display', () => {
it('should correctly display the folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust folder (project)');
unmount();
});
it('should correctly display the parent folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust parent folder (user)');
unmount();
});
it('should correctly display an empty parent folder name for a directory directly under root', () => {
mockedCwd.mockReturnValue('/project');
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
});
});
});
@@ -5,8 +5,10 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { Footer } from './Footer.js';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
@@ -2773,23 +2775,6 @@ describe('InputPrompt', () => {
});
unmount();
});
it('ensures Ctrl+R search results are prioritized newest-to-oldest by reversing userMessages', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
renderWithProviders(<InputPrompt {...props} />);
const calls = vi.mocked(useReverseSearchCompletion).mock.calls;
const commandSearchCall = calls.find(
(call) =>
call[1] === props.userMessages ||
(Array.isArray(call[1]) && call[1][0] === 'newest'),
);
expect(commandSearchCall).toBeDefined();
expect(commandSearchCall![1]).toEqual(['newest', 'middle', 'oldest']);
});
});
describe('Tab focus toggle', () => {
@@ -197,14 +197,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const reversedUserMessages = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
reversedUserMessages,
userMessages,
commandSearchActive,
);
@@ -26,7 +26,6 @@ import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
@@ -59,6 +58,56 @@ enum TerminalKeys {
BACKSPACE = '\u0008',
}
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../config/settingsSchema.js')>();
@@ -590,23 +639,11 @@ describe('SettingsDialog', () => {
});
it('should show different values for different scopes', () => {
const settings = createMockSettings({
user: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
system: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
workspace: {
settings: { autoUpdate: false },
originalSettings: { autoUpdate: false },
path: '',
},
});
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -696,23 +733,11 @@ describe('SettingsDialog', () => {
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings({
user: {
settings: { vimMode: true, hideTips: false },
originalSettings: { vimMode: true, hideTips: false },
path: '',
},
system: {
settings: { hideWindowTitle: true },
originalSettings: { hideWindowTitle: true },
path: '',
},
workspace: {
settings: { ideMode: false },
originalSettings: { ideMode: false },
path: '',
},
});
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -769,13 +794,11 @@ describe('SettingsDialog', () => {
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings({
system: {
settings: { vimMode: true, hideWindowTitle: false },
originalSettings: { vimMode: true, hideWindowTitle: false },
path: '',
},
});
const settings = createMockSettings(
{},
{ vimMode: true, hideWindowTitle: false }, // System settings
{},
);
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -786,18 +809,11 @@ describe('SettingsDialog', () => {
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings({
user: {
settings: { vimMode: false },
originalSettings: { vimMode: false },
path: '',
},
system: {
settings: { vimMode: true },
originalSettings: { vimMode: true },
path: '',
},
});
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{},
);
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -967,13 +983,11 @@ describe('SettingsDialog', () => {
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings({
user: {
settings: { vimMode: null as unknown as boolean },
originalSettings: { vimMode: null as unknown as boolean },
path: '',
},
});
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const onSelect = vi.fn();
const { lastFrame } = renderDialog(settings, onSelect);
@@ -1184,13 +1198,11 @@ describe('SettingsDialog', () => {
stdin.write('\r'); // Commit
});
settings = createMockSettings({
user: {
settings: { 'a.string.setting': 'new value' },
originalSettings: { 'a.string.setting': 'new value' },
path: '',
},
});
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
rerender(
<KeypressProvider>
<SettingsDialog settings={settings} onSelect={onSelect} />
@@ -1538,23 +1550,11 @@ describe('SettingsDialog', () => {
])(
'should render $name correctly',
({ userSettings, systemSettings, workspaceSettings, stdinActions }) => {
const settings = createMockSettings({
user: {
settings: userSettings,
originalSettings: userSettings,
path: '',
},
system: {
settings: systemSettings,
originalSettings: systemSettings,
path: '',
},
workspace: {
settings: workspaceSettings,
originalSettings: workspaceSettings,
path: '',
},
});
const settings = createMockSettings(
userSettings,
systemSettings,
workspaceSettings,
);
const onSelect = vi.fn();
const { lastFrame, stdin } = renderDialog(settings, onSelect);
@@ -11,7 +11,6 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
@@ -66,6 +65,14 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hooksConfig: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
@@ -8,10 +8,52 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ThemeDialog } from './ThemeDialog.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { LoadedSettings } from '../../config/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
const createMockSettings = (
userSettings = {},
workspaceSettings = {},
systemSettings = {},
): LoadedSettings =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, ...systemSettings },
originalSettings: { ui: { customThemes: {} }, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {},
originalSettings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
...userSettings,
},
originalSettings: {
ui: { customThemes: {} },
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
...workspaceSettings,
},
originalSettings: {
ui: { customThemes: {} },
...workspaceSettings,
},
path: '/workspace/settings.json',
},
true,
[],
);
describe('ThemeDialog Snapshots', () => {
const baseProps = {
onSelect: vi.fn(),
@@ -25,7 +25,6 @@ function getConfirmationHeader(
Record<SerializableConfirmationDetails['type'], string>
> = {
ask_user: 'Answer Questions',
exit_plan_mode: 'Ready to start implementation?',
};
if (!details?.type) {
return 'Action Required';
@@ -71,9 +70,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
return (
<OverflowProvider>
@@ -1,139 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { UserIdentity } from './UserIdentity.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
makeFakeConfig,
AuthType,
UserAccountManager,
type ContentGeneratorConfig,
} from '@google/gemini-cli-core';
// Mock UserAccountManager to control cached account
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
UserAccountManager: vi.fn().mockImplementation(() => ({
getCachedGoogleAccount: () => 'test@example.com',
})),
};
});
describe('<UserIdentity />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render login message and auth indicator', () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
const output = lastFrame();
expect(output).toContain('Logged in with Google: test@example.com');
expect(output).toContain('/auth');
unmount();
});
it('should render login message without colon if email is missing', () => {
// Modify the mock for this specific test
vi.mocked(UserAccountManager).mockImplementationOnce(
() =>
({
getCachedGoogleAccount: () => undefined,
}) as unknown as UserAccountManager,
);
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
const output = lastFrame();
expect(output).toContain('Logged in with Google');
expect(output).not.toContain('Logged in with Google:');
expect(output).toContain('/auth');
unmount();
});
it('should render plan name on a separate line if provided', () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Premium Plan');
const { lastFrame, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
const output = lastFrame();
expect(output).toContain('Logged in with Google: test@example.com');
expect(output).toContain('/auth');
expect(output).toContain('Plan: Premium Plan');
// Check for two lines (or more if wrapped, but here it should be separate)
const lines = output?.split('\n').filter((line) => line.trim().length > 0);
expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe(
true,
);
expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
true,
);
unmount();
});
it('should not render if authType is missing', () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue(
{} as unknown as ContentGeneratorConfig,
);
const { lastFrame, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
expect(lastFrame()).toBe('');
unmount();
});
it('should render non-Google auth message', () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.USE_GEMINI,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
const { lastFrame, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
expect(output).toContain('/auth');
unmount();
});
});
@@ -1,61 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
type Config,
UserAccountManager,
AuthType,
} from '@google/gemini-cli-core';
interface UserIdentityProps {
config: Config;
}
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
const authType = config.getContentGeneratorConfig()?.authType;
const { email, tierName } = useMemo(() => {
if (!authType) {
return { email: undefined, tierName: undefined };
}
const userAccountManager = new UserAccountManager();
return {
email: userAccountManager.getCachedGoogleAccount(),
tierName: config.getUserTierName(),
};
}, [config, authType]);
if (!authType) {
return null;
}
return (
<Box marginY={1} flexDirection="column">
<Box>
<Text color={theme.text.primary}>
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
<Text>
<Text bold>Logged in with Google{email ? ':' : ''}</Text>
{email ? ` ${email}` : ''}
</Text>
) : (
`Authenticated with ${authType}`
)}
</Text>
<Text color={theme.text.secondary}> /auth</Text>
</Box>
{tierName && (
<Text color={theme.text.primary}>
<Text bold>Plan:</Text> {tierName}
</Text>
)}
</Box>
);
};
@@ -1,25 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 1`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Enter a custom value
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
"Select your preferred language:
1. TypeScript
2. JavaScript
● 3. Type another language...
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
@@ -1,234 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback... ✓
Enter to submit · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests ✓
Enter to submit · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > handles long plan content appropriately 1`] = `
"Overview
Implement a comprehensive authentication system with multiple providers.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts
5. Add SAML provider support in src/auth/providers/SAMLProvider.ts
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden ...
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders correctly with plan content 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Type your feedback... ✓
Enter to submit · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
● 3. Add tests ✓
Enter to submit · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > handles long plan content appropriately 1`] = `
"Overview
Implement a comprehensive authentication system with multiple providers.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts
5. Add SAML provider support in src/auth/providers/SAMLProvider.ts
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
9. Implement session timeout handling in src/auth/SessionManager.ts
10. Add audit logging for auth events in src/auth/AuditLogger.ts
11. Create user profile management in src/auth/UserProfile.ts
12. Add role-based access control in src/auth/RBACService.ts
13. Implement password policy enforcement in src/auth/PasswordPolicy.ts
14. Add brute force protection in src/auth/BruteForceGuard.ts
15. Create secure cookie handling in src/auth/CookieManager.ts
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
- src/routes/api.ts - Add auth endpoints
- src/middleware/cors.ts - Update CORS for auth headers
- src/utils/crypto.ts - Add encryption utilities
Testing Strategy
- Unit tests for each auth provider
- Integration tests for full auth flows
- Security penetration testing
- Load testing for session management
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders correctly with plan content 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
@@ -31,8 +31,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -77,8 +77,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -123,8 +123,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false*
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -169,8 +169,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -215,8 +215,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -261,8 +261,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -307,8 +307,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false*
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -353,8 +353,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title false │
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -399,8 +399,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Output Format Text │
│ The format of the CLI output. Can be \`text\` or \`json\`. │
│ │
Auto Theme Switching true │
Automatically switch between default light and dark themes based on terminal backgro…
Hide Window Title true*
Hide the window title bar
│ │
│ ▼ │
│ │
@@ -10,8 +10,10 @@ import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
@@ -34,7 +34,6 @@ import {
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -63,9 +62,7 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handlesOwnUI =
confirmationDetails.type === 'ask_user' ||
confirmationDetails.type === 'exit_plan_mode';
const handlesOwnUI = confirmationDetails.type === 'ask_user';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
@@ -280,32 +277,6 @@ export const ToolConfirmationMessage: React.FC<
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import chalk from 'chalk';
import { describe, it, expect } from 'vitest';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
@@ -77,7 +76,6 @@ describe('ExpandableText', () => {
100,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(chalk.inverse(userInput));
unmount();
});
@@ -119,7 +119,11 @@ const _ExpandableText: React.FC<ExpandableTextProps> = ({
{before}
{match
? match.split(/(\s+)/).map((part, index) => (
<Text key={`match-${index}`} inverse color={textColor}>
<Text
key={`match-${index}`}
color={theme.background.primary}
backgroundColor={theme.text.primary}
>
{part}
</Text>
))
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render, renderWithProviders } from '../../../test-utils/render.js';
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
@@ -227,31 +226,4 @@ describe('<MaxSizedBox />', () => {
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not leak content after hidden indicator with bottom overflow', async () => {
const markdownContent = Array.from(
{ length: 20 },
(_, i) => `- Step ${i + 1}: Do something important`,
).join('\n');
const { lastFrame } = renderWithProviders(
<MaxSizedBox maxWidth={80} maxHeight={5} overflowDirection="bottom">
<MarkdownDisplay
text={`## Plan\n\n${markdownContent}`}
isPending={false}
terminalWidth={76}
/>
</MaxSizedBox>,
{ width: 80 },
);
await waitFor(() => expect(lastFrame()).toContain('... last'));
const frame = lastFrame()!;
const lines = frame.split('\n');
const lastLine = lines[lines.length - 1];
// The last line should only contain the hidden indicator, no leaked content
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -120,12 +120,7 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
hidden ...
</Text>
)}
<Box
flexDirection="column"
overflow="hidden"
flexGrow={0}
maxHeight={isOverflowing ? visibleContentHeight : undefined}
>
<Box flexDirection="column" overflow="hidden" flexGrow={1}>
<Box
flexDirection="column"
ref={onRefChange}
@@ -31,14 +31,6 @@ Line 29
Line 30"
`;
exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bottom overflow 1`] = `
"Plan
- Step 1: Do something important
- Step 2: Do something important
... last 18 lines hidden ..."
`;
exports[`<MaxSizedBox /> > does not truncate when maxHeight is undefined 1`] = `
"Line 1
Line 2"

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