mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0904c3767e | |||
| f18e45d34d | |||
| a3af4a8cae | |||
| 821355c429 | |||
| 3afc8f25e1 | |||
| d79478689f | |||
| 650980af37 | |||
| bd156e6832 | |||
| b987e1780d | |||
| 0012d95848 | |||
| fedc0c5d60 | |||
| 99e523a15f | |||
| a0b6602d09 | |||
| b39cefe14e | |||
| 94f4e5cc15 | |||
| d866e7e6e7 | |||
| ed02b94570 | |||
| aba8c5f662 | |||
| d1cde575d9 | |||
| 71f46f1160 | |||
| d63c34b6e1 | |||
| 7a6dfa3704 | |||
| 69f8273481 | |||
| 1fc59484b1 | |||
| 53027af94c | |||
| 69c0585ab2 | |||
| 3e954930f1 | |||
| 2cf3a14439 | |||
| 0f918f0cc8 | |||
| 75dbf9022c | |||
| 675ca07c8b | |||
| 40f505e257 | |||
| 5407878138 | |||
| 14e2e09d10 | |||
| 0365f13caa | |||
| 3183e4137a | |||
| 4aa295994d | |||
| e1bd1d239f | |||
| b84585d0c8 | |||
| 0e7944df4f | |||
| d8837ec95e | |||
| dc37b190fa | |||
| 19b1a74c99 | |||
| 1d045792ce | |||
| a8b4c38c89 | |||
| ad8796b02d | |||
| e7bfd2bf83 | |||
| 1b274b081d | |||
| 5b254c379c | |||
| ed26ea49e9 | |||
| 01e33465bd | |||
| 18cce6a9ab | |||
| 18d7d1a92c | |||
| 0dd0b83612 | |||
| 4e4a55be35 | |||
| f57fd642df | |||
| 5e96373e6b | |||
| 09beb648b8 | |||
| c159c85c89 | |||
| 8cae90f404 | |||
| c5d0fc2c3e | |||
| e860f517c0 | |||
| b0be1f1689 | |||
| 85dd6ef773 | |||
| ae672881d1 | |||
| 9e7c10ad88 | |||
| 381669dce0 | |||
| 707b3e85d5 | |||
| 7d36cc004f | |||
| cb4f0c6fa4 | |||
| b0f38104d7 | |||
| 7469ea0fca | |||
| 00fdb30211 | |||
| 0fe8492569 | |||
| 76387d22ae | |||
| a362b7b382 | |||
| 71308caf05 | |||
| 6396ab1ccb | |||
| 12531a06f8 | |||
| d1cd69a20d | |||
| 62346875e4 | |||
| 13e013230b | |||
| bb6a336ca9 | |||
| f14d0c6a17 | |||
| b611f9a519 | |||
| d3bca5d97a | |||
| 94f4e027f8 | |||
| 95b7d69d5b | |||
| f605628624 | |||
| 2238802e97 |
+31
-11
@@ -1,6 +1,6 @@
|
||||
description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
description = "Reviews a PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
prompt = """
|
||||
You are an expert Frontend Reviewer and Pickle Rick Worker.
|
||||
You are an expert Reviewer and Pickle Rick Worker.
|
||||
|
||||
Target: {{args}}
|
||||
|
||||
@@ -56,8 +56,6 @@ 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
|
||||
@@ -105,7 +103,20 @@ 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. General Gemini CLI design principles:
|
||||
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:
|
||||
* 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.
|
||||
@@ -125,17 +136,23 @@ 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.
|
||||
10. TypeScript Best Practices:
|
||||
12. 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.
|
||||
11. Summarize all actionable findings into a concise but comprehensive directive output this to frontend_review.md and advance to phase 2.
|
||||
* **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.
|
||||
|
||||
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 coding agent.
|
||||
You are initiating Pickle Rick - the ultimate engineering agent.
|
||||
|
||||
**Step 0: Persona Injection**
|
||||
First, you **MUST** activate your persona.
|
||||
@@ -160,7 +177,7 @@ bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
|
||||
pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS
|
||||
```
|
||||
|
||||
**CRITICAL**: Your request is to fix all findings in frontend_review.md
|
||||
**CRITICAL**: Your request is to fix all findings in review_findings.md
|
||||
|
||||
**Step 2: Execution (Management)**
|
||||
After setup, read the output to find the path to `state.json`.
|
||||
@@ -188,11 +205,14 @@ 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. Run tests/build (Check functionality)
|
||||
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)
|
||||
* **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 `frontend_review.md`.
|
||||
* **Action**: After all tickets are completed delete `review_findings.md`.
|
||||
|
||||
**Loop Constraints:**
|
||||
- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop.
|
||||
@@ -1,71 +1,135 @@
|
||||
---
|
||||
name: docs-writer
|
||||
description:
|
||||
Use this skill for writing, reviewing, and editing documentation (`/docs`
|
||||
directory or any .md file) for Gemini CLI.
|
||||
Always use this skill when the task involves writing, reviewing, or editing
|
||||
files in the `/docs` directory or any `.md` files in the repository.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## Step 1: Understand the goal and create a plan
|
||||
## Phase 1: Documentation standards
|
||||
|
||||
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.
|
||||
Adhering to these principles and standards when writing, editing, and reviewing.
|
||||
|
||||
## Step 2: Investigate and gather information
|
||||
### Voice and tone
|
||||
Adopt a tone that balances professionalism with a helpful, conversational
|
||||
approach.
|
||||
|
||||
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.
|
||||
- **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).
|
||||
|
||||
## Step 3: Write or edit the documentation
|
||||
### Language and grammar
|
||||
Write precisely to ensure your instructions are unambiguous.
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
|
||||
## Phase 4: Verification and finalization
|
||||
Perform a final quality check to ensure that all changes are correctly formatted
|
||||
and that all links are functional.
|
||||
|
||||
### 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`
|
||||
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.
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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).
|
||||
@@ -177,6 +177,19 @@ jobs:
|
||||
- name: 'Smoke test bundle'
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
TARBALL=$(npm pack | tail -n 1)
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
mkdir -p ../smoke-test-dir
|
||||
mv "$TARBALL" ../smoke-test-dir/
|
||||
cd ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
|
||||
- name: 'Wait for file system sync'
|
||||
run: 'sleep 2'
|
||||
|
||||
@@ -252,6 +265,19 @@ jobs:
|
||||
- name: 'Smoke test bundle'
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
TARBALL=$(npm pack | tail -n 1)
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
mkdir -p ../smoke-test-dir
|
||||
mv "$TARBALL" ../smoke-test-dir/
|
||||
cd ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
|
||||
- name: 'Wait for file system sync'
|
||||
run: 'sleep 2'
|
||||
|
||||
@@ -396,6 +422,21 @@ jobs:
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
$PACK_OUTPUT = npm pack
|
||||
$TARBALL = $PACK_OUTPUT[-1]
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
New-Item -ItemType Directory -Force -Path ../smoke-test-dir
|
||||
Move-Item $TARBALL ../smoke-test-dir/
|
||||
Set-Location ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
shell: 'pwsh'
|
||||
|
||||
ci:
|
||||
name: 'CI'
|
||||
if: 'always()'
|
||||
|
||||
@@ -43,19 +43,23 @@ jobs:
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
let teamFetchSucceeded = false;
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
|
||||
teamFetchSucceeded = true;
|
||||
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
return isTeamMember || isRepoMaintainer;
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
@@ -153,7 +157,17 @@ jobs:
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
@@ -177,8 +191,12 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -124,6 +124,8 @@ 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
|
||||
@@ -256,6 +258,33 @@ 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
|
||||
|
||||
@@ -35,6 +35,9 @@ 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. |
|
||||
@@ -99,3 +102,18 @@ 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.
|
||||
|
||||
@@ -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`**](./rewind.md)
|
||||
- **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
|
||||
@@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
modify them as desired. Changes to some settings are applied immediately,
|
||||
while others require a restart.
|
||||
|
||||
- **`/shells`** (or **`/bashes`**)
|
||||
- **Description:** Toggle the background shells view. This allows you to view
|
||||
and manage long-running processes that you've sent to the background.
|
||||
- **`/setup-github`**
|
||||
- **Description:** Set up GitHub Actions to triage issues and review PRs with
|
||||
Gemini.
|
||||
|
||||
@@ -203,6 +203,23 @@ 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
|
||||
|
||||
@@ -23,7 +23,7 @@ available combinations.
|
||||
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
|
||||
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
|
||||
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
|
||||
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
|
||||
@@ -106,6 +106,14 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Ctrl+B | `Ctrl + B` |
|
||||
| Ctrl+L | `Ctrl + L` |
|
||||
| Ctrl+K | `Ctrl + K` |
|
||||
| Enter | `Enter` |
|
||||
| Esc | `Esc` |
|
||||
| Shift+Tab | `Shift + Tab` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
|
||||
+36
-33
@@ -39,31 +39,33 @@ they appear in the UI.
|
||||
|
||||
### UI
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### IDE
|
||||
|
||||
@@ -77,6 +79,7 @@ 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
|
||||
@@ -97,7 +100,6 @@ they appear in the UI.
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
@@ -107,13 +109,14 @@ they appear in the UI.
|
||||
|
||||
### Security
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| 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` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
|
||||
+18
-3
@@ -52,6 +52,7 @@ 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.
|
||||
@@ -67,6 +68,13 @@ 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
|
||||
@@ -89,7 +97,7 @@ gemini skills enable my-expertise
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## How it Works (Security & Privacy)
|
||||
## How it Works
|
||||
|
||||
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
|
||||
@@ -106,7 +114,14 @@ 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](./guides/creating-skills.md) guide.
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
guide.
|
||||
|
||||
@@ -116,7 +116,7 @@ description: Specialized in finding security vulnerabilities in code.
|
||||
kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- search_file_content
|
||||
- grep_search
|
||||
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. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. |
|
||||
| `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`. |
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
|
||||
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
|
||||
MCP servers of their own. This should not be used as an airtight security
|
||||
mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes
|
||||
tool calls that are considered safe (e.g., read-only operations) without
|
||||
explicit user confirmation. If set to `true`, the CLI will bypass the
|
||||
confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||
- **Default:** `"Default"`
|
||||
|
||||
@@ -170,6 +170,15 @@ 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:** `{}`
|
||||
@@ -326,6 +335,12 @@ 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`
|
||||
@@ -661,11 +676,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
@@ -733,12 +743,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.enableHooks`** (boolean):
|
||||
- **Description:** Enables the hooks system experiment. When disabled, the
|
||||
hooks system is completely deactivated regardless of other settings.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
|
||||
- **`mcp.serverCommand`** (string):
|
||||
@@ -779,9 +783,16 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.allowedExtensions`** (array):
|
||||
- **Description:** List of Regex patterns for allowed extensions. If nonempty,
|
||||
only extensions that match the patterns in this list are allowed. Overrides
|
||||
the blockGitExtensions setting.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.allowed`** (array):
|
||||
@@ -1165,6 +1176,13 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||
preserving UI display.
|
||||
- `hookSpecificOutput.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.
|
||||
|
||||
|
||||
+99
-125
@@ -1,149 +1,123 @@
|
||||
# Welcome to Gemini CLI documentation
|
||||
# Gemini CLI documentation
|
||||
|
||||
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 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.
|
||||
|
||||
## Gemini CLI overview
|
||||
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 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`.
|
||||
## Get started
|
||||
|
||||
## Navigating the documentation
|
||||
Begin your journey with Gemini CLI by setting up your environment and learning
|
||||
the basics.
|
||||
|
||||
This documentation is organized into the following sections:
|
||||
- **[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.
|
||||
|
||||
### Overview
|
||||
## Use Gemini CLI
|
||||
|
||||
- **[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.
|
||||
Master the core capabilities that let Gemini CLI interact with your system
|
||||
safely and effectively.
|
||||
|
||||
### Get started
|
||||
|
||||
- **[Gemini CLI quickstart](./get-started/index.md):** Let's get started with
|
||||
Gemini CLI.
|
||||
- **[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
|
||||
- **[Using the CLI](./cli/index.md):** Learn the basics 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.
|
||||
- **[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.
|
||||
|
||||
### Core
|
||||
## Configuration
|
||||
|
||||
- **[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`.
|
||||
Customize Gemini CLI to match your workflow and preferences.
|
||||
|
||||
- **[Policy Engine](./core/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
- **[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.
|
||||
|
||||
### Tools
|
||||
## Advanced features
|
||||
|
||||
- **[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.
|
||||
Explore powerful features for complex workflows and enterprise environments.
|
||||
|
||||
### Extensions
|
||||
- **[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.
|
||||
|
||||
- **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI
|
||||
with new functionality.
|
||||
## 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
|
||||
build your own extension.
|
||||
- **[Extension releasing](./extensions/releasing.md):** How to release Gemini
|
||||
CLI extensions.
|
||||
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.
|
||||
|
||||
### Hooks
|
||||
## Ecosystem and extensibility
|
||||
|
||||
- **[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.
|
||||
Connect Gemini CLI to external services and other development tools.
|
||||
|
||||
### IDE integration
|
||||
- **[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.
|
||||
|
||||
- **[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 and reference
|
||||
|
||||
### Development
|
||||
Deep dive into the architecture and contribute to the project.
|
||||
|
||||
- **[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
|
||||
- **[Architecture](./architecture.md):** Understand the technical design of
|
||||
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!
|
||||
- **[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.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -45,6 +45,7 @@
|
||||
{ "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" }
|
||||
|
||||
@@ -117,14 +117,13 @@ 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. `search_file_content` (SearchText)
|
||||
## 5. `grep_search` (SearchText)
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
- **Tool name:** `search_file_content`
|
||||
- **Tool name:** `grep_search`
|
||||
- **Display name:** SearchText
|
||||
- **File:** `grep.ts`
|
||||
- **Parameters:**
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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/);
|
||||
},
|
||||
});
|
||||
});
|
||||
+65
-1
@@ -7,9 +7,13 @@
|
||||
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 } from '@google/gemini-cli-core';
|
||||
import {
|
||||
createUnauthorizedToolError,
|
||||
parseAgentMarkdown,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export * from '@google/gemini-cli-test-utils';
|
||||
|
||||
@@ -41,11 +45,64 @@ 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 };
|
||||
@@ -66,6 +123,7 @@ 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,
|
||||
},
|
||||
@@ -88,6 +146,11 @@ 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),
|
||||
@@ -114,6 +177,7 @@ 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>;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,10 @@ class MockConfig {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGitIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
|
||||
|
||||
it('rejects invalid shell expressions', async () => {
|
||||
await rig.setup('rejects invalid shell expressions', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
allowed: ['run_shell_command(echo)'], // Specifically allow echo
|
||||
},
|
||||
},
|
||||
});
|
||||
const invalidCommand = getInvalidCommand();
|
||||
const result = await rig.run({
|
||||
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
|
||||
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
|
||||
});
|
||||
expect(result).toContain('FAIL');
|
||||
|
||||
|
||||
Generated
+31
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2251,6 +2251,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2431,6 +2432,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2464,6 +2466,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2832,6 +2835,7 @@
|
||||
"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"
|
||||
@@ -2865,6 +2869,7 @@
|
||||
"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"
|
||||
@@ -2917,6 +2922,7 @@
|
||||
"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",
|
||||
@@ -4122,6 +4128,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4399,6 +4406,7 @@
|
||||
"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",
|
||||
@@ -5391,6 +5399,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8400,6 +8409,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8940,6 +8950,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -10541,6 +10552,7 @@
|
||||
"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",
|
||||
@@ -14299,6 +14311,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14309,6 +14322,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16545,6 +16559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16768,7 +16783,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16776,6 +16792,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16948,6 +16965,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17155,6 +17173,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17268,6 +17287,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17280,6 +17300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17984,6 +18005,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17999,7 +18021,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18055,7 +18077,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18142,7 +18164,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -18278,6 +18300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18300,7 +18323,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18317,7 +18340,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
getCodeAssistServer,
|
||||
Config,
|
||||
ExperimentFlags,
|
||||
fetchAdminControlsOnce,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -19,11 +24,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Config: vi.fn().mockImplementation((params) => ({
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
...params, // Expose params for assertion
|
||||
})),
|
||||
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;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
@@ -31,6 +48,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
getCodeAssistServer: vi.fn(),
|
||||
fetchAdminControlsOnce: vi.fn(),
|
||||
coreEvents: {
|
||||
emitAdminSettingsChanged: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +78,121 @@ 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;
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
ExperimentFlags,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
@@ -124,37 +127,50 @@ export async function loadConfig(
|
||||
configParams.userMemory = memoryContent;
|
||||
configParams.geminiMdFileCount = fileCount;
|
||||
configParams.geminiMdFilePaths = filePaths;
|
||||
const config = new Config({
|
||||
|
||||
// Set an initial config to use to get a code assist server.
|
||||
// This is needed to fetch admin controls.
|
||||
const initialConfig = 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);
|
||||
|
||||
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);
|
||||
}
|
||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -222,3 +238,33 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
type ResolvedExtensionSetting,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export async function getExtensionManager() {
|
||||
const workspaceDir = process.cwd();
|
||||
@@ -35,3 +38,15 @@ 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,6 +21,17 @@ 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(),
|
||||
@@ -30,6 +41,14 @@ 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;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -27,6 +28,7 @@ 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),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @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();
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
loadEnvironment(loadSettings().merged, process.cwd());
|
||||
if (
|
||||
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
authMethod === AuthType.COMPUTE_ADC
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
@@ -596,9 +597,9 @@ describe('parseArguments', () => {
|
||||
|
||||
it('should set isCommand to true for hooks command', async () => {
|
||||
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
|
||||
// Hooks command enabled via tools settings
|
||||
// Hooks command enabled via hooksConfig settings
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
@@ -1014,7 +1015,9 @@ 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(defaultExcludes);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set([...defaultExcludes, ASK_USER_TOOL_NAME]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
@@ -1098,6 +1101,7 @@ 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 () => {
|
||||
@@ -1118,6 +1122,7 @@ 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 () => {
|
||||
@@ -1138,9 +1143,10 @@ 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 no interactive tools in non-interactive mode with yolo approval mode', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
@@ -1158,6 +1164,7 @@ 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 () => {
|
||||
@@ -1182,9 +1189,10 @@ 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 no interactive tools in non-interactive mode with legacy yolo flag', async () => {
|
||||
it('should exclude only ask_user 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();
|
||||
@@ -1195,6 +1203,7 @@ 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 () => {
|
||||
@@ -1219,6 +1228,7 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1255,7 +1265,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1556,12 +1566,12 @@ describe('loadCliConfig folderTrust', () => {
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false by default', async () => {
|
||||
it('should be true 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(false);
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1777,6 +1787,7 @@ 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 () => {
|
||||
@@ -1791,6 +1802,7 @@ 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 () => {
|
||||
@@ -1805,9 +1817,10 @@ 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 not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with YOLO', async () => {
|
||||
process.stdin.isTTY = false;
|
||||
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1819,6 +1832,7 @@ 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 () => {
|
||||
@@ -2928,7 +2942,7 @@ describe('loadCliConfig disableYoloMode', () => {
|
||||
security: { disableYoloMode: true },
|
||||
});
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2960,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2974,7 +2988,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HookDefinition,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -68,6 +70,11 @@ 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;
|
||||
@@ -139,6 +146,31 @@ 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',
|
||||
@@ -308,7 +340,7 @@ export async function parseArguments(
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
// Register hooks command if hooks are enabled
|
||||
if (settings.tools?.enableHooks) {
|
||||
if (settings.hooksConfig.enabled) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
@@ -432,7 +464,7 @@ export async function loadCliConfig(
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.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
|
||||
@@ -550,7 +582,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
throw new FatalConfigError(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
getAdminErrorMessage('YOLO mode', undefined /* config */),
|
||||
);
|
||||
}
|
||||
} else if (approvalMode === ApprovalMode.YOLO) {
|
||||
@@ -595,6 +627,10 @@ 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,
|
||||
@@ -761,6 +797,7 @@ export async function loadCliConfig(
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
folderTrust,
|
||||
interactive,
|
||||
@@ -790,10 +827,8 @@ export async function loadCliConfig(
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
enableHooksUI: settings.hooksConfig.enabled,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
|
||||
@@ -227,7 +227,6 @@ System using model: \${MODEL_NAME}
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -146,6 +147,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -182,6 +184,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -195,7 +198,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('[not set]');
|
||||
expect(setting?.value).toBeUndefined();
|
||||
expect(setting?.scope).toBeUndefined();
|
||||
|
||||
// Verify output string does not contain scope
|
||||
|
||||
@@ -51,7 +51,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
experimental: { extensionConfig: true },
|
||||
security: { blockGitExtensions: false },
|
||||
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
|
||||
tools: { enableHooks: true },
|
||||
}),
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
@@ -134,6 +133,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
}),
|
||||
isTrustedFolder: () => true,
|
||||
getImportFormat: () => 'tree',
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
@@ -209,6 +209,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
getAgentRegistry: () => ({
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
|
||||
@@ -70,6 +70,7 @@ 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[];
|
||||
@@ -144,6 +145,26 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
const extensionAllowed = this.settings.security?.allowedExtensions.some(
|
||||
(pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern).test(installMetadata.source);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!extensionAllowed) {
|
||||
throw new Error(
|
||||
`Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release') &&
|
||||
this.settings.security.blockGitExtensions
|
||||
@@ -152,6 +173,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||
);
|
||||
}
|
||||
|
||||
const isUpdate = !!previousExtensionConfig;
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
@@ -522,10 +544,39 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
if (!installMetadata?.source) {
|
||||
throw new Error(
|
||||
`Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
|
||||
);
|
||||
}
|
||||
const extensionAllowed = this.settings.security?.allowedExtensions.some(
|
||||
(pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern).test(installMetadata?.source);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!extensionAllowed) {
|
||||
debugLogger.warn(
|
||||
`Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else if (
|
||||
(installMetadata?.type === 'git' ||
|
||||
installMetadata?.type === 'github-release') &&
|
||||
this.settings.security.blockGitExtensions
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -598,12 +649,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
resolvedSettings.push({
|
||||
name: setting.name,
|
||||
envVar: setting.envVar,
|
||||
value:
|
||||
value === undefined
|
||||
? '[not set]'
|
||||
: setting.sensitive
|
||||
? '***'
|
||||
: value,
|
||||
value,
|
||||
sensitive: setting.sensitive ?? false,
|
||||
scope,
|
||||
source,
|
||||
@@ -639,10 +685,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
};
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
if (this.settings.hooksConfig.enabled) {
|
||||
hooks = await this.loadExtensionHooks(
|
||||
effectiveExtensionPath,
|
||||
hydrationContext,
|
||||
@@ -894,7 +937,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
scope += ')';
|
||||
}
|
||||
output += `\n ${setting.name}: ${setting.value} ${scope}`;
|
||||
output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -622,6 +622,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
@@ -645,6 +646,73 @@ describe('extension tests', () => {
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Extensions from remote sources is disallowed by your current settings.',
|
||||
),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should load allowed extensions if the allowlist is set.', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://allowed.com/foo/bar',
|
||||
},
|
||||
});
|
||||
const extensionAllowlistSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('my-ext');
|
||||
});
|
||||
|
||||
it('should not load disallowed extensions if the allowlist is set.', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://notallowed.com/foo/bar',
|
||||
},
|
||||
});
|
||||
const extensionAllowlistSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'This extension is not allowed by the "allowedExtensions" security setting',
|
||||
),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not load any extensions if admin.extensions.enabled is false', async () => {
|
||||
@@ -1116,6 +1184,30 @@ describe('extension tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not install a disallowed extension if the allowlist is set', async () => {
|
||||
const gitUrl = 'https://somehost.com/somerepo.git';
|
||||
const allowedExtensionsSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: allowedExtensionsSetting,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension({
|
||||
source: gitUrl,
|
||||
type: 'git',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt for trust if workspace is not trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
|
||||
@@ -28,14 +28,19 @@ export async function skillsConsentString(
|
||||
skills: SkillDefinition[],
|
||||
source: string,
|
||||
targetDir?: string,
|
||||
isLink = false,
|
||||
): Promise<string> {
|
||||
const action = isLink ? 'Linking' : 'Installing';
|
||||
const output: string[] = [];
|
||||
output.push(`Installing agent skill(s) from "${source}".`);
|
||||
output.push('\nThe following agent skill(s) will be installed:\n');
|
||||
output.push(`${action} agent skill(s) from "${source}".`);
|
||||
output.push(
|
||||
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
|
||||
);
|
||||
output.push(...(await renderSkillsList(skills)));
|
||||
|
||||
if (targetDir) {
|
||||
output.push(`Install Destination: ${targetDir}`);
|
||||
const destLabel = isLink ? 'Link' : 'Install';
|
||||
output.push(`${destLabel} Destination: ${targetDir}`);
|
||||
}
|
||||
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
||||
|
||||
|
||||
@@ -786,6 +786,23 @@ 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,7 +251,11 @@ export async function updateSetting(
|
||||
}
|
||||
|
||||
const parsedEnv = dotenv.parse(envContent);
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
if (!newValue) {
|
||||
delete parsedEnv[settingToUpdate.envVar];
|
||||
} else {
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
}
|
||||
|
||||
// We only want to write back the variables that are not sensitive.
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
|
||||
@@ -72,6 +72,15 @@ export enum Command {
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
|
||||
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
|
||||
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
|
||||
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
|
||||
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
|
||||
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
SHOW_FULL_TODOS = 'app.showFullTodos',
|
||||
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
],
|
||||
[Command.MOVE_LEFT]: [
|
||||
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
{ key: 'b', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_RIGHT]: [
|
||||
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
|
||||
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
|
||||
{ key: 'tab', shift: false },
|
||||
],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.TOGGLE_BACKGROUND_SHELL,
|
||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
Command.KILL_BACKGROUND_SHELL,
|
||||
Command.BACKGROUND_SHELL_SELECT,
|
||||
Command.BACKGROUND_SHELL_ESCAPE,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
|
||||
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should handle complex mixed configurations', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify rules are created with correct priorities', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
|
||||
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
|
||||
},
|
||||
tools: {
|
||||
sandbox: 'inherit',
|
||||
autoAccept: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ vi.mock('./settings.js', async (importActual) => {
|
||||
});
|
||||
|
||||
// Mock trustedFolders
|
||||
import * as trustedFolders from './trustedFolders.js';
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }),
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
isFolderTrustEnabled: vi.fn(),
|
||||
loadTrustedFolders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./settingsSchema.js', async (importOriginal) => {
|
||||
@@ -66,13 +67,14 @@ import {
|
||||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
type Settings,
|
||||
saveSettings,
|
||||
type SettingsFile,
|
||||
saveSettings,
|
||||
getDefaultsFromSchema,
|
||||
loadEnvironment,
|
||||
migrateDeprecatedSettings,
|
||||
SettingScope,
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
} from './settings.js';
|
||||
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -81,6 +83,7 @@ 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
|
||||
@@ -104,7 +107,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,9 +121,11 @@ 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()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -151,7 +156,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.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
@@ -1459,6 +1464,44 @@ 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', () => {
|
||||
@@ -1635,7 +1678,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
it('should NOT merge workspace settings when workspace is not trusted', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
@@ -1666,23 +1709,61 @@ 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,
|
||||
isWorkspaceTrustedValue = true as boolean | undefined,
|
||||
}) {
|
||||
delete process.env['GEMINI_API_KEY']; // reset
|
||||
delete process.env['TESTTEST']; // reset
|
||||
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
);
|
||||
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
source: 'file',
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
|
||||
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
@@ -1698,9 +1779,11 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === geminiEnvPath) return 'TESTTEST=1234';
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -1708,17 +1791,68 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
it('sets environment variables from .env files', () => {
|
||||
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
|
||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: false } },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
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 });
|
||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
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', () => {
|
||||
@@ -1731,7 +1865,7 @@ describe('Settings Loading and Merging', () => {
|
||||
mockFsExistsSync.mockReturnValue(true);
|
||||
mockFsReadFileSync = vi.mocked(fs.readFileSync);
|
||||
mockFsReadFileSync.mockReturnValue('{}');
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
@@ -1876,29 +2010,7 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
};
|
||||
|
||||
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 loadedSettings = createMockSettings(userSettingsContent);
|
||||
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
@@ -2067,11 +2179,8 @@ describe('Settings Loading and Merging', () => {
|
||||
describe('saveSettings', () => {
|
||||
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: { ui: { theme: 'dark' } },
|
||||
originalSettings: { ui: { theme: 'dark' } },
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2085,11 +2194,8 @@ describe('Settings Loading and Merging', () => {
|
||||
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
||||
mockFsExistsSync.mockReturnValue(false);
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/new/dir/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/new/dir/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2106,11 +2212,8 @@ describe('Settings Loading and Merging', () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2157,8 +2260,11 @@ describe('Settings Loading and Merging', () => {
|
||||
// 2. Now, set remote admin settings.
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Verify that remote admin settings take precedence.
|
||||
@@ -2198,8 +2304,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const newRemoteSettings = {
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
loadedSettings.setRemoteAdminSettings(newRemoteSettings);
|
||||
@@ -2210,13 +2319,6 @@ 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', () => {
|
||||
@@ -2248,84 +2350,6 @@ 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({
|
||||
@@ -2343,51 +2367,6 @@ 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);
|
||||
|
||||
@@ -2437,4 +2416,391 @@ 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+201
-153
@@ -16,7 +16,7 @@ import {
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
formatValidationError,
|
||||
} from './settings-validation.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
export function getMergeStrategyForPath(
|
||||
path: string[],
|
||||
): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
||||
let parent: SettingDefinition | undefined = undefined;
|
||||
@@ -75,6 +77,21 @@ 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'];
|
||||
@@ -275,8 +292,11 @@ export class LoadedSettings {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this._workspaceFile = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? workspace
|
||||
: this.createEmptyWorkspace(workspace);
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
@@ -284,10 +304,11 @@ export class LoadedSettings {
|
||||
readonly system: SettingsFile;
|
||||
readonly systemDefaults: SettingsFile;
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
workspace: SettingsFile;
|
||||
isTrusted: boolean;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _workspaceFile: SettingsFile;
|
||||
private _merged: MergedSettings;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
@@ -295,6 +316,26 @@ 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,
|
||||
@@ -339,21 +380,30 @@ export class LoadedSettings {
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
|
||||
setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void {
|
||||
const admin: Settings['admin'] = {};
|
||||
const {
|
||||
secureModeEnabled,
|
||||
strictModeDisabled,
|
||||
mcpSetting,
|
||||
cliFeatureSetting,
|
||||
} = remoteSettings;
|
||||
const { strictModeDisabled, mcpSetting, cliFeatureSetting } =
|
||||
remoteSettings;
|
||||
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -361,19 +411,13 @@ export class LoadedSettings {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strictModeDisabled !== undefined) {
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
} else if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
} else {
|
||||
admin.secureModeEnabled = true;
|
||||
}
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
|
||||
};
|
||||
admin.skills = {
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled,
|
||||
};
|
||||
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -410,38 +454,63 @@ function findEnvFile(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
export function setUpCloudShellEnvironment(
|
||||
envFilePath: string | null,
|
||||
isTrusted: boolean,
|
||||
isSandboxed: boolean,
|
||||
): 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
|
||||
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';
|
||||
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
||||
if (!isTrusted && isSandboxed) {
|
||||
value = sanitizeEnvVar(value);
|
||||
}
|
||||
}
|
||||
} 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): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
export function loadEnvironment(
|
||||
settings: Settings,
|
||||
workspaceDir: string,
|
||||
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
||||
): void {
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
|
||||
if (!isWorkspaceTrusted(settings).isTrusted) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath);
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
@@ -457,6 +526,16 @@ export function loadEnvironment(settings: Settings): void {
|
||||
|
||||
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;
|
||||
@@ -464,7 +543,7 @@ export function loadEnvironment(settings: Settings): void {
|
||||
|
||||
// Load variable only if it's not already set in the environment.
|
||||
if (!Object.hasOwn(process.env, key)) {
|
||||
process.env[key] = parsedEnv[key];
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,12 +674,14 @@ export function loadSettings(
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
getDefaultsFromSchema(),
|
||||
systemDefaultSettings,
|
||||
userSettings,
|
||||
systemSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
||||
.isTrusted ?? false;
|
||||
|
||||
// Create a temporary merged settings object to pass to loadEnvironment.
|
||||
const tempMergedSettings = mergeSettings(
|
||||
@@ -613,7 +694,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);
|
||||
loadEnvironment(tempMergedSettings, workspaceDir);
|
||||
|
||||
// Check for any fatal errors before proceeding
|
||||
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
|
||||
@@ -674,57 +755,55 @@ 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 inverted boolean settings (disableX -> enableX)
|
||||
// These settings were renamed and their boolean logic inverted
|
||||
// Migrate general settings
|
||||
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: Record<string, unknown> = { ...generalSettings };
|
||||
const newGeneral = { ...generalSettings };
|
||||
let modified = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
modified =
|
||||
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
|
||||
modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
@@ -733,94 +812,63 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
|
||||
// Migrate ui settings
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi: Record<string, unknown> = { ...uiSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
|
||||
const newUi = { ...uiSettings };
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
accessibilitySettings &&
|
||||
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
const newAccessibility: Record<string, unknown> = {
|
||||
...accessibilitySettings,
|
||||
};
|
||||
if (
|
||||
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;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
if (accessibilitySettings) {
|
||||
const newAccessibility = { ...accessibilitySettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate context settings
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext: Record<string, unknown> = { ...contextSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
|
||||
const newContext = { ...contextSettings };
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
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;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
if (fileFilteringSettings) {
|
||||
const newFileFiltering = { ...fileFilteringSettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
anyModified =
|
||||
migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
) || anyModified;
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.default,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
|
||||
@@ -351,6 +351,26 @@ 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',
|
||||
@@ -739,6 +759,16 @@ 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',
|
||||
@@ -1041,17 +1071,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
@@ -1179,16 +1198,6 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
enableHooks: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks System (Experimental)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1278,6 +1287,17 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Blocks installing and loading extensions from Git.',
|
||||
showInDialog: true,
|
||||
},
|
||||
allowedExtensions: {
|
||||
type: 'array',
|
||||
label: 'Extension Source Regex Allowlist',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
folderTrust: {
|
||||
type: 'object',
|
||||
label: 'Folder Trust',
|
||||
@@ -1292,7 +1312,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Folder Trust',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import * as osActual from 'node:os';
|
||||
import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -26,6 +30,9 @@ 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) => {
|
||||
@@ -53,6 +60,7 @@ 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', () => ({
|
||||
@@ -60,22 +68,23 @@ 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,
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,13 +99,16 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
describe('isPathTrusted', () => {
|
||||
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
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 '{}';
|
||||
},
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
@@ -124,26 +136,62 @@ describe('Trusted Folders Loading', () => {
|
||||
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
|
||||
|
||||
// No explicit rule covers this file
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
|
||||
undefined,
|
||||
);
|
||||
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,
|
||||
);
|
||||
|
||||
// /parent/other matches /parent/trustme (len 15, effective /parent)
|
||||
expect(folders.isPathTrusted('/parent/other')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user rules if only user file exists', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -154,11 +202,15 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
});
|
||||
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 '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([]);
|
||||
@@ -171,14 +223,18 @@ describe('Trusted Folders Loading', () => {
|
||||
const customPath = '/custom/path/to/trusted_folders.json';
|
||||
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === customPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -221,14 +277,16 @@ describe('isWorkspaceTrusted', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,12 +299,14 @@ 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) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/Please fix the configuration file/,
|
||||
@@ -255,12 +315,14 @@ 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) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/not a valid JSON object/,
|
||||
@@ -303,10 +365,10 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for a child of an untrusted folder', () => {
|
||||
it('should return false for a child of an untrusted folder', () => {
|
||||
mockCwd = '/home/user/untrusted/src';
|
||||
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when no rules match', () => {
|
||||
@@ -316,11 +378,24 @@ describe('isWorkspaceTrusted', () => {
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize trust over distrust', () => {
|
||||
it('should prioritize specific distrust over parent trust', () => {
|
||||
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',
|
||||
});
|
||||
@@ -338,6 +413,19 @@ 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();
|
||||
@@ -377,10 +465,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
});
|
||||
|
||||
it('should fall back to config when ideTrust is undefined', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
|
||||
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
|
||||
);
|
||||
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',
|
||||
@@ -406,8 +499,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
describe('Trusted Folders Caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -441,14 +537,20 @@ describe('invalid trust levels', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
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, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) =>
|
||||
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -482,3 +584,235 @@ 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,7 +36,9 @@ export enum TrustLevel {
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(value: unknown): value is TrustLevel {
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
@@ -63,6 +65,32 @@ 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,
|
||||
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
|
||||
config?: Record<string, TrustLevel>,
|
||||
): boolean | undefined {
|
||||
const configToUse = config ?? this.user.config;
|
||||
const trustedPaths: string[] = [];
|
||||
const untrustedPaths: string[] = [];
|
||||
|
||||
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;
|
||||
// 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 trustedPath of trustedPaths) {
|
||||
if (isWithinRoot(location, trustedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const untrustedPath of untrustedPaths) {
|
||||
if (path.normalize(location) === path.normalize(untrustedPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
@@ -161,11 +187,13 @@ 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: unknown = JSON.parse(stripJsonComments(content));
|
||||
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
@@ -222,11 +250,12 @@ 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 ?? false;
|
||||
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
function getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir: string,
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
const folders = loadTrustedFolders();
|
||||
@@ -241,7 +270,7 @@ function getWorkspaceTrustFromLocalConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const isTrusted = folders.isPathTrusted(process.cwd(), configToUse);
|
||||
const isTrusted = folders.isPathTrusted(workspaceDir, configToUse);
|
||||
return {
|
||||
isTrusted,
|
||||
source: isTrusted !== undefined ? 'file' : undefined,
|
||||
@@ -250,6 +279,7 @@ function getWorkspaceTrustFromLocalConfig(
|
||||
|
||||
export function isWorkspaceTrusted(
|
||||
settings: Settings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
@@ -262,5 +292,5 @@ export function isWorkspaceTrusted(
|
||||
}
|
||||
|
||||
// Fall back to the local user configuration
|
||||
return getWorkspaceTrustFromLocalConfig(trustConfig);
|
||||
return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,13 @@ import {
|
||||
} from './deferred.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import { createMockSettings } from './test-utils/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
||||
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||
mockRunExitCleanup: vi.fn(),
|
||||
mockDebugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
mockCoreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: mockDebugLogger,
|
||||
coreEvents: mockCoreEvents,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,16 +46,10 @@ 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());
|
||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -68,7 +61,9 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: true } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: true } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
@@ -82,11 +77,14 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -99,11 +97,14 @@ describe('deferred', () => {
|
||||
commandName: 'extensions',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { extensions: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Extensions are disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -116,11 +117,14 @@ describe('deferred', () => {
|
||||
commandName: 'skills',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { skills: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Agent skills are disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -134,7 +138,7 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({}); // No admin settings
|
||||
const settings = createMockSettings({}).merged; // No admin settings
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
@@ -162,7 +166,7 @@ describe('deferred', () => {
|
||||
expect(originalHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings());
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
@@ -180,11 +184,14 @@ describe('deferred', () => {
|
||||
const deferredMcp = defer(commandModule, 'mcp');
|
||||
await deferredMcp.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
const mcpSettings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -203,10 +210,14 @@ describe('deferred', () => {
|
||||
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
|
||||
// and defaulted to 'unknown' (or something else safe).
|
||||
const settings = createMockSettings({
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
});
|
||||
merged: {
|
||||
admin: {
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
ExitCodes,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './utils/cleanup.js';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import process from 'node:process';
|
||||
@@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
const commandName = deferredCommand.commandName;
|
||||
|
||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
||||
debugLogger.error('Error: MCP is disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('MCP', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
@@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
commandName === 'extensions' &&
|
||||
adminSettings?.extensions?.enabled === false
|
||||
) {
|
||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Extensions', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
||||
debugLogger.error('Error: Agent skills are disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Agent skills', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
+292
-527
File diff suppressed because it is too large
Load Diff
+39
-23
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -67,7 +68,7 @@ import {
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
@@ -98,6 +99,7 @@ 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';
|
||||
@@ -228,19 +230,21 @@ export async function startInteractiveUI(
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
@@ -737,13 +741,25 @@ export async function main() {
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
@@ -806,13 +822,13 @@ export function initializeOutputListenersAndFlush() {
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
let pendingSettings: FetchAdminControlsResponse | undefined;
|
||||
let pendingSettings: AdminControlsSettings | undefined;
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: FetchAdminControlsResponse;
|
||||
settings?: AdminControlsSettings;
|
||||
};
|
||||
if (message?.type === 'admin-settings' && message.settings) {
|
||||
if (config) {
|
||||
|
||||
@@ -258,6 +258,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||
@@ -374,6 +377,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -531,6 +537,9 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -670,6 +679,9 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -703,6 +715,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -833,6 +848,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
@@ -967,6 +985,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
@@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
export interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
input: string;
|
||||
@@ -55,13 +55,15 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
// Moved to ralphWiggum.ts
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
}: RunNonInteractiveParams): Promise<string> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
@@ -181,6 +183,9 @@ export async function runNonInteractive({
|
||||
}
|
||||
};
|
||||
|
||||
// Store accumulated response text to return
|
||||
let fullResponseText = '';
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
@@ -301,6 +306,9 @@ export async function runNonInteractive({
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
@@ -313,6 +321,13 @@ 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,
|
||||
@@ -322,7 +337,7 @@ export async function runNonInteractive({
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
// responseText is already updated
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
@@ -378,7 +393,7 @@ export async function runNonInteractive({
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
@@ -485,7 +500,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
@@ -509,7 +524,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -525,5 +540,6 @@ export async function runNonInteractive({
|
||||
if (errorToHandle) {
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
return fullResponseText;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @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,6 +98,17 @@ 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',
|
||||
@@ -115,6 +126,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
getEnableHooksUI: () => false,
|
||||
@@ -216,6 +228,22 @@ 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);
|
||||
@@ -256,6 +284,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
vi.resetModules();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type CommandContext,
|
||||
} from '../ui/commands/types.js';
|
||||
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
|
||||
import { isNightly, startupProfiler } from '@google/gemini-cli-core';
|
||||
import {
|
||||
isNightly,
|
||||
startupProfiler,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
@@ -36,8 +40,9 @@ 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 { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
@@ -47,6 +52,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -101,7 +107,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Extensions are disabled by your admin.',
|
||||
content: getAdminErrorMessage(
|
||||
'Extensions',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -126,7 +135,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'MCP is disabled by your admin.',
|
||||
content: getAdminErrorMessage('MCP', this.config ?? undefined),
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -134,8 +143,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
privacyCommand,
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
@@ -157,13 +167,17 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Agent skills are disabled by your admin.',
|
||||
content: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -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, type Settings } from '../config/settings.js';
|
||||
import { LoadedSettings } 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,10 +27,12 @@ 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();
|
||||
|
||||
@@ -134,20 +136,6 @@ 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 = {
|
||||
@@ -157,6 +145,9 @@ const baseMockUiState = {
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: undefined,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -201,7 +192,11 @@ const mockUIActions: UIActions = {
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
setBannerVisible: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
@@ -310,16 +305,18 @@ export const renderWithProviders = (
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<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>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @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;
|
||||
};
|
||||
@@ -88,6 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', () => {
|
||||
@@ -234,7 +235,6 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
||||
expect(lastFrame()).toContain('1 of 1');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -29,16 +28,8 @@ import {
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
type ContentGeneratorConfig,
|
||||
type AgentDefinition,
|
||||
MessageBusType,
|
||||
QuestionType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AskUserActionsContext,
|
||||
type AskUserState,
|
||||
} from './contexts/AskUserActionsContext.js';
|
||||
|
||||
// Mock coreEvents
|
||||
const mockCoreEvents = vi.hoisted(() => ({
|
||||
@@ -53,11 +44,6 @@ 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() },
|
||||
@@ -87,9 +73,6 @@ 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(),
|
||||
})),
|
||||
@@ -124,11 +107,9 @@ vi.mock('ink', async (importOriginal) => {
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedAskUserRequest: AskUserState | null;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -165,6 +146,12 @@ 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');
|
||||
@@ -193,7 +180,6 @@ 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';
|
||||
@@ -268,6 +254,26 @@ 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',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -279,7 +285,6 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
capturedUIState = null!;
|
||||
capturedAskUserRequest = null;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
@@ -334,14 +339,7 @@ describe('AppContainer State Management', () => {
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||
mockedUseFolderTrust.mockReturnValue({
|
||||
isFolderTrustDialogOpen: false,
|
||||
@@ -368,7 +366,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
// Add other properties if AppContainer uses them
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -383,6 +383,7 @@ describe('AppContainer State Management', () => {
|
||||
currentLoadingPhrase: '',
|
||||
});
|
||||
mockedUseHookDisplayState.mockReturnValue([]);
|
||||
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
@@ -416,7 +417,6 @@ describe('AppContainer State Management', () => {
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: false,
|
||||
hideWindowTitle: false,
|
||||
showUserIdentity: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
@@ -488,162 +488,6 @@ 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;
|
||||
@@ -1193,12 +1037,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state as Active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Some thought' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1234,12 +1075,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Some thought' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1306,12 +1144,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const thoughtSubject = 'Processing request';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: thoughtSubject },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1347,14 +1182,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock the streaming state as Idle with no thought
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer({
|
||||
@@ -1391,12 +1219,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const thoughtSubject = 'Confirm tool execution';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'waiting_for_confirmation',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: thoughtSubject },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1448,16 +1273,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1512,12 +1332,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty with redirection active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
@@ -1527,9 +1344,7 @@ describe('AppContainer State Management', () => {
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1587,16 +1402,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty with NO output since operation started (silent)
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1643,12 +1453,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = startTime + 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime,
|
||||
}));
|
||||
@@ -1669,12 +1476,9 @@ describe('AppContainer State Management', () => {
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = startTime + 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime,
|
||||
}));
|
||||
@@ -1734,12 +1538,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought with a short subject
|
||||
const shortTitle = 'Short';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: shortTitle },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1778,12 +1579,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const title = 'Test Title';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: title },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1821,12 +1619,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1928,12 +1722,7 @@ describe('AppContainer State Management', () => {
|
||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
@@ -1952,7 +1741,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
@@ -1987,9 +1776,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
@@ -2005,11 +1796,7 @@ describe('AppContainer State Management', () => {
|
||||
// Mock request cancellation
|
||||
mockCancelOngoingRequest = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
|
||||
@@ -2017,6 +1804,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -2030,11 +1820,8 @@ describe('AppContainer State Management', () => {
|
||||
describe('CTRL+C', () => {
|
||||
it('should cancel ongoing request on first press', async () => {
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
await setupKeypressTest();
|
||||
@@ -2079,19 +1866,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('CTRL+D', () => {
|
||||
it('should do nothing if text buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2106,6 +1880,50 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
lines: ['some text'],
|
||||
cursor: [0, 9], // At the end
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2125,7 +1943,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -2155,9 +1973,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2510,6 +2330,59 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.activeHooks).toEqual(mockHooks);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles consent request events', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const handler = mockCoreEvents.on.mock.calls.find(
|
||||
(call: unknown[]) => call[0] === CoreEvent.ConsentRequest,
|
||||
)?.[1];
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const onConfirm = vi.fn();
|
||||
const payload = {
|
||||
prompt: 'Do you consent?',
|
||||
onConfirm,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
handler(payload);
|
||||
});
|
||||
|
||||
expect(capturedUIState.authConsentRequest).toBeDefined();
|
||||
expect(capturedUIState.authConsentRequest?.prompt).toBe(
|
||||
'Do you consent?',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
capturedUIState.authConsentRequest?.onConfirm(true);
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true);
|
||||
expect(capturedUIState.authConsentRequest).toBeNull();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('unsubscribes from ConsentRequest on unmount', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
unmount!();
|
||||
|
||||
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||
CoreEvent.ConsentRequest,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Interaction', () => {
|
||||
@@ -2521,12 +2394,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
||||
});
|
||||
|
||||
@@ -2676,41 +2544,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should show ask user dialog when request is received', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'What is your favorite color?',
|
||||
header: 'Color Preference',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await mockConfig.getMessageBus().publish({
|
||||
type: MessageBusType.ASK_USER_REQUEST,
|
||||
questions,
|
||||
correlationId: 'test-id',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedAskUserRequest).not.toBeNull();
|
||||
expect(capturedAskUserRequest?.questions).toEqual(questions);
|
||||
expect(capturedAskUserRequest?.correlationId).toBe('test-id');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression Tests', () => {
|
||||
|
||||
@@ -27,13 +27,10 @@ import {
|
||||
type HistoryItemWithoutId,
|
||||
type HistoryItemToolGroup,
|
||||
AuthState,
|
||||
type ConfirmationRequest,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
AskUserActionsProvider,
|
||||
type AskUserState,
|
||||
} from './contexts/AskUserActionsContext.js';
|
||||
import {
|
||||
type EditorType,
|
||||
type Config,
|
||||
@@ -47,7 +44,6 @@ import {
|
||||
getErrorMessage,
|
||||
getAllGeminiMdFilenames,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
clearCachedCredentialFile,
|
||||
type ResumedSessionData,
|
||||
recordExitFail,
|
||||
@@ -68,8 +64,7 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
MessageBusType,
|
||||
type AskUserRequest,
|
||||
type ConsentRequestPayload,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -97,6 +92,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
@@ -136,6 +132,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
@@ -143,6 +140,7 @@ 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) => {
|
||||
@@ -192,51 +190,6 @@ 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();
|
||||
@@ -257,6 +210,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
@@ -267,7 +224,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>(
|
||||
@@ -336,11 +293,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
AgentDefinition | undefined
|
||||
>();
|
||||
|
||||
// AskUser dialog state
|
||||
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openAgentConfigDialog = useCallback(
|
||||
(name: string, displayName: string, definition: AgentDefinition) => {
|
||||
setSelectedAgentName(name);
|
||||
@@ -358,56 +310,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setSelectedAgentDefinition(undefined);
|
||||
}, []);
|
||||
|
||||
// Subscribe to ASK_USER_REQUEST messages from the message bus
|
||||
useEffect(() => {
|
||||
const messageBus = config.getMessageBus();
|
||||
|
||||
const handler = (msg: AskUserRequest) => {
|
||||
setAskUserRequest({
|
||||
questions: msg.questions,
|
||||
correlationId: msg.correlationId,
|
||||
});
|
||||
};
|
||||
|
||||
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
|
||||
return () => {
|
||||
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Handler to submit ask_user answers
|
||||
const handleAskUserSubmit = useCallback(
|
||||
async (answers: { [questionIndex: string]: string }) => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
},
|
||||
[config, askUserRequest],
|
||||
);
|
||||
|
||||
// Handler to cancel ask_user dialog
|
||||
const handleAskUserCancel = useCallback(async () => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers: {},
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
}, [config, askUserRequest]);
|
||||
|
||||
const toggleDebugProfiler = useCallback(
|
||||
() => setShowDebugProfiler((prev) => !prev),
|
||||
[],
|
||||
@@ -487,6 +389,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
registerCleanup(async () => {
|
||||
// Turn off mouse scroll.
|
||||
disableMouseEvents();
|
||||
|
||||
// Kill all background shells
|
||||
for (const pid of backgroundShellsRef.current.keys()) {
|
||||
ShellExecutionService.kill(pid);
|
||||
}
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
|
||||
@@ -579,6 +487,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
const bufferRef = useRef(buffer);
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
|
||||
const stableSetText = useCallback((text: string) => {
|
||||
bufferRef.current.setText(text);
|
||||
}, []);
|
||||
|
||||
// Initialize input history from logger (past sessions)
|
||||
useEffect(() => {
|
||||
@@ -640,6 +556,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
initializationResult.themeError,
|
||||
);
|
||||
|
||||
// Poll for terminal background color changes to auto-switch theme
|
||||
useTerminalTheme(handleThemeSelect, config);
|
||||
|
||||
const {
|
||||
authState,
|
||||
setAuthState,
|
||||
@@ -835,6 +754,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
openAuthDialog: () => setAuthState(AuthState.Updating),
|
||||
@@ -858,7 +781,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -876,7 +810,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -885,7 +819,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
confirmationRequest: commandConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -902,6 +836,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCustomDialog,
|
||||
);
|
||||
|
||||
const [authConsentRequest, setAuthConsentRequest] =
|
||||
useState<ConfirmationRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsentRequest = (payload: ConsentRequestPayload) => {
|
||||
setAuthConsentRequest({
|
||||
prompt: payload.prompt,
|
||||
onConfirm: (confirmed: boolean) => {
|
||||
setAuthConsentRequest(null);
|
||||
payload.onConfirm(confirmed);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -989,6 +943,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1011,7 +971,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
|
||||
const {
|
||||
activeBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
isBackgroundShellListOpen,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
} = useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
||||
|
||||
const lastOutputTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
@@ -1160,7 +1143,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Compute available terminal height based on controls measurement
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - controlsHeight - staticExtraHeight - 2,
|
||||
terminalHeight -
|
||||
controlsHeight -
|
||||
staticExtraHeight -
|
||||
2 -
|
||||
backgroundShellHeight,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1384,7 +1371,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlCPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlCPressCount > 0) {
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
@@ -1403,7 +1390,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlDPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlDPressCount > 0) {
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
@@ -1444,7 +1431,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
@@ -1464,10 +1451,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// Skip when ask_user dialog is open (use Esc to cancel instead)
|
||||
if (askUserRequest) {
|
||||
return;
|
||||
}
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
cancelOngoingRequest?.();
|
||||
@@ -1475,9 +1458,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlCPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
setCtrlDPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
}
|
||||
@@ -1520,9 +1500,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
|
||||
activePtyId &&
|
||||
embeddedShellFocused
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
@@ -1530,26 +1509,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (embeddedShellFocused) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||
if (isIdle) {
|
||||
if (isIdle && !activePtyId) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
toggleBackgroundShell();
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
} else {
|
||||
// We are about to hide it
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
|
||||
// Not idle, just focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
if (isBackgroundShellVisible && !embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
||||
if (!embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1561,11 +1586,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
ideContextState,
|
||||
setCtrlCPressCount,
|
||||
buffer.text.length,
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1573,6 +1596,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
],
|
||||
);
|
||||
@@ -1586,7 +1616,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isConfirming:
|
||||
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
@@ -1602,7 +1633,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
@@ -1678,11 +1709,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
!!askUserRequest ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!confirmationRequest ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
@@ -1792,7 +1823,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1853,6 +1885,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -1862,6 +1896,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
}),
|
||||
@@ -1890,7 +1928,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1951,6 +1990,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -1963,6 +2004,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
],
|
||||
@@ -2010,7 +2055,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
handleRestart: async () => {
|
||||
if (process.send) {
|
||||
@@ -2082,7 +2131,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
newAgents,
|
||||
config,
|
||||
@@ -2113,15 +2166,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<AskUserActionsProvider
|
||||
request={askUserRequest}
|
||||
onSubmit={handleAskUserSubmit}
|
||||
onCancel={handleAskUserCancel}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</AskUserActionsProvider>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
|
||||
@@ -125,7 +125,7 @@ Tips for getting started:
|
||||
4. /help for more information.
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
|
||||
@@ -86,6 +86,11 @@ describe('directoryCommand', () => {
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,9 +30,6 @@ describe('hooksCommand', () => {
|
||||
hooksConfig?: {
|
||||
disabled?: string[];
|
||||
};
|
||||
tools?: {
|
||||
enableHooks?: boolean;
|
||||
};
|
||||
};
|
||||
setValue: ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -187,8 +184,8 @@ describe('hooksCommand', () => {
|
||||
it('should display panel when no hooks are configured', async () => {
|
||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
@@ -215,8 +212,8 @@ describe('hooksCommand', () => {
|
||||
|
||||
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
|
||||
@@ -10,6 +10,7 @@ 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',
|
||||
@@ -49,5 +50,63 @@ 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()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @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,6 +41,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
logRewind: vi.fn(),
|
||||
RewindEvent: class {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,14 +14,16 @@ 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 type {
|
||||
ChatRecordingService,
|
||||
GeminiClient,
|
||||
import {
|
||||
checkExhaustive,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
logRewind,
|
||||
RewindEvent,
|
||||
type ChatRecordingService,
|
||||
type 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.
|
||||
@@ -144,6 +146,9 @@ 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();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { shellsCommand } from './shellsCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('shellsCommand', () => {
|
||||
it('should call toggleBackgroundShell', async () => {
|
||||
const toggleBackgroundShell = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundShell,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (shellsCommand.action) {
|
||||
await shellsCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(shellsCommand.name).toBe('shells');
|
||||
expect(shellsCommand.altNames).toContain('bashes');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(shellsCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,27 @@ 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')>();
|
||||
@@ -58,6 +79,7 @@ describe('skillsCommand', () => {
|
||||
(name: string) => skills.find((s) => s.name === name) ?? null,
|
||||
),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: createTestMergedSettings({ skills: { disabled: [] } }),
|
||||
@@ -184,6 +206,80 @@ 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(() => {
|
||||
(
|
||||
@@ -367,7 +463,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -385,7 +481,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -11,13 +11,23 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import {
|
||||
MessageType,
|
||||
type HistoryItemSkillsList,
|
||||
type HistoryItemInfo,
|
||||
type HistoryItemSkillsList,
|
||||
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 { SettingScope } from '../../config/settings.js';
|
||||
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
|
||||
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
@@ -66,6 +76,69 @@ 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,
|
||||
@@ -83,7 +156,10 @@ async function disableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -141,7 +217,10 @@ async function enableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -293,6 +372,13 @@ 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>',
|
||||
|
||||
@@ -83,7 +83,14 @@ 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;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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';
|
||||
@@ -40,6 +41,9 @@ 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,6 +10,8 @@ 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
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
@@ -41,6 +43,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -105,6 +108,7 @@ describe('AskUserDialog', () => {
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -124,6 +128,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -153,12 +158,57 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
])(
|
||||
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
|
||||
({ useAlternateBuffer, expectedArrows }) => {
|
||||
it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Choose an option',
|
||||
header: 'Scroll Test',
|
||||
options: Array.from({ length: 15 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
})),
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={10} // Small height to force scrolling
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
if (expectedArrows) {
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
} else {
|
||||
expect(lastFrame()).not.toContain('▲');
|
||||
expect(lastFrame()).not.toContain('▼');
|
||||
}
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -209,6 +259,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -222,6 +273,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -235,6 +287,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -265,6 +318,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -306,6 +360,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -373,6 +428,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -401,6 +457,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -445,6 +502,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -480,6 +538,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -512,6 +571,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -533,6 +593,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -554,6 +615,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -588,6 +650,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -618,6 +681,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -664,6 +728,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -713,6 +778,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -738,6 +804,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -783,6 +850,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -841,6 +909,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -872,4 +941,258 @@ 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[] = [
|
||||
{
|
||||
question: 'Choose an option',
|
||||
header: 'Context Test',
|
||||
options: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
})),
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5, // Small height to force scroll arrows
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
});
|
||||
|
||||
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
|
||||
const longQuestion =
|
||||
'This is a very long question ' + 'with many words '.repeat(10);
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Alternate Buffer Test',
|
||||
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={40} // Small width to force wrapping
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
|
||||
// Should NOT contain the truncation message
|
||||
expect(lastFrame()).not.toContain('hidden ...');
|
||||
// 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,13 +21,65 @@ 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 '../../utils/checks.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.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 };
|
||||
@@ -121,6 +173,14 @@ interface AskUserDialogProps {
|
||||
* Useful for managing global keypress handlers.
|
||||
*/
|
||||
onActiveTextInputChange?: (active: boolean) => void;
|
||||
/**
|
||||
* Width of the dialog.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height constraint for scrollable content.
|
||||
*/
|
||||
availableHeight?: number;
|
||||
}
|
||||
|
||||
interface ReviewViewProps {
|
||||
@@ -152,12 +212,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
@@ -174,15 +229,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{questions.map((q, i) => (
|
||||
<Box key={i} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{q.header}</Text>
|
||||
<Text color={theme.text.secondary}> → </Text>
|
||||
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
|
||||
{answers[i] || '(not answered)'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box flexDirection="column">
|
||||
{questions.map((q, i) => (
|
||||
<Box key={i} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{q.header}</Text>
|
||||
<Text color={theme.text.secondary}> → </Text>
|
||||
<Text
|
||||
color={answers[i] ? theme.text.primary : theme.status.warning}
|
||||
>
|
||||
{answers[i] || '(not answered)'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
@@ -199,6 +258,7 @@ interface TextQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -210,12 +270,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const horizontalPadding = 1; // 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
@@ -241,12 +303,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
const handleExtraKeys = useCallback(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
if (textValue === '') {
|
||||
return false;
|
||||
}
|
||||
buffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
[buffer, textValue],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -270,18 +335,30 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const placeholder = question.placeholder || 'Enter your response';
|
||||
|
||||
const HEADER_HEIGHT = progressHeader ? 2 : 0;
|
||||
const INPUT_HEIGHT = 2; // TextInput + margin
|
||||
const FOOTER_HEIGHT = 2; // DialogFooter + margin
|
||||
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
|
||||
const questionHeight =
|
||||
availableHeight && !isAlternateBuffer
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -381,6 +458,7 @@ interface ChoiceQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -391,14 +469,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const numLen = String(numOptions).length;
|
||||
@@ -407,15 +484,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
|
||||
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
|
||||
const cursorPadding = 1; // Extra character for cursor at end of line
|
||||
const outerBoxPadding = 4; // border (2) + paddingX (2)
|
||||
|
||||
const horizontalPadding =
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
@@ -544,6 +615,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
(key: Key) => {
|
||||
// If focusing custom option, handle Ctrl+C
|
||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||
if (customOptionText === '') {
|
||||
return false;
|
||||
}
|
||||
customBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
@@ -586,7 +660,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
[
|
||||
isCustomOptionFocused,
|
||||
customBuffer,
|
||||
onEditingCustomOption,
|
||||
customOptionText,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -698,31 +777,53 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
}, [customOptionText, isCustomOptionSelected, question.multiSelect]);
|
||||
|
||||
const HEADER_HEIGHT = progressHeader ? 2 : 0;
|
||||
const TITLE_MARGIN = 1;
|
||||
const FOOTER_HEIGHT = 2; // DialogFooter + margin
|
||||
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
|
||||
const listHeight = availableHeight
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
const questionHeight =
|
||||
listHeight && !isAlternateBuffer
|
||||
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
|
||||
: undefined;
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeight
|
||||
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
|
||||
: selectionItems.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<Box marginBottom={TITLE_MARGIN}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<BaseSelectionList<OptionItem>
|
||||
items={selectionItems}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
focusKey={isCustomOptionFocused ? 'other' : undefined}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
showScrollArrows={true}
|
||||
renderItem={(item, context) => {
|
||||
const optionItem = item.value;
|
||||
const isChecked =
|
||||
@@ -734,7 +835,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
const placeholder = 'Enter a custom value';
|
||||
const placeholder = question.placeholder || 'Enter a custom value';
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
@@ -787,7 +888,10 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
{optionItem.description && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{' '}
|
||||
{optionItem.description}
|
||||
<RenderInline
|
||||
text={optionItem.description}
|
||||
defaultColor={theme.text.secondary}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -804,14 +908,19 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onActiveTextInputChange,
|
||||
width,
|
||||
availableHeight: availableHeightProp,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const availableHeight =
|
||||
availableHeightProp ??
|
||||
(uiState?.constrainHeight !== false
|
||||
? uiState?.availableTerminalHeight
|
||||
: undefined);
|
||||
|
||||
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
||||
const { answers, isEditingCustomOption, submitted } = state;
|
||||
|
||||
const uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
const tabCount =
|
||||
questions.length > 1 ? questions.length + 1 : questions.length;
|
||||
@@ -842,9 +951,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
onCancel();
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key)) {
|
||||
if (!isEditingCustomOption) {
|
||||
onCancel();
|
||||
}
|
||||
// Return false to let ctrl-C bubble up to AppContainer for exit flow
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -1021,7 +1133,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1033,7 +1146,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1043,7 +1157,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={availableWidth}
|
||||
width={width}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockHandleWarning = vi.fn();
|
||||
const mockSetEmbeddedShellFocused = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
handleWarning: mockHandleWarning,
|
||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
ShellExecutionService: {
|
||||
resizePty: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock AnsiOutputText since it's a complex component
|
||||
vi.mock('./AnsiOutput.js', () => ({
|
||||
AnsiOutputText: ({ data }: { data: string | unknown }) => {
|
||||
if (typeof data === 'string') return <>{data}</>;
|
||||
// Simple serialization for object data
|
||||
return <>{JSON.stringify(data)}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useKeypress
|
||||
let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> =
|
||||
[];
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn((handler, { isActive }) => {
|
||||
keypressHandlers.push({ handler, isActive });
|
||||
}),
|
||||
}));
|
||||
|
||||
const simulateKey = (key: Partial<Key>) => {
|
||||
const fullKey: Key = createMockKey(key);
|
||||
keypressHandlers.forEach(({ handler, isActive }) => {
|
||||
if (isActive) {
|
||||
handler(fullKey);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
vi.mock('../contexts/MouseContext.js', () => ({
|
||||
useMouseContext: vi.fn(() => ({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
useMouse: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ScrollableList
|
||||
vi.mock('./shared/ScrollableList.js', () => ({
|
||||
SCROLL_TO_ITEM_END: 999999,
|
||||
ScrollableList: vi.fn(
|
||||
({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: BackgroundShell[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundShell;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
shift: false,
|
||||
insertable: false,
|
||||
sequence: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundShell = {
|
||||
pid: 1002,
|
||||
command: 'tail -f log.txt',
|
||||
output: 'Log entry 1',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShells.clear();
|
||||
mockShells.set(shell1.pid, shell1);
|
||||
mockShells.set(shell2.pid, shell2);
|
||||
keypressHandlers = [];
|
||||
});
|
||||
|
||||
it('renders the output of the active shell', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders tabs for multiple shells', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('highlights the focused state', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true} // Focused
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('resizes the PTY on mount and when dimensions change', async () => {
|
||||
const { rerender } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
76,
|
||||
21,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={30}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
96,
|
||||
27,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the process list when isListOpenProp is true', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
|
||||
act(() => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
act(() => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Initial state: shell1 (active) is highlighted
|
||||
|
||||
// Move to shell2
|
||||
act(() => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Press Ctrl+K
|
||||
act(() => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
});
|
||||
|
||||
it('kills the active process when Ctrl+K is pressed in output view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
});
|
||||
|
||||
it('scrolls to active shell when list opens', async () => {
|
||||
// shell2 is active
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundShell = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'exited',
|
||||
exitCode: 0,
|
||||
};
|
||||
mockShells.set(exitedShell.pid, exitedShell);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={exitedShell.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('unfocuses the shell when Shift+Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab', shift: true });
|
||||
});
|
||||
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows a warning when Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab' });
|
||||
});
|
||||
|
||||
expect(mockHandleWarning).toHaveBeenCalledWith(
|
||||
'Press Shift+Tab to focus out.',
|
||||
);
|
||||
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { commandDescriptions } from '../../config/keyBindings.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
|
||||
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
|
||||
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isFocused: boolean;
|
||||
isListOpenProp: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_PADDING_X = 1;
|
||||
const BORDER_WIDTH = 2; // Left and Right border
|
||||
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
|
||||
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
|
||||
|
||||
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
const commandFirstLine = command.split('\n')[0];
|
||||
return cpLen(commandFirstLine) > maxWidth
|
||||
? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...`
|
||||
: commandFirstLine;
|
||||
};
|
||||
|
||||
export const BackgroundShellDisplay = ({
|
||||
shells,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
isFocused,
|
||||
isListOpenProp,
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
const {
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
activeShell?.output || '',
|
||||
);
|
||||
const [highlightedPid, setHighlightedPid] = useState<number | null>(
|
||||
activePid,
|
||||
);
|
||||
const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(null);
|
||||
const subscribedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePid) return;
|
||||
|
||||
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
|
||||
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
|
||||
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
|
||||
}, [activePid, width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePid) {
|
||||
setOutput('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial output from the shell object
|
||||
const shell = shells.get(activePid);
|
||||
if (shell) {
|
||||
setOutput(shell.output);
|
||||
}
|
||||
|
||||
subscribedRef.current = false;
|
||||
|
||||
// Subscribe to live updates for the active shell
|
||||
const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => {
|
||||
if (event.type === 'data') {
|
||||
if (typeof event.chunk === 'string') {
|
||||
if (!subscribedRef.current) {
|
||||
// Initial synchronous update contains full history
|
||||
setOutput(event.chunk);
|
||||
} else {
|
||||
// Subsequent updates are deltas for child_process
|
||||
setOutput((prev) =>
|
||||
typeof prev === 'string' ? prev + event.chunk : event.chunk,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// PTY always sends full AnsiOutput
|
||||
setOutput(event.chunk);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
subscribedRef.current = true;
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
subscribedRef.current = false;
|
||||
};
|
||||
}, [activePid, shells]);
|
||||
|
||||
// Sync highlightedPid with activePid when list opens
|
||||
useEffect(() => {
|
||||
if (isListOpenProp) {
|
||||
setHighlightedPid(activePid);
|
||||
}
|
||||
}, [isListOpenProp, activePid]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!activeShell) return;
|
||||
|
||||
// Handle Shift+Tab or Tab (in list) to focus out
|
||||
if (
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
|
||||
(isListOpenProp &&
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
|
||||
) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Tab to warn but propagate
|
||||
if (
|
||||
!isListOpenProp &&
|
||||
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
|
||||
) {
|
||||
handleWarning(
|
||||
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
|
||||
);
|
||||
// Fall through to allow Tab to be sent to the shell
|
||||
}
|
||||
|
||||
if (isListOpenProp) {
|
||||
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
|
||||
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
|
||||
// RadioButtonSelect handles Enter -> onSelect
|
||||
|
||||
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
dismissBackgroundShell(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (highlightedPid) {
|
||||
setActiveBackgroundShellPid(highlightedPid);
|
||||
}
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) {
|
||||
ShellExecutionService.writeToPty(activeShell.pid, '\r');
|
||||
return true;
|
||||
} else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
|
||||
ShellExecutionService.writeToPty(activeShell.pid, '\b');
|
||||
return true;
|
||||
} else if (key.sequence) {
|
||||
ShellExecutionService.writeToPty(activeShell.pid, key.sequence);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused && !!activeShell },
|
||||
);
|
||||
|
||||
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
|
||||
|
||||
const renderTabs = () => {
|
||||
const shellList = Array.from(shells.values()).filter(
|
||||
(s) => s.status === 'running',
|
||||
);
|
||||
|
||||
const pidInfoWidth = getCachedStringWidth(
|
||||
` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`,
|
||||
);
|
||||
|
||||
const availableWidth =
|
||||
width -
|
||||
TAB_DISPLAY_HORIZONTAL_PADDING -
|
||||
getCachedStringWidth(helpText) -
|
||||
pidInfoWidth;
|
||||
|
||||
let currentWidth = 0;
|
||||
const tabs = [];
|
||||
|
||||
for (let i = 0; i < shellList.length; i++) {
|
||||
const shell = shellList[i];
|
||||
// Account for " i: " (length 4 if i < 9) and spaces (length 2)
|
||||
const labelOverhead = 4 + (i + 1).toString().length;
|
||||
const maxTabLabelLength = Math.max(
|
||||
1,
|
||||
Math.floor(availableWidth / shellList.length) - labelOverhead,
|
||||
);
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxTabLabelLength,
|
||||
);
|
||||
const label = ` ${i + 1}: ${truncatedCommand} `;
|
||||
const labelWidth = getCachedStringWidth(label);
|
||||
|
||||
// If this is the only shell, we MUST show it (truncated if necessary)
|
||||
// even if it exceeds availableWidth, as there are no alternatives.
|
||||
if (i > 0 && currentWidth + labelWidth > availableWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
const isActive = shell.pid === activePid;
|
||||
|
||||
tabs.push(
|
||||
<Text
|
||||
key={shell.pid}
|
||||
color={isActive ? theme.text.primary : theme.text.secondary}
|
||||
bold={isActive}
|
||||
>
|
||||
{label}
|
||||
</Text>,
|
||||
);
|
||||
currentWidth += labelWidth;
|
||||
}
|
||||
|
||||
if (shellList.length > tabs.length && !isListOpenProp) {
|
||||
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
|
||||
const overflowWidth = getCachedStringWidth(overflowLabel);
|
||||
|
||||
// If we only have one tab, ensure we don't show the overflow if it's too cramped
|
||||
// We want at least 10 chars for the overflow or we favor the first tab.
|
||||
const shouldShowOverflow =
|
||||
tabs.length > 1 || availableWidth - currentWidth >= overflowWidth;
|
||||
|
||||
if (shouldShowOverflow) {
|
||||
tabs.push(
|
||||
<Text key="overflow" color={theme.status.warning} bold>
|
||||
{overflowLabel}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tabs;
|
||||
};
|
||||
|
||||
const renderProcessList = () => {
|
||||
const maxCommandLength = Math.max(
|
||||
0,
|
||||
width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10,
|
||||
);
|
||||
|
||||
const items: Array<RadioSelectItem<number>> = Array.from(
|
||||
shells.values(),
|
||||
).map((shell, index) => {
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
|
||||
let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`;
|
||||
if (shell.status === 'exited') {
|
||||
label += ` (Exit Code: ${shell.exitCode})`;
|
||||
}
|
||||
|
||||
return {
|
||||
key: shell.pid.toString(),
|
||||
value: shell.pid,
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
const initialIndex = items.findIndex((item) => item.value === activePid);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height="100%" width="100%">
|
||||
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
|
||||
<Text bold>
|
||||
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundShellPid(pid);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
|
||||
renderItem={(
|
||||
item,
|
||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||
) => {
|
||||
// Custom render to handle exit code coloring if needed,
|
||||
// or just use default. The default RadioButtonSelect renderer
|
||||
// handles standard label.
|
||||
// But we want to color exit code differently?
|
||||
// The previous implementation colored exit code green/red.
|
||||
// Let's reimplement that.
|
||||
|
||||
// We need access to shell details here.
|
||||
// We can put shell details in the item or lookup.
|
||||
// Lookup from shells map.
|
||||
const shell = shells.get(item.value);
|
||||
if (!shell) return <Text>{item.label}</Text>;
|
||||
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutput = () => {
|
||||
const lines = typeof output === 'string' ? output.split('\n') : output;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={outputRef}
|
||||
data={lines}
|
||||
renderItem={({ item: line, index }) => {
|
||||
if (typeof line === 'string') {
|
||||
return <Text key={index}>{line}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text key={index} wrap="truncate">
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
hasFocus={isFocused}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height="100%"
|
||||
width="100%"
|
||||
borderStyle="single"
|
||||
borderColor={isFocused ? theme.border.focused : undefined}
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderTop={false}
|
||||
paddingX={1}
|
||||
borderColor={isFocused ? theme.border.focused : undefined}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{renderTabs()}
|
||||
<Text bold>
|
||||
{' '}
|
||||
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.accent}>{helpText}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => {
|
||||
questions={choiceQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
|
||||
export type ChecklistStatus =
|
||||
| 'pending'
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
@@ -15,6 +15,7 @@ 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(() => ({
|
||||
@@ -24,7 +25,6 @@ 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', () => ({
|
||||
@@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -166,21 +168,6 @@ 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,
|
||||
@@ -205,7 +192,7 @@ describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
const settings = createMockSettings({ ui: { hideFooter: false } });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
@@ -214,7 +201,7 @@ describe('Composer', () => {
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
const settings = createMockSettings({ ui: { hideFooter: true } });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
@@ -243,8 +230,10 @@ describe('Composer', () => {
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
ui: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
@@ -310,6 +299,32 @@ describe('Composer', () => {
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show during confirmation');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Queue Display', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{!uiState.embeddedShellFocused && (
|
||||
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
ideContext?: IdeContext;
|
||||
skillCount: number;
|
||||
backgroundProcessCount?: number;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
blockedMcpServers,
|
||||
ideContext,
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
openFileCount === 0 &&
|
||||
skillCount === 0
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
@@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
|
||||
Boolean,
|
||||
);
|
||||
const backgroundText = (() => {
|
||||
if (backgroundProcessCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${backgroundProcessCount} Background process${
|
||||
backgroundProcessCount > 1 ? 'es' : ''
|
||||
}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [
|
||||
openFilesText,
|
||||
geminiMdText,
|
||||
mcpText,
|
||||
skillText,
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('DialogManager', () => {
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
consentRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
isSettingsDialogOpen: false,
|
||||
isModelDialogOpen: false,
|
||||
@@ -137,7 +138,11 @@ describe('DialogManager', () => {
|
||||
'LoopDetectionConfirmation',
|
||||
],
|
||||
[
|
||||
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
|
||||
@@ -32,8 +32,6 @@ import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
|
||||
@@ -59,22 +57,6 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
const {
|
||||
request: askUserRequest,
|
||||
submit: askUserSubmit,
|
||||
cancel: askUserCancel,
|
||||
} = useAskUserActions();
|
||||
|
||||
if (askUserRequest) {
|
||||
return (
|
||||
<AskUserDialog
|
||||
questions={askUserRequest.questions}
|
||||
onSubmit={askUserSubmit}
|
||||
onCancel={askUserCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
@@ -134,11 +116,24 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.confirmationRequest) {
|
||||
|
||||
// commandConfirmationRequest and authConsentRequest are kept separate
|
||||
// to avoid focus deadlocks and state race conditions between the
|
||||
// synchronous command loop and the asynchronous auth flow.
|
||||
if (uiState.commandConfirmationRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.confirmationRequest.prompt}
|
||||
onConfirm={uiState.confirmationRequest.onConfirm}
|
||||
prompt={uiState.commandConfirmationRequest.prompt}
|
||||
onConfirm={uiState.commandConfirmationRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.authConsentRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.authConsentRequest.prompt}
|
||||
onConfirm={uiState.authConsentRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user