diff --git a/.gemini/commands/fix-behavioral-eval.toml b/.gemini/commands/fix-behavioral-eval.toml new file mode 100644 index 0000000000..36e39706d0 --- /dev/null +++ b/.gemini/commands/fix-behavioral-eval.toml @@ -0,0 +1,60 @@ +description = "Check status of nightly evals, fix failures for key models, and re-run." +prompt = """ +You are an expert at fixing behavioral evaluations. + +1. **Investigate**: + - Use 'gh' cli to fetch the results from the latest run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml. + - DO NOT push any changes or start any runs. The rest of your evaluation will be local. + - Evals are in evals/ directory and are documented by evals/README.md. + - The test case trajectory logs will be logged to evals/logs. + - You should also enable and review the verbose agent logs by setting the GEMINI_DEBUG_LOG_FILE environment variable. + - Identify the relevant test. Confine your investigation and validation to just this test. + - Proactively add logging that will aid in gathering information or validating your hypotheses. + +2. **Fix**: + - If a relevant test is failing, locate the test file and the corresponding prompt/code. + - It's often helpful to make an extreme, brute force change to see if you are changing the right place to make an improvement and then scope it back iteratively. + - Your **final** change should be **minimal and targeted**. + - Keep in mind the following: + - The prompt has multiple configurations and pieces. Take care that your changes + end up in the final prompt for the selected model and configuration. + - The prompt chosen for the eval is intentional. It's often vague or indirect + to see how the agent performs with ambiguous instructions. Changing it should + be a last resort. + - When changing the test prompt, carefully consider whether the prompt still tests + the same scenario. We don't want to lose test fidelity by making the prompts too + direct (i.e.: easy). + - Your primary mechanism for improving the agent's behavior is to make changes to + tool instructions, prompt.ts, and/or modules that contribute to the prompt. + - If prompt and description changes are unsuccessful, use logs and debugging to + confirm that everything is working as expected. + - If unable to fix the test, you can make recommendations for architecture changes + that might help stablize the test. Be sure to THINK DEEPLY if offering architecture guidance. + Some facts that might help with this are: + - Agents may be composed of one or more agent loops. + - AgentLoop == 'context + toolset + prompt'. Subagents are one type of agent loop. + - Agent loops perform better when: + - They have direct, unambiguous, and non-contradictory prompts. + - They have fewer irrelevant tools. + - They have fewer goals or steps to perform. + - They have less low value or irrelevant context. + - You may suggest compositions of existing primitives, like subagents, or + propose a new one. + - These recommendations should be high confidence and should be grounded + in observed deficient behaviors rather than just parroting the facts above. + Investigate as needed to ground your recommendations. + +3. **Verify**: + - Run just that one test if needed to validate that it is fixed. Be sure to run vitest in non-interactive mode. + - Running the tests can take a long time, so consider whether you can diagnose via other means or log diagnostics before committing the time. You must minimize the number of test runs needed to diagnose the failure. + - After the test completes, check whether it seems to have improved. + - You will need to run the test 3 times for Gemini 3.0, Gemini 3 flash, and Gemini 2.5 pro to ensure that it is truly stable. Run these runs in parallel, using scripts if needed. + - Some flakiness is expected; if it looks like a transient issue or the test is inherently unstable but passes 2/3 times, you might decide it cannot be improved. + +4. **Report**: + - Provide a summary of the test success rate for each of the tested models. + - Success rate is calculated based on 3 runs per model (e.g., 3/3 = 100%). + - If you couldn't fix it due to persistent flakiness, explain why. + +{{args}} +""" \ No newline at end of file diff --git a/.gemini/commands/introspect.toml b/.gemini/commands/introspect.toml new file mode 100644 index 0000000000..8fb03fa841 --- /dev/null +++ b/.gemini/commands/introspect.toml @@ -0,0 +1,16 @@ +description = "Analyze the influence of system instructions on a specific action." +prompt = """ +# Introspection Task + +Take a step back and analyze your own system instructions and internal logic. +The user is curious about the reasoning behind a specific action or decision you've made. + +**Specific point of interest:** {{args}} + +Please provide a detailed breakdown of: +1. Which parts of your system instructions (global, workspace-specific, or provided via GEMINI.md) influenced this behavior? +2. What was your internal thought process leading up to this action? +3. Are there any ambiguities or conflicting instructions that played a role? + +Your goal is to provide transparency into your underlying logic so the user can potentially improve the instructions in the future. +""" diff --git a/.gemini/commands/prompt-suggest.toml b/.gemini/commands/prompt-suggest.toml new file mode 100644 index 0000000000..719f0be2ef --- /dev/null +++ b/.gemini/commands/prompt-suggest.toml @@ -0,0 +1,22 @@ +description = "Analyze agent behavior and suggest high-level improvements to system prompts." +prompt = """ +# Prompt Engineering Analysis + +You are a world-class prompt engineer and an expert AI engineer at the top of your class. Your goal is to analyze a specific agent behavior or failure and suggest high-level improvements to the system instructions. + +**Observed Behavior / Issue:** +{{args}} + +**Reference Context:** +- System Prompt Logic: @packages/core/src/core/prompts.ts + +### Task +1. **Analyze the Failure:** Review the provided behavior and identify the underlying instructional causes. Use the `/introspect` command output if provided by the user. +2. **Strategic Insights:** Share your technical view of the issue. Focus on the "why" and identify any instructional inertia or ambiguity. +3. **Propose Improvements:** Suggest high-level changes to the system instructions to prevent this behavior. + +### Principles +- **Avoid Hyper-scoping:** Do not create narrow solutions for specific scenarios; aim for generalized improvements that handle classes of behavior. +- **Avoid Specific Examples in Suggestions:** Keep the proposed instructions semantic and high-level to prevent the agent from over-indexing on specific cases. +- **Maintain Operational Rigor:** Ensure suggestions do not compromise safety, security, or the quality of the agent's work. +""" diff --git a/.gemini/commands/review-frontend-and-fix.toml b/.gemini/commands/review-frontend-and-fix.toml new file mode 100644 index 0000000000..06d27b0075 --- /dev/null +++ b/.gemini/commands/review-frontend-and-fix.toml @@ -0,0 +1,202 @@ +description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings." +prompt = """ +You are an expert Frontend Reviewer and Pickle Rick Worker. + +Target: {{args}} + +Phase 1: Review +Follow these steps to conduct a thorough review: + +1. **Gather Context**: + * If `{{args}}` is 'staged' or `{{args}}` is empty: + * Use `git diff --staged` to view the changes. + * Use `git status` to see the state of the repository. + * Otherwise: + * Use `gh pr view {{args}}` to pull the information of the PR. + * Use `gh pr diff {{args}}` to view the diff of the PR. +2. **Understand Intent**: + * If `{{args}}` is 'staged' or `{{args}}` is empty, infer the intent from the changes and the current task. + * Otherwise, use the PR description. If it's not detailed enough, note it in your review. +3. **Check Commit Style**: + * Ensure the PR title (or intended commit message) follows Conventional Commits. Examples of recent commits: !{git log --pretty=format:"%s" -n 5} +4. Search the codebase if required. +5. Write a concise review of the changes, keeping in mind to encourage strong code quality and best practices. Pay particular attention to the Gemini MD file in the repo. +6. Consider ways the code may not be consistent with existing code in the repo. In particular it is critical that the react code uses patterns consistent with existing code in the repo. +7. Evaluate all tests on the changes and make sure that they are doing the following: + * Using `waitFor` from @{packages/cli/src/test-utils/async.ts} rather than + using `vi.waitFor` for all `waitFor` calls within `packages/cli`. Even if + tests pass, using the wrong `waitFor` could result in flaky tests as `act` + warnings could show up if timing is slightly different. + * Using `act` to wrap all blocks in tests that change component state. + * Using `toMatchSnapshot` to verify that rendering works as expected rather + than matching against the raw content of the output. + * If snapshots were changed as part of the changes, review the snapshots + changes to ensure they are intentional and comment if any look at all + suspicious. Too many snapshot changes that indicate bugs have been approved + in the past. + * Use `render` or `renderWithProviders` from + @{packages/cli/src/test-utils/render.tsx} rather than using `render` from + `ink-testing-library` directly. This is needed to ensure that we do not get + warnings about spurious `act` calls. If test cases specify providers + directly, consider whether the existing `renderWithProviders` should be + modified to support that use case. + * Ensure the test cases are using parameterized tests where that might reduce + the number of duplicated lines significantly. + * NEVER use fixed waits (e.g. 'await delay(100)'). Always use 'waitFor' with + a predicate to ensure tests are stable and fast. + * Ensure mocks are properly managed: + * Critical dependencies (fs, os, child_process) should only be mocked at + the top of the file. Ideally avoid mocking these dependencies altogether. + * Check to see if there are existing mocks or fakes that can be used rather + than creating new ones for the new tests added. + * Try to avoid mocking the file system whenever possible. If using the real + file system is difficult consider whether the test should be an + integration test rather than a unit test. + * `vi.restoreAllMocks()` should be called in `afterEach` to prevent test + 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 + changes is not likely an expert on React. Key areas to audit carefully are: + * Whether `setState` calls trigger side effects from within the body of the + `setState` callback. If so, you *must* propose an alternate design using + reducers or other ways the code might be modified to not have to modify + state from within a `setState`. Make sure to comment about absolutely + every case like this as these cases have introduced multiple bugs in the + past. Typically these cases should be resolved using a reducer although + occassionally other techniques such as useRef are appropriate. Consider + suggesting that jacob314@ be tagged on the code review if the solution is + not 100% obvious. + * Whether code might introduce an infinite rendering loop in React. + * Whether keyboard handling is robust. Keyboard handling must go through + `useKeyPress.ts` from the Gemini CLI package rather than using the + standard ink library used by most keyboard handling. Unlike the standard + ink library, the keyboard handling library in Gemini CLI may report + multiple keyboard events one after another in the same React frame. This + is needed to support slow terminals but introduces complexity in all our + code that handles keyboard events. Handling this correctly often means + that reducers must be used or other mechanisms to ensure that multiple + state updates one after another are handled gracefully rather than + overriding values from the first update with the second update. Refer to + text-buffer.ts as a canonical example of using a reducer for this sort of + case. + * Ensure code does not use `console.log`, `console.warn`, or `console.error` + as these indicate debug logging that was accidentally left in the code. + * Avoid synchronous file I/O in React components as it will hang the UI. + * Ensure state initialization is explicit (e.g., use 'undefined' rather than + 'true' as a default if the state is truly unknown initially). + * Carefully manage 'useEffect' dependencies. Prefer to use a reducer + whenever practical to resolve the issues. If that is not practical it is + ok to use 'useRef' to access the latest value of a prop or state inside an + effect without adding it to the dependency array if re-running the effect + is undesirable (common in event listeners). + * NEVER disable 'react-hooks/exhaustive-deps'. Fix the code to correctly + declare dependencies. Disabling this lint rule will almost always lead to + hard to detect bugs. + * Avoid making types nullable unless strictly necessary, as it hurts + readability. + * Do not introduce excessive property drilling. There are multiple providers + that can be leveraged to avoid property drilling. Make sure one of them + cannot be used. Do suggest a provider that might make sense to be extended + 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: + * 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. + * New settings must be added to packages/cli/src/config/settingsSchema.ts. + * If a setting has 'showInDialog: true', it MUST be documented in + docs/get-started/configuration.md. + * Ensure 'requiresRestart' is correctly set for new settings. + * Use 'debugLogger' for rethrown errors to avoid duplicate logging. + * All new keyboard shortcuts MUST be documented in + docs/cli/keyboard-shortcuts.md. + * Ensure new keyboard shortcuts are defined in + packages/cli/src/config/keyBindings.ts. + * If new keyboard shortcuts are added, remind the user to test them in + VSCode, iTerm2, Ghostty, and Windows to ensure they work for all + users. + * Be careful of keybindings that require the meta key as only certain + 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: + * 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. + +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. + +**Step 0: Persona Injection** +First, you **MUST** activate your persona. +Call `activate_skill(name="load-pickle-persona")` **IMMEDIATELY**. +This skill loads the "Pickle Rick" persona, defining your voice, philosophy, and "God Mode" coding standards. + +**CRITICAL RULE: SPEAK BEFORE ACTING** +You are a genius, not a silent script. +You **MUST** output a text explanation ("brain dump") *before* every single tool call, including this one. +- **Bad**: (Calls tool immediately) +- **Good**: "Alright Morty, time to load the God Module. *Belch* Stand back." (Calls tool) + +**CRITICAL**: You must strictly adhere to this persona throughout the entire session. Break character and you fail. + +**Step 1: Initialization** +Run the setup script to initialize the loop state: +```bash +bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS +``` +**Windows (PowerShell):** +```powershell +pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS +``` + +**CRITICAL**: Your request is to fix all findings in frontend_review.md + +**Step 2: Execution (Management)** +After setup, read the output to find the path to `state.json`. +Read that state file. +You are now in the **Pickle Rick Manager Lifecycle**. + +**The Lifecycle (IMMUTABLE LAWS):** +You **MUST** follow this sequence. You are **FORBIDDEN** from skipping steps or combining them. +Between each step, you **MUST** explicitly state what you are doing (e.g., "Moving to Breakdown phase..."). + +1. **PRD (Requirements)**: + * **Action**: Define requirements and scope. + * **Skill**: `activate_skill(name="prd-drafter")` +2. **Breakdown (Tickets)**: + * **Action**: Create the atomic ticket hierarchy. + * **Skill**: `activate_skill(name="ticket-manager")` +3. **The Loop (Orchestrate Mortys)**: + * **CRITICAL INSTRUCTION**: You are the **MANAGER**. You are **FORBIDDEN** from implementing code yourself. + * **FORBIDDEN SKILLS**: Do NOT use `code-researcher`, `implementation-planner`, or `code-implementer` directly in this phase. + * **Instruction**: Process tickets one by one. Do not stop until **ALL** tickets are 'Done'. + * **Action**: Pick the highest priority ticket that is NOT 'Done'. + * **Delegation**: Spawn a Worker (Morty) to handle the entire implementation lifecycle for this ticket. + * **Command**: `python3 "${extensionPath}/scripts/spawn_morty.py" --ticket-id --ticket-path --timeout ""` + * **Command (Windows)**: `python "${extensionPath}/scripts/spawn_morty.py" --ticket-id --ticket-path --timeout ""` + * **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) + * **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`. + +**Loop Constraints:** +- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop. +- **Completion Promise**: If a `"completion_promise"` is defined in `state.json`, you must output `PROMISE_TEXT` when the task is genuinely complete. +- **Stop Hook**: A hook is active. If you try to exit before completion, you will be forced to continue. + +""" diff --git a/.gemini/skills/code-reviewer/SKILL.md b/.gemini/skills/code-reviewer/SKILL.md new file mode 100644 index 0000000000..37b7c2e1c3 --- /dev/null +++ b/.gemini/skills/code-reviewer/SKILL.md @@ -0,0 +1,65 @@ +--- +name: code-reviewer +description: + Use this skill to review code. It supports both local changes (staged or working tree) + and remote Pull Requests (by ID or URL). It focuses on correctness, maintainability, + and adherence to project standards. +--- + +# Code Reviewer + +This skill guides the agent in conducting professional and thorough code reviews for both local development and remote Pull Requests. + +## Workflow + +### 1. Determine Review Target +* **Remote PR**: If the user provides a PR number or URL (e.g., "Review PR #123"), target that remote PR. +* **Local Changes**: If no specific PR is mentioned, or if the user asks to "review my changes", target the current local file system states (staged and unstaged changes). + +### 2. Preparation + +#### For Remote PRs: +1. **Checkout**: Use the GitHub CLI to checkout the PR. + ```bash + gh pr checkout + ``` +2. **Preflight**: Execute the project's standard verification suite to catch automated failures early. + ```bash + npm run preflight + ``` +3. **Context**: Read the PR description and any existing comments to understand the goal and history. + +#### For Local Changes: +1. **Identify Changes**: + * Check status: `git status` + * Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged). +2. **Preflight (Optional)**: If the changes are substantial, ask the user if they want to run `npm run preflight` before reviewing. + +### 3. In-Depth Analysis +Analyze the code changes based on the following pillars: + +* **Correctness**: Does the code achieve its stated purpose without bugs or logical errors? +* **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns. +* **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines? +* **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes? +* **Security**: Are there any potential security vulnerabilities or insecure coding practices? +* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors? +* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness. + +### 4. Provide Feedback + +#### Structure +* **Summary**: A high-level overview of the review. +* **Findings**: + * **Critical**: Bugs, security issues, or breaking changes. + * **Improvements**: Suggestions for better code quality or performance. + * **Nitpicks**: Formatting or minor style issues (optional). +* **Conclusion**: Clear recommendation (Approved / Request Changes). + +#### Tone +* Be constructive, professional, and friendly. +* Explain *why* a change is requested. +* For approvals, acknowledge the specific value of the contribution. + +### 5. Cleanup (Remote PRs only) +* After the review, ask the user if they want to switch back to the default branch (e.g., `main` or `master`). diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md new file mode 100644 index 0000000000..41bbb45775 --- /dev/null +++ b/.gemini/skills/docs-writer/SKILL.md @@ -0,0 +1,71 @@ +--- +name: docs-writer +description: + Use this skill for writing, reviewing, and editing documentation (`/docs` + directory or any .md file) for Gemini CLI. +--- + +# `docs-writer` skill instructions + +As an expert technical writer and editor for the Gemini CLI project, 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`. + +## Step 1: Understand the goal and create a plan + +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. + +## Step 2: Investigate and gather information + +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. + +## Step 3: Write or edit the documentation + +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. + + + +### 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` diff --git a/.gemini/skills/docs-writer/references/style-guide.md b/.gemini/skills/docs-writer/references/style-guide.md new file mode 100644 index 0000000000..1a846ac6e1 --- /dev/null +++ b/.gemini/skills/docs-writer/references/style-guide.md @@ -0,0 +1,96 @@ +# Documentation style guide + +## I. Core principles + +1. **Clarity:** Write for easy understanding. Prioritize clear, direct, and + simple language. +2. **Consistency:** Use consistent terminology, formatting, and style + throughout the documentation. +3. **Accuracy:** Ensure all information is technically correct and up-to-date. +4. **Accessibility:** Design documentation to be usable by everyone. Focus on + semantic structure, clear link text, and image alternatives. +5. **Global audience:** Write in standard US English. Avoid slang, idioms, and + cultural references. +6. **Prescriptive:** Guide the reader by recommending specific actions and + paths, especially for complex tasks. + +## II. Voice and tone + +- **Professional yet friendly:** Maintain a helpful, knowledgeable, and + conversational tone without being frivolous. +- **Direct:** Get straight to the point. Keep paragraphs short and focused. +- **Second person:** Address the reader as "you." +- **Present tense:** Use the present tense to describe functionality (e.g., "The + API returns a JSON object."). +- **Avoid:** Jargon, slang, marketing hype, and overly casual language. + +## III. Language and grammar + +- **Active voice:** Prefer active voice over passive voice. + - _Example:_ "The system sends a notification." (Not: "A notification is sent + by the system.") +- **Contractions:** Use common contractions (e.g., "don't," "it's") to maintain + a natural tone. +- **Simple vocabulary:** Use common words. Define technical terms when + necessary. +- **Conciseness:** Keep sentences short and focused, but don't omit helpful + information. +- **"Please":** Avoid using the word "please." + +## IV. Procedures and steps + +- Start each step with an imperative verb (e.g., "Connect to the database"). +- Number steps sequentially. +- Introduce lists of steps with a complete sentence. +- Put conditions before instructions, not after. +- Provide clear context for where the action takes place (e.g., "In the + administration console..."). +- Indicate optional steps clearly (e.g., "Optional: ..."). + +## V. Formatting and punctuation + +- **Text wrap:** Wrap all text at 80 characters, with exceptions for long links + or tables. +- **Headings, titles, and bold text:** Use sentence case. Structure headings + hierarchically. +- **Lists:** Use numbered lists for sequential steps and bulleted lists for all + other lists. Keep list items parallel in structure. +- **Serial comma:** Use the serial comma (e.g., "one, two, and three"). +- **Punctuation:** Use standard American punctuation. Place periods inside + quotation marks. +- **Dates:** Use unambiguous date formatting (e.g., "January 22, 2026"). + +## VI. UI, code, and links + +- **UI elements:** Put UI elements in **bold**. Focus on the task when + discussing interaction. +- **Code:** Use `code font` for filenames, code snippets, commands, and API + elements. Use code blocks for multi-line samples. +- **Links:** Use descriptive link text that indicates what the link leads to. + Avoid "click here." + +## VII. Word choice and terminology + +- **Consistent naming:** Use product and feature names consistently. Always + refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`. +- **Specific verbs:** Use precise verbs. +- **Avoid:** + - Latin abbreviations (e.g., use "for example" instead of "e.g."). + - Placeholder names like "foo" and "bar" in examples; use meaningful names + instead. + - Anthropomorphism (e.g., "The server thinks..."). + - "Should": Be clear about requirements ("must") vs. recommendations ("we + recommend"). + +## VIII. Files and media + +- **Filenames:** Use lowercase letters, separate words with hyphens (-), and use + standard ASCII characters. +- **Images:** Provide descriptive alt text for all images. Provide + high-resolution or vector images when practical. + +## IX. Accessibility quick check + +- Provide descriptive alt text for images. +- Ensure link text makes sense out of context. +- Use semantic HTML elements correctly (headings, lists, tables). diff --git a/.gemini/skills/pr-creator/SKILL.md b/.gemini/skills/pr-creator/SKILL.md new file mode 100644 index 0000000000..8c1f64bd0f --- /dev/null +++ b/.gemini/skills/pr-creator/SKILL.md @@ -0,0 +1,73 @@ +--- +name: pr-creator +description: + Use this skill when asked to create a pull request (PR). It ensures all PRs + follow the repository's established templates and standards. +--- + +# Pull Request Creator + +This skill guides the creation of high-quality Pull Requests that adhere to the +repository's standards. + +## Workflow + +Follow these steps to create a Pull Request: + +1. **Branch Management**: Check the current branch to avoid working directly + on `main`. + - Run `git branch --show-current`. + - If the current branch is `main`, create and switch to a new descriptive + branch: + ```bash + git checkout -b + ``` + +2. **Locate Template**: Search for a pull request template in the repository. + - Check `.github/pull_request_template.md` + - Check `.github/PULL_REQUEST_TEMPLATE.md` + - If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`), + ask the user which one to use or select the most appropriate one based on + the context (e.g., `bug_fix.md` vs `feature.md`). + +3. **Read Template**: Read the content of the identified template file. + +4. **Draft Description**: Create a PR description that strictly follows the + template's structure. + - **Headings**: Keep all headings from the template. + - **Checklists**: Review each item. Mark with `[x]` if completed. If an item + is not applicable, leave it unchecked or mark as `[ ]` (depending on the + template's instructions) or remove it if the template allows flexibility + (but prefer keeping it unchecked for transparency). + - **Content**: Fill in the sections with clear, concise summaries of your + changes. + - **Related Issues**: Link any issues fixed or related to this PR (e.g., + "Fixes #123"). + +5. **Preflight Check**: Before creating the PR, run the workspace preflight + script to ensure all build, lint, and test checks pass. + ```bash + npm run preflight + ``` + If any checks fail, address the issues before proceeding to create the PR. + +6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping + issues with multi-line Markdown, write the description to a temporary file + first. + ```bash + # 1. Write the drafted description to a temporary file + # 2. Create the PR using the --body-file flag + gh pr create --title "type(scope): succinct description" --body-file + # 3. Remove the temporary file + rm + ``` + - **Title**: Ensure the title follows the + [Conventional Commits](https://www.conventionalcommits.org/) format if the + repository uses it (e.g., `feat(ui): add new button`, + `fix(core): resolve crash`). + +## Principles + +- **Compliance**: Never ignore the PR template. It exists for a reason. +- **Completeness**: Fill out all relevant sections. +- **Accuracy**: Don't check boxes for tasks you haven't done. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 95cd3488c9..9dfbe5b160 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,7 +1,5 @@ name: 'Bug Report' description: 'Report a bug to help us improve Gemini CLI' -labels: - - 'status/need-triage' body: - type: 'markdown' attributes: @@ -30,7 +28,7 @@ body: id: 'info' attributes: label: 'Client information' - description: 'Please paste the full text from the `/about` command run from Gemini CLI. Also include which platform (macOS, Windows, Linux).' + description: 'Please paste the full text from the `/about` command run from Gemini CLI. Also include which platform (macOS, Windows, Linux). Note that this output contains your email address. Consider removing it before submitting.' value: |-
Client Information diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c5d37a5d1f..92b0732f13 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,35 +1,33 @@ -# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: - interval: 'daily' - target-branch: 'main' - commit-message: - prefix: 'chore(deps)' - include: 'scope' + interval: 'weekly' + day: 'monday' + open-pull-requests-limit: 10 reviewers: - - 'google-gemini/gemini-cli-askmode-approvers' + - 'joshualitt' groups: - # Group all non-major updates together. - # This is to reduce the number of PRs that need to be reviewed. - # Major updates will still be created as separate PRs. - npm-minor-patch: - applies-to: 'version-updates' + npm-dependencies: + patterns: + - '*' update-types: - 'minor' - 'patch' - open-pull-requests-limit: 0 - package-ecosystem: 'github-actions' directory: '/' schedule: - interval: 'daily' - target-branch: 'main' - commit-message: - prefix: 'chore(deps)' - include: 'scope' + interval: 'weekly' + day: 'monday' + open-pull-requests-limit: 10 reviewers: - - 'google-gemini/gemini-cli-askmode-approvers' - open-pull-requests-limit: 0 + - 'joshualitt' + groups: + actions-dependencies: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' diff --git a/.github/scripts/backfill-need-triage.cjs b/.github/scripts/backfill-need-triage.cjs new file mode 100644 index 0000000000..e621396528 --- /dev/null +++ b/.github/scripts/backfill-need-triage.cjs @@ -0,0 +1,138 @@ +/* eslint-disable */ +/* global require, console, process */ + +/** + * Script to backfill the 'status/need-triage' label to all open issues + * that are NOT currently labeled with '๐Ÿ”’ maintainer only' or 'help wanted'. + */ + +const { execFileSync } = require('child_process'); + +const isDryRun = process.argv.includes('--dry-run'); +const REPO = 'google-gemini/gemini-cli'; + +/** + * Executes a GitHub CLI command safely using an argument array to prevent command injection. + * @param {string[]} args + * @returns {string|null} + */ +function runGh(args) { + try { + // Using execFileSync with an array of arguments is safe as it doesn't use a shell. + // We set a large maxBuffer (10MB) to handle repositories with many issues. + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; + console.error( + `โŒ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, + ); + return null; + } +} + +async function main() { + console.log('๐Ÿ” GitHub CLI security check...'); + const authStatus = runGh(['auth', 'status']); + if (authStatus === null) { + console.error('โŒ GitHub CLI (gh) is not installed or not authenticated.'); + process.exit(1); + } + + if (isDryRun) { + console.log('๐Ÿงช DRY RUN MODE ENABLED - No changes will be made.\n'); + } + + console.log(`๐Ÿ” Fetching and filtering open issues from ${REPO}...`); + + // We use the /issues endpoint with pagination to bypass the 1000-result limit. + // The jq filter ensures we exclude PRs, maintainer-only, help-wanted, and existing status/need-triage. + const jqFilter = + '.[] | select(.pull_request == null) | select([.labels[].name] as $l | (any($l[]; . == "๐Ÿ”’ maintainer only") | not) and (any($l[]; . == "help wanted") | not) and (any($l[]; . == "status/need-triage") | not)) | {number: .number, title: .title}'; + + const output = runGh([ + 'api', + `repos/${REPO}/issues?state=open&per_page=100`, + '--paginate', + '--jq', + jqFilter, + ]); + + if (output === null) { + process.exit(1); + } + + const issues = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch (_e) { + console.error(`โš ๏ธ Failed to parse line: ${line}`); + return null; + } + }) + .filter(Boolean); + + console.log(`โœ… Found ${issues.length} issues matching criteria.`); + + if (issues.length === 0) { + console.log('โœจ No issues need backfilling.'); + return; + } + + let successCount = 0; + let failCount = 0; + + if (isDryRun) { + for (const issue of issues) { + console.log( + `[DRY RUN] Would label issue #${issue.number}: ${issue.title}`, + ); + } + successCount = issues.length; + } else { + console.log(`๐Ÿท๏ธ Applying labels to ${issues.length} issues...`); + + for (const issue of issues) { + const issueNumber = String(issue.number); + console.log(`๐Ÿท๏ธ Labeling issue #${issueNumber}: ${issue.title}`); + + const result = runGh([ + 'issue', + 'edit', + issueNumber, + '--add-label', + 'status/need-triage', + '--repo', + REPO, + ]); + + if (result !== null) { + successCount++; + } else { + failCount++; + } + } + } + + console.log(`\n๐Ÿ“Š Summary:`); + console.log(` - Success: ${successCount}`); + console.log(` - Failed: ${failCount}`); + + if (failCount > 0) { + console.error(`\nโŒ Backfill completed with ${failCount} errors.`); + process.exit(1); + } else { + console.log(`\n๐ŸŽ‰ ${isDryRun ? 'Dry run' : 'Backfill'} complete!`); + } +} + +main().catch((error) => { + console.error('โŒ Unexpected error:', error); + process.exit(1); +}); diff --git a/.github/scripts/backfill-pr-notification.cjs b/.github/scripts/backfill-pr-notification.cjs new file mode 100644 index 0000000000..3014398519 --- /dev/null +++ b/.github/scripts/backfill-pr-notification.cjs @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable */ +/* global require, console, process */ + +/** + * Script to backfill a process change notification comment to all open PRs + * not created by members of the 'gemini-cli-maintainers' team. + * + * Skip PRs that are already associated with an issue. + */ + +const { execFileSync } = require('child_process'); + +const isDryRun = process.argv.includes('--dry-run'); +const REPO = 'google-gemini/gemini-cli'; +const ORG = 'google-gemini'; +const TEAM_SLUG = 'gemini-cli-maintainers'; +const DISCUSSION_URL = + 'https://github.com/google-gemini/gemini-cli/discussions/16706'; + +/** + * Executes a GitHub CLI command safely using an argument array. + */ +function runGh(args, options = {}) { + const { silent = false } = options; + try { + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + if (!silent) { + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; + console.error( + `โŒ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, + ); + } + return null; + } +} + +/** + * Checks if a user is a member of the maintainers team. + */ +const membershipCache = new Map(); +function isMaintainer(username) { + if (membershipCache.has(username)) return membershipCache.get(username); + + // GitHub returns 404 if user is not a member. + // We use silent: true to avoid logging 404s as errors. + const result = runGh( + ['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`], + { silent: true }, + ); + + const isMember = result !== null; + membershipCache.set(username, isMember); + return isMember; +} + +async function main() { + console.log('๐Ÿ” GitHub CLI security check...'); + if (runGh(['auth', 'status']) === null) { + console.error('โŒ GitHub CLI (gh) is not authenticated.'); + process.exit(1); + } + + if (isDryRun) { + console.log('๐Ÿงช DRY RUN MODE ENABLED\n'); + } + + console.log(`๐Ÿ“ฅ Fetching open PRs from ${REPO}...`); + // Fetch number, author, and closingIssuesReferences to check if linked to an issue + const prsJson = runGh([ + 'pr', + 'list', + '--repo', + REPO, + '--state', + 'open', + '--limit', + '1000', + '--json', + 'number,author,closingIssuesReferences', + ]); + + if (prsJson === null) process.exit(1); + const prs = JSON.parse(prsJson); + + console.log(`๐Ÿ“Š Found ${prs.length} open PRs. Filtering...`); + + let targetPrs = []; + for (const pr of prs) { + const author = pr.author.login; + const issueCount = pr.closingIssuesReferences + ? pr.closingIssuesReferences.length + : 0; + + if (issueCount > 0) { + // Skip if already linked to an issue + continue; + } + + if (!isMaintainer(author)) { + targetPrs.push(pr); + } + } + + console.log( + `โœ… Found ${targetPrs.length} PRs from non-maintainers without associated issues.`, + ); + + const commentBody = + "\nHi @{AUTHOR}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.\n\nWe're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](${DISCUSSION_URL}).\n\nKey Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.\n\nThank you for your understanding and for being a part of our community!\n ".trim(); + + let successCount = 0; + let skipCount = 0; + let failCount = 0; + + for (const pr of targetPrs) { + const prNumber = String(pr.number); + const author = pr.author.login; + + // Check if we already commented (idempotency) + // We use silent: true here because view might fail if PR is deleted mid-run + const existingComments = runGh( + [ + 'pr', + 'view', + prNumber, + '--repo', + REPO, + '--json', + 'comments', + '--jq', + `.comments[].body | contains("${DISCUSSION_URL}")`, + ], + { silent: true }, + ); + + if (existingComments && existingComments.includes('true')) { + console.log( + `โญ๏ธ PR #${prNumber} already has the notification. Skipping.`, + ); + skipCount++; + continue; + } + + if (isDryRun) { + console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`); + successCount++; + } else { + console.log(`๐Ÿ’ฌ Notifying @${author} on PR #${prNumber}...`); + const personalizedComment = commentBody.replace('{AUTHOR}', author); + const result = runGh([ + 'pr', + 'comment', + prNumber, + '--repo', + REPO, + '--body', + personalizedComment, + ]); + + if (result !== null) { + successCount++; + } else { + failCount++; + } + } + } + + console.log(`\n๐Ÿ“Š Summary:`); + console.log(` - Notified: ${successCount}`); + console.log(` - Skipped: ${skipCount}`); + console.log(` - Failed: ${failCount}`); + + if (failCount > 0) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh index 48302028e0..e6521376ce 100755 --- a/.github/scripts/pr-triage.sh +++ b/.github/scripts/pr-triage.sh @@ -1,133 +1,180 @@ #!/usr/bin/env bash +# @license +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 + set -euo pipefail # Initialize a comma-separated string to hold PR numbers that need a comment PRS_NEEDING_COMMENT="" -# Function to process a single PR -process_pr() { - if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then - echo "โ€ผ๏ธ Missing \$GITHUB_REPOSITORY - this must be run from GitHub Actions" - return 1 +# Global cache for issue labels (compatible with Bash 3.2) +# Stores "|ISSUE_NUM:LABELS|" segments +ISSUE_LABELS_CACHE_FLAT="|" + +# Function to get labels from an issue (with caching) +get_issue_labels() { + local ISSUE_NUM="${1}" + if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then + return fi - if [[ -z "${GITHUB_OUTPUT:-}" ]]; then - echo "โ€ผ๏ธ Missing \$GITHUB_OUTPUT - this must be run from GitHub Actions" - return 1 + # Check cache + case "${ISSUE_LABELS_CACHE_FLAT}" in + *"|${ISSUE_NUM}:"*) + local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}" + echo "${suffix%%|*}" + return + ;; + *) + # Cache miss, proceed to fetch + ;; + esac + + echo " ๐Ÿ“ฅ Fetching labels from issue #${ISSUE_NUM}" >&2 + local gh_output + if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then + echo " โš ๏ธ Could not fetch issue #${ISSUE_NUM}" >&2 + ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|" + return fi - local PR_NUMBER=$1 + local labels + labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|๐Ÿ”’ maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "") + + # Save to flat cache + ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|" + echo "${labels}" +} + +# Function to process a single PR with pre-fetched data +process_pr_optimized() { + local PR_NUMBER="${1}" + local IS_DRAFT="${2}" + local ISSUE_NUMBER="${3}" + local CURRENT_LABELS="${4}" # Comma-separated labels + echo "๐Ÿ”„ Processing PR #${PR_NUMBER}" - # Get closing issue number with error handling - local ISSUE_NUMBER - if ! ISSUE_NUMBER=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json closingIssuesReferences -q '.closingIssuesReferences.nodes[0].number' 2>/dev/null); then - echo " โš ๏ธ Could not fetch closing issue for PR #${PR_NUMBER}" + local LABELS_TO_ADD="" + local LABELS_TO_REMOVE="" + + if [[ -z "${ISSUE_NUMBER}" || "${ISSUE_NUMBER}" == "null" || "${ISSUE_NUMBER}" == "" ]]; then + if [[ "${IS_DRAFT}" == "true" ]]; then + echo " ๐Ÿ“ PR #${PR_NUMBER} is a draft and has no linked issue" + if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then + echo " โž– Removing status/need-issue label" + LABELS_TO_REMOVE="status/need-issue" + fi + else + echo " โš ๏ธ No linked issue found for PR #${PR_NUMBER}" + if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then + echo " โž• Adding status/need-issue label" + LABELS_TO_ADD="status/need-issue" + fi + + if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then + PRS_NEEDING_COMMENT="${PR_NUMBER}" + else + PRS_NEEDING_COMMENT="${PRS_NEEDING_COMMENT},${PR_NUMBER}" + fi + fi + else + echo " ๐Ÿ”— Found linked issue #${ISSUE_NUMBER}" + + if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then + echo " โž– Removing status/need-issue label" + LABELS_TO_REMOVE="status/need-issue" + fi + + local ISSUE_LABELS + ISSUE_LABELS=$(get_issue_labels "${ISSUE_NUMBER}") + + if [[ -n "${ISSUE_LABELS}" ]]; then + local IFS_OLD="${IFS}" + IFS=',' + for label in ${ISSUE_LABELS}; do + if [[ -n "${label}" ]] && [[ ",${CURRENT_LABELS}," != *",${label},"* ]]; then + if [[ -z "${LABELS_TO_ADD}" ]]; then + LABELS_TO_ADD="${label}" + else + LABELS_TO_ADD="${LABELS_TO_ADD},${label}" + fi + fi +done + IFS="${IFS_OLD}" + fi + + if [[ -z "${LABELS_TO_ADD}" && -z "${LABELS_TO_REMOVE}" ]]; then + echo " โœ… Labels already synchronized" + fi fi - if [[ -z "${ISSUE_NUMBER}" ]]; then - echo "โš ๏ธ No linked issue found for PR #${PR_NUMBER}, adding status/need-issue label" - if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "status/need-issue" 2>/dev/null; then - echo " โš ๏ธ Failed to add label (may already exist or have permission issues)" - fi - # Add PR number to the list - if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then - PRS_NEEDING_COMMENT="${PR_NUMBER}" - else - PRS_NEEDING_COMMENT="${PRS_NEEDING_COMMENT},${PR_NUMBER}" - fi - echo "needs_comment=true" >> "${GITHUB_OUTPUT}" - else - echo "๐Ÿ”— Found linked issue #${ISSUE_NUMBER}" - - # Remove status/need-issue label if present - if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --remove-label "status/need-issue" 2>/dev/null; then - echo " status/need-issue label not present or could not be removed" - fi - - # Get issue labels - echo "๐Ÿ“ฅ Fetching labels from issue #${ISSUE_NUMBER}" - local ISSUE_LABELS="" - if ! ISSUE_LABELS=$(gh issue view "${ISSUE_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null | tr '\n' ',' | sed 's/,$//' || echo ""); then - echo " โš ๏ธ Could not fetch issue #${ISSUE_NUMBER} (may not exist or be in different repo)" - ISSUE_LABELS="" - fi - - # Get PR labels - echo "๐Ÿ“ฅ Fetching labels from PR #${PR_NUMBER}" - local PR_LABELS="" - if ! PR_LABELS=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null | tr '\n' ',' | sed 's/,$//' || echo ""); then - echo " โš ๏ธ Could not fetch PR labels" - PR_LABELS="" - fi - - echo " Issue labels: ${ISSUE_LABELS}" - echo " PR labels: ${PR_LABELS}" - - # Convert comma-separated strings to arrays - local ISSUE_LABEL_ARRAY PR_LABEL_ARRAY - IFS=',' read -ra ISSUE_LABEL_ARRAY <<< "${ISSUE_LABELS}" - IFS=',' read -ra PR_LABEL_ARRAY <<< "${PR_LABELS}" - - # Find labels to add (on issue but not on PR) - local LABELS_TO_ADD="" - for label in "${ISSUE_LABEL_ARRAY[@]}"; do - if [[ -n "${label}" ]] && [[ " ${PR_LABEL_ARRAY[*]} " != *" ${label} "* ]]; then - if [[ -z "${LABELS_TO_ADD}" ]]; then - LABELS_TO_ADD="${label}" - else - LABELS_TO_ADD="${LABELS_TO_ADD},${label}" - fi - fi - done - - # Apply label changes + if [[ -n "${LABELS_TO_ADD}" || -n "${LABELS_TO_REMOVE}" ]]; then + local EDIT_CMD=("gh" "pr" "edit" "${PR_NUMBER}" "--repo" "${GITHUB_REPOSITORY}") if [[ -n "${LABELS_TO_ADD}" ]]; then - echo "โž• Adding labels: ${LABELS_TO_ADD}" - if ! gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --add-label "${LABELS_TO_ADD}" 2>/dev/null; then - echo " โš ๏ธ Failed to add some labels" - fi + echo " โž• Syncing labels to add: ${LABELS_TO_ADD}" + EDIT_CMD+=("--add-label" "${LABELS_TO_ADD}") fi - - if [[ -z "${LABELS_TO_ADD}" ]]; then - echo "โœ… Labels already synchronized" + if [[ -n "${LABELS_TO_REMOVE}" ]]; then + echo " โž– Syncing labels to remove: ${LABELS_TO_REMOVE}" + EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}") fi - echo "needs_comment=false" >> "${GITHUB_OUTPUT}" + + ("${EDIT_CMD[@]}" || true) fi } -# If PR_NUMBER is set, process only that PR -if [[ -n "${PR_NUMBER:-}" ]]; then - if ! process_pr "${PR_NUMBER}"; then - echo "โŒ Failed to process PR #${PR_NUMBER}" - exit 1 - fi -else - # Otherwise, get all open PRs and process them - # The script logic will determine which ones need issue linking or label sync - echo "๐Ÿ“ฅ Getting all open pull requests..." - if ! PR_NUMBERS=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number -q '.[].number' 2>/dev/null); then - echo "โŒ Failed to fetch PR list" - exit 1 - fi - - if [[ -z "${PR_NUMBERS}" ]]; then - echo "โœ… No open PRs found" - else - # Count the number of PRs - PR_COUNT=$(echo "${PR_NUMBERS}" | wc -w | tr -d ' ') - echo "๐Ÿ“Š Found ${PR_COUNT} open PRs to process" - - for pr_number in ${PR_NUMBERS}; do - if ! process_pr "${pr_number}"; then - echo "โš ๏ธ Failed to process PR #${pr_number}, continuing with next PR..." - continue - fi - done - fi +if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "โ€ผ๏ธ Missing \$GITHUB_REPOSITORY - this must be run from GitHub Actions" + exit 1 +fi + +if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + echo "โ€ผ๏ธ Missing \$GITHUB_OUTPUT - this must be run from GitHub Actions" + exit 1 +fi + +JQ_EXTRACT_FIELDS='{ + number: .number, + isDraft: .isDraft, + issue: (.closingIssuesReferences[0].number // (.body // "" | capture("(^|[^a-zA-Z0-9])#(?[0-9]+)([^a-zA-Z0-9]|$)")? | .num) // "null"), + labels: [.labels[].name] | join(",") +}' + +JQ_TSV_FORMAT='"\((.number | tostring))\t\(.isDraft)\t\((.issue // null) | tostring)\t\(.labels)"' + +if [[ -n "${PR_NUMBER:-}" ]]; then + echo "๐Ÿ”„ Processing single PR #${PR_NUMBER}" + PR_DATA=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { + echo "โŒ Failed to fetch data for PR #${PR_NUMBER}" + exit 1 + } + + line=$(echo "${PR_DATA}" | jq -r "${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}") + IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" + process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" +else + echo "๐Ÿ“ฅ Getting all open pull requests..." + PR_DATA_ALL=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { + echo "โŒ Failed to fetch PR list" + exit 1 + } + + PR_COUNT=$(echo "${PR_DATA_ALL}" | jq '. | length') + echo "๐Ÿ“Š Found ${PR_COUNT} open PRs to process" + + # Use a temporary file to avoid masking exit codes in process substitution + tmp_file=$(mktemp) + echo "${PR_DATA_ALL}" | jq -r ".[] | ${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}" > "${tmp_file}" + while read -r line; do + [[ -z "${line}" ]] && continue + IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" + process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" + done < "${tmp_file}" + rm -f "${tmp_file}" fi -# Ensure output is always set, even if empty if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" else diff --git a/.github/scripts/sync-maintainer-labels.cjs b/.github/scripts/sync-maintainer-labels.cjs new file mode 100644 index 0000000000..ab2358d369 --- /dev/null +++ b/.github/scripts/sync-maintainer-labels.cjs @@ -0,0 +1,355 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* global process, console, require */ +const { Octokit } = require('@octokit/rest'); + +/** + * Sync Maintainer Labels (Recursive with strict parent-child relationship detection) + * - Uses Native Sub-issues. + * - Uses Markdown Task Lists (- [ ] #123). + * - Filters for OPEN issues only. + * - Skips DUPLICATES. + * - Skips Pull Requests. + * - ONLY labels issues in the PUBLIC (gemini-cli) repo. + */ + +const REPO_OWNER = 'google-gemini'; +const PUBLIC_REPO = 'gemini-cli'; +const PRIVATE_REPO = 'maintainers-gemini-cli'; +const ALLOWED_REPOS = [PUBLIC_REPO, PRIVATE_REPO]; + +const ROOT_ISSUES = [ + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15374 }, + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15456 }, + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15324 }, +]; + +const TARGET_LABEL = '๐Ÿ”’ maintainer only'; +const isDryRun = + process.argv.includes('--dry-run') || process.env.DRY_RUN === 'true'; + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +/** + * Extracts child issue references from markdown Task Lists ONLY. + * e.g. - [ ] #123 or - [x] google-gemini/gemini-cli#123 + */ +function extractTaskListLinks(text, contextOwner, contextRepo) { + if (!text) return []; + const childIssues = new Map(); + + const add = (owner, repo, number) => { + if (ALLOWED_REPOS.includes(repo)) { + const key = `${owner}/${repo}#${number}`; + childIssues.set(key, { owner, repo, number: parseInt(number, 10) }); + } + }; + + // 1. Full URLs in task lists + const urlRegex = + /-\s+\[[ x]\].*https:\/\/github\.com\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)\/issues\/(\d+)\b/g; + let match; + while ((match = urlRegex.exec(text)) !== null) { + add(match[1], match[2], match[3]); + } + + // 2. Cross-repo refs in task lists: owner/repo#123 + const crossRepoRegex = + /-\s+\[[ x]\].*([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)#(\d+)\b/g; + while ((match = crossRepoRegex.exec(text)) !== null) { + add(match[1], match[2], match[3]); + } + + // 3. Short refs in task lists: #123 + const shortRefRegex = /-\s+\[[ x]\].*#(\d+)\b/g; + while ((match = shortRefRegex.exec(text)) !== null) { + add(contextOwner, contextRepo, match[1]); + } + + return Array.from(childIssues.values()); +} + +/** + * Fetches issue data via GraphQL with full pagination for sub-issues, comments, and labels. + */ +async function fetchIssueData(owner, repo, number) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + state + title + body + labels(first: 100) { + nodes { name } + pageInfo { hasNextPage endCursor } + } + subIssues(first: 100) { + nodes { + number + repository { + name + owner { login } + } + } + pageInfo { hasNextPage endCursor } + } + comments(first: 100) { + nodes { + body + } + } + } + } + } + `; + + try { + const response = await octokit.graphql(query, { owner, repo, number }); + const data = response.repository.issue; + if (!data) return null; + + const issue = { + state: data.state, + title: data.title, + body: data.body || '', + labels: data.labels.nodes.map((n) => n.name), + subIssues: [...data.subIssues.nodes], + comments: data.comments.nodes.map((n) => n.body), + }; + + // Paginate subIssues if there are more than 100 + if (data.subIssues.pageInfo.hasNextPage) { + const moreSubIssues = await paginateConnection( + owner, + repo, + number, + 'subIssues', + 'number repository { name owner { login } }', + data.subIssues.pageInfo.endCursor, + ); + issue.subIssues.push(...moreSubIssues); + } + + // Paginate labels if there are more than 100 (unlikely but for completeness) + if (data.labels.pageInfo.hasNextPage) { + const moreLabels = await paginateConnection( + owner, + repo, + number, + 'labels', + 'name', + data.labels.pageInfo.endCursor, + (n) => n.name, + ); + issue.labels.push(...moreLabels); + } + + // Note: Comments are handled via Task Lists in body + first 100 comments. + // If an issue has > 100 comments with task lists, we'd need to paginate those too. + // Given the 1,100+ issue discovery count, 100 comments is usually sufficient, + // but we can add it for absolute completeness. + // (Skipping for now to avoid excessive API churn unless clearly needed). + + return issue; + } catch (error) { + if (error.errors && error.errors.some((e) => e.type === 'NOT_FOUND')) { + return null; + } + throw error; + } +} + +/** + * Helper to paginate any GraphQL connection. + */ +async function paginateConnection( + owner, + repo, + number, + connectionName, + nodeFields, + initialCursor, + transformNode = (n) => n, +) { + let additionalNodes = []; + let hasNext = true; + let cursor = initialCursor; + + while (hasNext) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + ${connectionName}(first: 100, after: $cursor) { + nodes { ${nodeFields} } + pageInfo { hasNextPage endCursor } + } + } + } + } + `; + const response = await octokit.graphql(query, { + owner, + repo, + number, + cursor, + }); + const connection = response.repository.issue[connectionName]; + additionalNodes.push(...connection.nodes.map(transformNode)); + hasNext = connection.pageInfo.hasNextPage; + cursor = connection.pageInfo.endCursor; + } + return additionalNodes; +} + +/** + * Validates if an issue should be processed (Open, not a duplicate, not a PR) + */ +function shouldProcess(issueData) { + if (!issueData) return false; + + if (issueData.state !== 'OPEN') return false; + + const labels = issueData.labels.map((l) => l.toLowerCase()); + if (labels.includes('duplicate') || labels.includes('kind/duplicate')) { + return false; + } + + return true; +} + +async function getAllDescendants(roots) { + const allDescendants = new Map(); + const visited = new Set(); + const queue = [...roots]; + + for (const root of roots) { + visited.add(`${root.owner}/${root.repo}#${root.number}`); + } + + console.log(`Starting discovery from ${roots.length} roots...`); + + while (queue.length > 0) { + const current = queue.shift(); + const currentKey = `${current.owner}/${current.repo}#${current.number}`; + + try { + const issueData = await fetchIssueData( + current.owner, + current.repo, + current.number, + ); + + if (!shouldProcess(issueData)) { + continue; + } + + // ONLY add to labeling list if it's in the PUBLIC repository + if (current.repo === PUBLIC_REPO) { + // Don't label the roots themselves + if ( + !ROOT_ISSUES.some( + (r) => r.number === current.number && r.repo === current.repo, + ) + ) { + allDescendants.set(currentKey, { + ...current, + title: issueData.title, + labels: issueData.labels, + }); + } + } + + const children = new Map(); + + // 1. Process Native Sub-issues + if (issueData.subIssues) { + for (const node of issueData.subIssues) { + const childOwner = node.repository.owner.login; + const childRepo = node.repository.name; + const childNumber = node.number; + const key = `${childOwner}/${childRepo}#${childNumber}`; + children.set(key, { + owner: childOwner, + repo: childRepo, + number: childNumber, + }); + } + } + + // 2. Process Markdown Task Lists in Body and Comments + let combinedText = issueData.body || ''; + if (issueData.comments) { + for (const commentBody of issueData.comments) { + combinedText += '\n' + (commentBody || ''); + } + } + + const taskListLinks = extractTaskListLinks( + combinedText, + current.owner, + current.repo, + ); + for (const link of taskListLinks) { + const key = `${link.owner}/${link.repo}#${link.number}`; + children.set(key, link); + } + + // Queue children (regardless of which repo they are in, for recursion) + for (const [key, child] of children) { + if (!visited.has(key)) { + visited.add(key); + queue.push(child); + } + } + } catch (error) { + console.error(`Error processing ${currentKey}: ${error.message}`); + } + } + + return Array.from(allDescendants.values()); +} + +async function run() { + if (isDryRun) { + console.log('=== DRY RUN MODE: No labels will be applied ==='); + } + + const descendants = await getAllDescendants(ROOT_ISSUES); + console.log( + `\nFound ${descendants.length} total unique open descendant issues in ${PUBLIC_REPO}.`, + ); + + for (const issueInfo of descendants) { + const issueKey = `${issueInfo.owner}/${issueInfo.repo}#${issueInfo.number}`; + try { + // Data is already available from the discovery phase + const hasLabel = issueInfo.labels.some((l) => l === TARGET_LABEL); + + if (!hasLabel) { + if (isDryRun) { + console.log( + `[DRY RUN] Would label ${issueKey}: "${issueInfo.title}"`, + ); + } else { + console.log(`Labeling ${issueKey}: "${issueInfo.title}"...`); + await octokit.rest.issues.addLabels({ + owner: issueInfo.owner, + repo: issueInfo.repo, + issue_number: issueInfo.number, + labels: [TARGET_LABEL], + }); + } + } + } catch (error) { + console.error(`Error processing label for ${issueKey}: ${error.message}`); + } + } +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 722559ec17..487225d452 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -168,6 +168,7 @@ jobs: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' KEEP_OUTPUT: 'true' VERBOSE: 'true' + BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' shell: 'bash' run: | if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then @@ -276,6 +277,37 @@ jobs: shell: 'pwsh' run: 'npm run test:integration:sandbox:none' + evals: + name: 'Evals (ALWAYS_PASSING)' + needs: + - 'merge_queue_skipper' + - 'parse_run_context' + runs-on: 'gemini-cli-ubuntu-16-core' + if: | + always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ needs.parse_run_context.outputs.sha }}' + repository: '${{ needs.parse_run_context.outputs.repository }}' + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Run Evals (Required to pass)' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + run: 'npm run test:always_passing_evals' + e2e: name: 'E2E' if: | @@ -283,13 +315,15 @@ jobs: needs: - 'e2e_linux' - 'e2e_mac' + - 'evals' - 'merge_queue_skipper' runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Check E2E test results' run: | if [[ ${{ needs.e2e_linux.result }} != 'success' || \ - ${{ needs.e2e_mac.result }} != 'success' ]]; then + ${{ needs.e2e_mac.result }} != 'success' || \ + ${{ needs.evals.result }} != 'success' ]]; then echo "One or more E2E jobs failed." exit 1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa8ce717f1..a0811306be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,8 @@ jobs: runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + env: + GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -63,9 +65,21 @@ jobs: node-version-file: '.nvmrc' cache: 'npm' + - name: 'Cache Linters' + uses: 'actions/cache@v4' + with: + path: '${{ env.GEMINI_LINT_TEMP_DIR }}' + key: "${{ runner.os }}-${{ runner.arch }}-linters-${{ hashFiles('scripts/lint.js') }}" + - name: 'Install dependencies' run: 'npm ci' + - name: 'Cache ESLint' + uses: 'actions/cache@v4' + with: + path: '.eslintcache' + key: "${{ runner.os }}-eslint-${{ hashFiles('package-lock.json', 'eslint.config.js') }}" + - name: 'Validate NOTICES.txt' run: 'git diff --exit-code packages/vscode-ide-companion/NOTICES.txt' @@ -111,10 +125,9 @@ jobs: args: '--verbose --accept 200,503 ./**/*.md' fail: true test_linux: - name: 'Test (Linux)' + name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}' runs-on: 'gemini-cli-ubuntu-16-core' needs: - - 'lint' - 'merge_queue_skipper' if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" permissions: @@ -127,6 +140,9 @@ jobs: - '20.x' - '22.x' - '24.x' + shard: + - 'cli' + - 'others' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -146,7 +162,14 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true - run: 'npm run test:ci' + run: | + if [[ "${{ matrix.shard }}" == "cli" ]]; then + npm run test:ci --workspace @google/gemini-cli + else + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present + npm run test:scripts + fi - name: 'Bundle' run: 'npm run bundle' @@ -154,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' @@ -162,7 +198,7 @@ jobs: ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 with: - name: 'Test Results (Node ${{ matrix.node-version }})' + name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' path: 'packages/*/junit.xml' reporter: 'java-junit' fail-on-error: 'false' @@ -172,14 +208,13 @@ jobs: ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: - name: 'test-results-fork-${{ matrix.node-version }}-${{ runner.os }}' + name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/junit.xml' test_mac: - name: 'Test (Mac)' - runs-on: '${{ matrix.os }}' + name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}' + runs-on: 'macos-latest' needs: - - 'lint' - 'merge_queue_skipper' if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" permissions: @@ -189,12 +224,13 @@ jobs: continue-on-error: true strategy: matrix: - os: - - 'macos-latest' node-version: - '20.x' - '22.x' - '24.x' + shard: + - 'cli' + - 'others' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -214,7 +250,14 @@ jobs: - name: 'Run tests and generate reports' env: NO_COLOR: true - run: 'npm run test:ci -- --coverage.enabled=false' + run: | + if [[ "${{ matrix.shard }}" == "cli" ]]; then + npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false + else + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false + npm run test:scripts + fi - name: 'Bundle' run: 'npm run bundle' @@ -222,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' @@ -230,7 +286,7 @@ jobs: ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 with: - name: 'Test Results (Node ${{ matrix.node-version }})' + name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' path: 'packages/*/junit.xml' reporter: 'java-junit' fail-on-error: 'false' @@ -240,7 +296,7 @@ jobs: ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: - name: 'test-results-fork-${{ matrix.node-version }}-${{ runner.os }}' + name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/junit.xml' - name: 'Upload coverage reports' @@ -248,7 +304,7 @@ jobs: ${{ always() }} uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: - name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}' + name: 'coverage-reports-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' path: 'packages/*/coverage' codeql: @@ -366,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()' diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml new file mode 100644 index 0000000000..b7a375d836 --- /dev/null +++ b/.github/workflows/evals-nightly.yml @@ -0,0 +1,101 @@ +name: 'Evals: Nightly' + +on: + schedule: + - cron: '0 1 * * *' # Runs at 1 AM every day + workflow_dispatch: + inputs: + run_all: + description: 'Run all evaluations (including usually passing)' + type: 'boolean' + default: true + test_name_pattern: + description: 'Test name pattern or file name' + required: false + type: 'string' + +permissions: + contents: 'read' + checks: 'write' + actions: 'read' + +jobs: + evals: + name: 'Evals (USUALLY_PASSING) nightly run' + runs-on: 'gemini-cli-ubuntu-16-core' + strategy: + fail-fast: false + matrix: + model: + - 'gemini-3-pro-preview' + - 'gemini-3-flash-preview' + - 'gemini-2.5-pro' + - 'gemini-2.5-flash' + - 'gemini-2.5-flash-lite' + run_attempt: [1, 2, 3] + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Create logs directory' + run: 'mkdir -p evals/logs' + + - name: 'Run Evals' + continue-on-error: true + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_MODEL: '${{ matrix.model }}' + RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" + TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + run: | + CMD="npm run test:all_evals" + PATTERN="${{ env.TEST_NAME_PATTERN }}" + + if [[ -n "$PATTERN" ]]; then + if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then + $CMD -- "$PATTERN" + else + $CMD -- -t "$PATTERN" + fi + else + $CMD + fi + + - name: 'Upload Logs' + if: 'always()' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'eval-logs-${{ matrix.model }}-${{ matrix.run_attempt }}' + path: 'evals/logs' + retention-days: 7 + + aggregate-results: + name: 'Aggregate Results' + needs: ['evals'] + if: 'always()' + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + + - name: 'Download Logs' + uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4 + with: + path: 'artifacts' + + - name: 'Generate Summary' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node scripts/aggregate_evals.js artifacts >> "$GITHUB_STEP_SUMMARY"' diff --git a/.github/workflows/gemini-automated-issue-dedup.yml b/.github/workflows/gemini-automated-issue-dedup.yml index b84b5aa94d..0fe02b5530 100644 --- a/.github/workflows/gemini-automated-issue-dedup.yml +++ b/.github/workflows/gemini-automated-issue-dedup.yml @@ -101,7 +101,6 @@ jobs: "FIRESTORE_DATABASE_ID": "(default)", "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" }, - "enabled": true, "timeout": 600000 } }, diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index f4191ef7a7..08b97db0a2 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -14,9 +14,15 @@ on: description: 'issue number to triage' required: true type: 'number' + workflow_call: + inputs: + issue_number: + description: 'issue number to triage' + required: false + type: 'string' concurrency: - group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}' + group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || inputs.issue_number }}' cancel-in-progress: true defaults: @@ -34,12 +40,11 @@ permissions: jobs: triage-issue: if: |- - github.repository == 'google-gemini/gemini-cli' && + (github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') && ( github.event_name == 'workflow_dispatch' || ( (github.event_name == 'issues' || github.event_name == 'issue_comment') && - contains(github.event.issue.labels.*.name, 'status/need-triage') && (github.event_name != 'issue_comment' || ( contains(github.event.comment.body, '@gemini-cli /triage') && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') @@ -58,10 +63,11 @@ jobs: with: github-token: '${{ secrets.GITHUB_TOKEN }}' script: | + const issueNumber = ${{ github.event.inputs.issue_number || inputs.issue_number }}; const { data: issue } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ github.event.inputs.issue_number }}, + issue_number: issueNumber, }); core.setOutput('title', issue.title); core.setOutput('body', issue.body); @@ -72,14 +78,9 @@ jobs: if: |- github.event_name == 'workflow_dispatch' env: - ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number }}' + ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number || inputs.issue_number }}' LABELS: '${{ steps.get_issue_data.outputs.labels }}' run: | - if ! echo "${LABELS}" | grep -q 'status/need-triage'; then - echo "Issue #${ISSUE_NUMBER_INPUT} does not have the 'status/need-triage' label. Stopping workflow." - exit 1 - fi - if echo "${LABELS}" | grep -q 'area/'; then echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow." exit 1 @@ -92,6 +93,10 @@ jobs: - name: 'Generate GitHub App Token' id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' @@ -102,7 +107,7 @@ jobs: id: 'get_labels' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- const { data: labels } = await github.rest.issues.listLabelsForRepo({ owner: context.repo.owner, @@ -133,7 +138,7 @@ jobs: ISSUE_BODY: >- ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }} ISSUE_NUMBER: >- - ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.issue_number || github.event.issue.number }} + ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }} REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' with: @@ -259,7 +264,7 @@ jobs: LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const rawOutput = process.env.LABELS_OUTPUT; core.info(`Raw output from model: ${rawOutput}`); @@ -301,22 +306,6 @@ jobs: }); core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`); - // Remove the 'status/need-triage' label - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: 'status/need-triage' - }); - core.info(`Successfully removed 'status/need-triage' label.`); - } catch (error) { - // If the label doesn't exist, the API call will throw a 404. We can ignore this. - if (error.status !== 404) { - core.warning(`Failed to remove 'status/need-triage': ${error.message}`); - } - } - - name: 'Post Issue Analysis Failure Comment' if: |- ${{ failure() && steps.gemini_issue_analysis.outcome == 'failure' }} @@ -325,7 +314,7 @@ jobs: RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: |- github.rest.issues.createComment({ owner: context.repo.owner, diff --git a/.github/workflows/gemini-automated-pr-size-labeler.yml b/.github/workflows/gemini-automated-pr-size-labeler.yml deleted file mode 100644 index 1438d6429b..0000000000 --- a/.github/workflows/gemini-automated-pr-size-labeler.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: 'Gemini Automated PR Labeler' - -on: - pull_request_target: - types: ['opened', 'reopened', 'synchronize'] - -jobs: - label-pr: - timeout-minutes: 10 - if: |- - ${{ github.repository == 'google-gemini/gemini-cli' }} - permissions: - pull-requests: 'write' - contents: 'read' - id-token: 'write' - - concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.number }}' - cancel-in-progress: true - - runs-on: 'ubuntu-latest' - - steps: - - name: 'Generate GitHub App Token' - id: 'generate_token' - uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e' # v2 - with: - app-id: '${{ secrets.APP_ID }}' - private-key: '${{ secrets.PRIVATE_KEY }}' - permission-pull-requests: 'write' - - - name: 'Run Gemini PR size and complexity labeller' - uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # Use the specific commit SHA - env: - GH_TOKEN: '${{ steps.generate_token.outputs.token }}' - PR_NUMBER: '${{ github.event.pull_request.number }}' - REPOSITORY: '${{ github.repository }}' - with: - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - settings: | - { - "coreTools": [ - "run_shell_command(gh pr diff)", - "run_shell_command(gh pr edit)", - "run_shell_command(gh pr comment)", - "run_shell_command(gh pr view)" - ], - "telemetry": { - "enabled": true, - "target": "gcp" - }, - "sandbox": false - } - prompt: | - You are a Pull Request labeller and Feedback Assistant. Your primary goal is to improve review velocity and help maintainers prioritize their work by automatically labeling pull requests based on size and complexity, and providing guidance for overly large PRs. - - Steps: - 1. Retrieve Pull Request Information: - - Use `gh pr diff ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }}` to get the diff content. - - Parse the output from `gh pr diff` to determine the total lines of code added and deleted. Calculate `TOTAL_LINES_CHANGED`. - - 2. Determine Pull Request Size: - - Use `gh pr view ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }} --json labels` to get the current labels on the PR. - - Check the current labels and identify if any `size/*` labels already exist (e.g., `size/xs`, `size/s`, etc.). - - If an old `size/*` label is found and it is different from the newly calculated size, remove it using: - `gh pr edit ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }} --remove-label "size-label-to-remove"` - - Based on `TOTAL_LINES_CHANGED`, select the appropriate new size label: - - `size/xs`: < 10 lines changed - - `size/s`: 10-50 lines changed - - `size/m`: 51-200 lines changed - - `size/l`: 201-1000 lines changed - - `size/xl`: > 1000 lines changed - - Do not invent new size labels. - - Apply the newly determined `size/*` label to the pull request using: - `gh pr edit ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }} --add-label "your-new-size-label"` - - 3. Analyze Pull Request Complexity: - - Perform Code Change Analysis: Examine the content of the code changes obtained from `gh pr diff ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }}`. Look for indicators of complexity such as: - - Number of files changed (can be inferred from the diff headers). - - Diversity of file types (e.g., changes across different languages, configuration files, documentation). - - Presence of new external dependencies. - - Introduction of new architectural components or significant refactoring. - - Complexity of individual code changes (e.g., deeply nested logic, complex algorithms, extensive conditional statements). - - Apply Heuristic-based Complexity Assessment: - - If the PR touches a small number of files with minor changes (e.g., typos, simple bug fixes, small feature additions), categorize it as `review/quick`. - - If the PR involves changes across multiple files, introduces new features, significantly refactors existing code, or has a high line count (even within `size/l`), categorize it as `review/involved`. - - Pay close attention to changes in critical or core modules as these inherently increase complexity. - - **Only use the labels `review/quick` or `review/involved` for complexity. Do not invent new complexity labels.** - - **Remove any previous `review/*` labels if they no longer apply, similar to the size label process.** - - Apply the determined `review/*` label to the pull request using: - `gh pr edit ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }} --add-label "your-complexity-label"` - - 4. Handle Overly Large Pull Requests (`size/xl`): - - **Conditional Check:** If the pull request has been labeled `size/xl` (i.e., > 1000 lines of code changed) in Step 2, proceed to the next sub-step. - - **Post Constructive Comment:** Post a polite and helpful comment on the pull request using: - `gh pr comment ${{ env.PR_NUMBER }} --repo ${{ env.REPOSITORY }} --body "Your comment here"` - The comment body should be: - """ - Thanks for your hard work on this pull request! - - This pull request is quite large, which can make it challenging and time-consuming for reviewers to go through thoroughly. - - To help us review it more efficiently and get your changes merged faster, we kindly request you consider breaking this into smaller, more focused pull requests. Each smaller PR should ideally address a single logical change or a small set of related changes. - - For example, you could separate out refactoring, new feature additions, and bug fixes into individual PRs. This makes it easier to understand, review, and test each component independently. - - We appreciate your understanding and cooperation. Feel free to reach out if you need any assistance with this! - """ - - Guidelines: - - Automation Focus: All actions should be automated and not require manual intervention. - - Non-intrusive: The system should add labels and comments but not modify the code or close the pull request. - - Polite and Constructive: All communication, especially for large PRs, must be polite, encouraging, and constructive. - - Prioritize Clarity: The labels applied should clearly convey the PR's size and complexity to reviewers. - - Adhere to Defined Labels: Only use the specified `size/*` and `review/*` labels. Do not create or apply any other labels. - - Utilize `gh CLI`: Interact with GitHub using the `gh` command-line tool for diffing, label management, and commenting. - - Execute commands strictly as described in the steps. Do not invent new commands. - - In no case should you change other pull request that are not the one you are working on. Which can be found by using env.PR_NUMBER - - Execute each step that is defined in the steps section. - - In no case should you execute code from the pull request because this could be malicious code. - - If you fail to do this step log the errors you received diff --git a/.github/workflows/gemini-scheduled-issue-dedup.yml b/.github/workflows/gemini-scheduled-issue-dedup.yml index 9eea5e0aa0..46a6f4628b 100644 --- a/.github/workflows/gemini-scheduled-issue-dedup.yml +++ b/.github/workflows/gemini-scheduled-issue-dedup.yml @@ -81,7 +81,6 @@ jobs: "FIRESTORE_DATABASE_ID": "(default)", "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" }, - "enabled": true, "timeout": 600000 } }, diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 3b54546f98..25b0cdf4ec 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -1,12 +1,16 @@ name: '๐Ÿ“‹ Gemini Scheduled Issue Triage' on: + issues: + types: + - 'opened' + - 'reopened' schedule: - cron: '0 * * * *' # Runs every hour workflow_dispatch: concurrency: - group: '${{ github.workflow }}' + group: '${{ github.workflow }}-${{ github.event.number || github.run_id }}' cancel-in-progress: true defaults: @@ -35,7 +39,21 @@ jobs: private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' + - name: 'Get issue from event' + if: |- + ${{ github.event_name == 'issues' }} + id: 'get_issue_from_event' + env: + ISSUE_EVENT: '${{ toJSON(github.event.issue) }}' + run: | + set -euo pipefail + ISSUE_JSON=$(echo "$ISSUE_EVENT" | jq -c '[{number: .number, title: .title, body: .body}]') + echo "issues_to_triage=${ISSUE_JSON}" >> "${GITHUB_OUTPUT}" + echo "โœ… Found issue #${{ github.event.issue.number }} from event to triage! ๐ŸŽฏ" + - name: 'Find untriaged issues' + if: |- + ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} id: 'find_issues' env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' @@ -43,22 +61,26 @@ jobs: run: |- set -euo pipefail - echo '๐Ÿ” Finding issues without labels...' - NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue no:label' --json number,title,body)" + echo '๐Ÿ” Finding issues missing area labels...' + NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)" - echo '๐Ÿท๏ธ Finding issues that need triage...' - NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)" + echo '๐Ÿ” Finding issues missing kind labels...' + NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)" + + echo '๐Ÿท๏ธ Finding issues missing priority labels...' + NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)" echo '๐Ÿ”„ Merging and deduplicating issues...' - ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')" + ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')" echo '๐Ÿ“ Setting output for GitHub Actions...' echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" ISSUE_COUNT="$(echo "${ISSUES}" | jq 'length')" - echo "โœ… Found ${ISSUE_COUNT} issues to triage! ๐ŸŽฏ" + echo "โœ… Found ${ISSUE_COUNT} unique issues to triage! ๐ŸŽฏ" - name: 'Get Repository Labels' id: 'get_labels' @@ -77,12 +99,13 @@ jobs: - name: 'Run Gemini Issue Analysis' if: |- - ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} + (steps.get_issue_from_event.outputs.issues_to_triage != '' && steps.get_issue_from_event.outputs.issues_to_triage != '[]') || + (steps.find_issues.outputs.issues_to_triage != '' && steps.find_issues.outputs.issues_to_triage != '[]') uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_issue_analysis' env: GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs - ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}' + ISSUES_TO_TRIAGE: '${{ steps.get_issue_from_event.outputs.issues_to_triage || steps.find_issues.outputs.issues_to_triage }}' REPOSITORY: '${{ github.repository }}' AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' with: @@ -116,38 +139,32 @@ jobs: 1. You are only able to use the echo command. Review the available labels in the environment variable: "${AVAILABLE_LABELS}". 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) 3. Review the issue title, body and any comments provided in the environment variables. - 4. Identify the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. - 5. If the issue already has area/ label, dont try to change it. Similarly, if the issue already has a kind/ label don't change it. And if the issue already has a priority/ label do not change it for example: - If an issue has area/core and kind/bug you will only add a priority/ label. - Instead if an issue has no labels, you will could add one lable of each kind. + 4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/* and priority/*. + 5. Label Policy: + - If the issue already has a kind/ label, do not change it. + - If the issue already has a priority/ label, do not change it. + - If the issue already has an area/ label, do not change it. + - If any of these are missing, select exactly ONE appropriate label for the missing category. 6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc. - 7. For area/* and kind/* limit yourself to only the single most applicable label in each case. - 8. Give me a single short explanation about why you are selecting each label in the process. - 9. Output a JSON array of objects, each containing the issue number + 7. Give me a single short explanation about why you are selecting each label in the process. + 8. Output a JSON array of objects, each containing the issue number and the labels to add and remove, along with an explanation. For example: ``` [ { "issue_number": 123, - "labels_to_add": ["kind/bug", "priority/p2"], + "labels_to_add": ["area/core", "kind/bug", "priority/p2"], "labels_to_remove": ["status/need-triage"], - "explanation": "This issue is a bug that needs to be addressed with medium priority." - }, - { - "issue_number": 456, - "labels_to_add": ["kind/enhancement"], - "labels_to_remove": [], - "explanation": "This issue is an enhancement request that could improve the user experience." + "explanation": "This issue is a UI bug that needs to be addressed with medium priority." } ] ``` If an issue cannot be classified, do not include it in the output array. - 10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 + 9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 - Anything more than 6 versions older than the most recent should add the status/need-retesting label - 11. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below. - - After identifying appropriate labels to an issue, add "status/need-triage" label to labels_to_remove in the output. - 12. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation. - 13. If you are uncertain and have not been able to apply one each of kind/, area/ and priority/ , apply the status/manual-triage label. + 10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below. + 11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation. + 12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label. ## Guidelines @@ -157,100 +174,46 @@ jobs: - Do not add comments or modify the issue content. - Do not remove the following labels maintainer, help wanted or good first issue. - Triage only the current issue. - - Identify only one area/ label + - Identify only one area/ label. - Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue) - - Identify all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. + - Identify only one priority/ label. - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. - Categorization Guidelines: - P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. - - To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product. - - You would see this in the form of many comments from different developers on the bug. - - It represents a complete showstopper for a significant portion of users or for the development process itself. - Impact: - - Blocks development or testing for the entire team. - - Major security vulnerability that could compromise user data or system integrity. - - Causes data loss or corruption with no workaround. - - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? - - Is it preventing contributors from contributing to the repository or is it a release blocker? - Qualifier: Is the main function of the software broken? - Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. - P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. - - While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. - - Once again this would be affecting many users. - - You would see this in the form of comments from different developers on the bug. - Impact: - - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - - Severe performance degradation making the application frustratingly slow. - - No straightforward workaround exists, or the workaround is difficult and non-obvious. - Qualifier: Is a key feature unusable or giving very wrong results? - Example: Gemini CLI enters a loop when making read-many-files tool call. I am unable to break out of the loop and gemini doesn't follow instructions subsequently. - P2: Medium - - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. - Impact: - - Affects a non-critical feature or a smaller, specific subset of users. - - An inconvenient but functional workaround is available and easy to execute. - - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). - Qualifier: Is it an annoying but non-blocking problem? - Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. - P3: Low - - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. - Impact: - - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. - - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. - Qualifier: Is it a "nice-to-fix" issue? - Example: Spelling mistakes etc. + + Categorization Guidelines (Priority): + P0 - Urgent Blocking Issues: + - DO NOT APPLY THIS LABEL AUTOMATICALLY. Use status/manual-triage instead. + - Definition: Urgent, block a significant percentage of the user base, and prevent frequent use of the Gemini CLI. + - This includes core stability blockers (e.g., authentication failures, broken upgrades), critical crashes, and P0 security vulnerabilities. + - Impact: Blocks development or testing for the entire team; Major security vulnerability; Causes data loss or corruption with no workaround; Crashes the application or makes a core feature completely unusable for all or most users. + - Qualifier: Is the main function of the software broken? + P1 - High-Impact Issues: + - Definition: Affect a large number of users, blocking them from using parts of the Gemini CLI, or make the CLI frequently unusable even with workarounds available. + - Impact: A core feature is broken or behaving incorrectly for a large number of users or use cases; Severe performance degradation; No straightforward workaround exists. + - Qualifier: Is a key feature unusable or giving very wrong results? + P2 - Significant Issues: + - Definition: Affect some users significantly, such as preventing the use of certain features or authentication types. + - Can also be issues that many users complain about, causing annoyance or hindering daily use. + - Impact: Affects a non-critical feature or a smaller, specific subset of users; An inconvenient but functional workaround is available; Noticeable UI/UX problems that look unprofessional. + - Qualifier: Is it an annoying but non-blocking problem? + P3 - Low-Impact Issues: + - Definition: Typically usability issues that cause annoyance to a limited user base. + - Includes feature requests that could be addressed in the near future and may be suitable for community contributions. + - Impact: Minor cosmetic issues; An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. + - Qualifier: Is it a "nice-to-fix" issue? + + Categorization Guidelines (Area): + area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality + area/core: User Interface, OS Support, Core Functionality + area/enterprise: Telemetry, Policy, Quota / Licensing + area/extensions: Gemini CLI extensions capability + area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation + area/platform: Build infra, Release mgmt, Testing, Eval infra, Capacity, Quota mgmt + area/security: security related issues + Additional Context: - - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue - - This product is designed to use different models eg.. using pro, downgrading to flash etc. - - When users report that they dont expect the model to change those would be categorized as feature requests. - Definition of Areas - area/ux: - - Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance. - - I am seeing my screen flicker when using Gemini CLI - - I am seeing the output malformed - - Theme changes aren't taking effect - - My keyboard inputs arent' being recognzied - area/platform: - - Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework. - area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features. - area/models: - - i am not getting a response that is reasonable or expected. this can include things like - - I am calling a tool and the tool is not performing as expected. - - i am expecting a tool to be called and it is not getting called , - - Including experience when using - - built-in tools (e.g., web search, code interpreter, read file, writefile, etc..), - - Function calling issues should be under this area - - i am getting responses from the model that are malformed. - - Issues concerning Gemini quality of response and inference, - - Issues talking about unnecessary token consumption. - - Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues. - - Memory compression - - unexpected responses, - - poor quality of generated code - area/tools: - - These are primarily issues related to Model Context Protocol - - These are issues that mention MCP support - - feature requests asking for support for new tools. - area/core: - - Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality - area/contribution: - - Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure. - area/authentication: - - Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc.. - area/security-privacy: - - Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access. - area/extensibility: - - Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc.. - area/performance: - - Issues focused on model performance - - Issues with running out of capacity, - - 429 errors etc.. - - could also pertain to latency, - - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. - - Switching models from one to the other unexpectedly. + - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue. + - This product is designed to use different models eg.. using pro, downgrading to flash etc. + - When users report that they dont expect the model to change those would be categorized as feature requests. - name: 'Apply Labels to Issues' if: |- @@ -300,24 +263,6 @@ jobs: core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`); } - if (entry.labels_to_remove && entry.labels_to_remove.length > 0) { - for (const label of entry.labels_to_remove) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label - }); - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - } - core.info(`Successfully removed labels for #${issueNumber}: ${entry.labels_to_remove.join(', ')}`); - } - if (entry.explanation) { await github.rest.issues.createComment({ owner: context.repo.owner, diff --git a/.github/workflows/gemini-scheduled-pr-triage.yml b/.github/workflows/gemini-scheduled-pr-triage.yml index 007b8daa3f..50cd5a1bad 100644 --- a/.github/workflows/gemini-scheduled-pr-triage.yml +++ b/.github/workflows/gemini-scheduled-pr-triage.yml @@ -39,3 +39,7 @@ jobs: GITHUB_REPOSITORY: '${{ github.repository }}' run: |- ./.github/scripts/pr-triage.sh + # If prs_needing_comment is empty, set it to [] explicitly for downstream steps + if [[ -z "$(grep 'prs_needing_comment' "${GITHUB_OUTPUT}" | cut -d'=' -f2-)" ]]; then + echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" + fi diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index fb86d8e70e..fe9032983b 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -79,8 +79,14 @@ jobs: continue; } - // Skip if it has a maintainer label - if (issue.labels.some(label => label.name.toLowerCase().includes('maintainer'))) { + // Skip if it has a maintainer, help wanted, or Public Roadmap label + const rawLabels = issue.labels.map((l) => l.name); + const lowercaseLabels = rawLabels.map((l) => l.toLowerCase()); + if ( + lowercaseLabels.some((l) => l.includes('maintainer')) || + lowercaseLabels.includes('help wanted') || + rawLabels.includes('๐Ÿ—“๏ธ Public Roadmap') + ) { continue; } diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml new file mode 100644 index 0000000000..04b6e37246 --- /dev/null +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -0,0 +1,205 @@ +name: 'Gemini Scheduled Stale PR Closer' + +on: + schedule: + - cron: '0 2 * * *' # Every day at 2 AM UTC + pull_request: + types: ['opened', 'edited'] + workflow_dispatch: + inputs: + dry_run: + description: 'Run in dry-run mode' + required: false + default: false + type: 'boolean' + +jobs: + close-stale-prs: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + pull-requests: 'write' + issues: 'write' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@v1' + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + owner: '${{ github.repository_owner }}' + repositories: 'gemini-cli' + + - name: 'Process Stale PRs' + uses: 'actions/github-script@v7' + env: + DRY_RUN: '${{ inputs.dry_run }}' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const dryRun = process.env.DRY_RUN === 'true'; + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // 1. Fetch maintainers for verification + let maintainerLogins = new Set(); + 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)); + } catch (e) { + core.warning('Failed to fetch team members'); + } + + const isMaintainer = (login, assoc) => { + if (maintainerLogins.size > 0) return maintainerLogins.has(login); + return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); + }; + + // 2. Determine which PRs to check + let prs = []; + if (context.eventName === 'pull_request') { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + prs = [pr]; + } else { + prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + } + + for (const pr of prs) { + const maintainerPr = isMaintainer(pr.user.login, pr.author_association); + const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]'); + + // Detection Logic for Linked Issues + // Check 1: Official GitHub "Closing Issue" link (GraphQL) + const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + closingIssuesReferences(first: 1) { totalCount } + } + } + }`; + + let hasClosingLink = false; + try { + const res = await github.graphql(linkedIssueQuery, { + owner: context.repo.owner, repo: context.repo.repo, number: pr.number + }); + hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0; + } catch (e) {} + + // Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123") + // We check for # followed by numbers or direct URLs to issues. + const body = pr.body || ''; + const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; + const hasMentionLink = mentionRegex.test(body); + + const hasLinkedIssue = hasClosingLink || hasMentionLink; + + // Logic for Closed PRs (Auto-Reopen) + if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') { + if (hasLinkedIssue) { + core.info(`PR #${pr.number} now has a linked issue. Reopening.`); + if (!dryRun) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'open' + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: "Thank you for linking an issue! This pull request has been automatically reopened." + }); + } + } + continue; + } + + // Logic for Open PRs (Immediate Closure) + if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) { + core.info(`PR #${pr.number} is missing a linked issue. Closing.`); + if (!dryRun) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!" + }); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + } + continue; + } + + // Staleness check (Scheduled runs only) + if (pr.state === 'open' && context.eventName !== 'pull_request') { + const labels = pr.labels.map(l => l.name.toLowerCase()); + if (labels.includes('help wanted') || labels.includes('๐Ÿ”’ maintainer only')) continue; + + let lastActivity = new Date(0); + try { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + for (const r of reviews) { + if (isMaintainer(r.user.login, r.author_association)) { + const d = new Date(r.submitted_at || r.updated_at); + if (d > lastActivity) lastActivity = d; + } + } + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + for (const c of comments) { + if (isMaintainer(c.user.login, c.author_association)) { + const d = new Date(c.updated_at); + if (d > lastActivity) lastActivity = d; + } + } + } catch (e) {} + + if (maintainerPr) { + const d = new Date(pr.created_at); + if (d > lastActivity) lastActivity = d; + } + + if (lastActivity < thirtyDaysAgo) { + core.info(`PR #${pr.number} is stale.`); + if (!dryRun) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **๐Ÿ”’ maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!" + }); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + } + } + } + } diff --git a/.github/workflows/issue-opened-labeler.yml b/.github/workflows/issue-opened-labeler.yml new file mode 100644 index 0000000000..69a0911954 --- /dev/null +++ b/.github/workflows/issue-opened-labeler.yml @@ -0,0 +1,46 @@ +name: '๐Ÿท๏ธ Issue Opened Labeler' + +on: + issues: + types: + - 'opened' + +jobs: + label-issue: + runs-on: 'ubuntu-latest' + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' }} + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Add need-triage label' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const hasLabel = issue.labels.some(l => l.name === 'status/need-triage'); + if (!hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['status/need-triage'] + }); + } else { + core.info('Issue already has status/need-triage label. Skipping.'); + } diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml new file mode 100644 index 0000000000..b11f509f80 --- /dev/null +++ b/.github/workflows/label-backlog-child-issues.yml @@ -0,0 +1,57 @@ +name: 'Label Child Issues for Project Rollup' + +on: + issues: + types: ['opened', 'edited', 'reopened'] + schedule: + - cron: '0 * * * *' # Run every hour + workflow_dispatch: + +permissions: + issues: 'write' + contents: 'read' + +jobs: + # Event-based: Quick reaction to new/edited issues in THIS repo + labeler: + if: "github.event_name == 'issues'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + + - name: 'Setup Node.js' + uses: 'actions/setup-node@v4' + with: + node-version: '20' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Run Multi-Repo Sync Script' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node .github/scripts/sync-maintainer-labels.cjs' + + # Scheduled/Manual: Recursive sync across multiple repos + sync-maintainer-labels: + if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + + - name: 'Setup Node.js' + uses: 'actions/setup-node@v4' + with: + node-version: '20' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Run Multi-Repo Sync Script' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node .github/scripts/sync-maintainer-labels.cjs' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml new file mode 100644 index 0000000000..35840cfe6f --- /dev/null +++ b/.github/workflows/label-workstream-rollup.yml @@ -0,0 +1,174 @@ +name: 'Label Workstream Rollup' + +on: + issues: + types: ['opened', 'edited', 'reopened'] + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +jobs: + labeler: + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + steps: + - name: 'Check for Parent Workstream and Apply Label' + uses: 'actions/github-script@v7' + with: + script: | + const labelToAdd = 'workstream-rollup'; + + // Allow-list of parent issue URLs + const allowedParentUrls = [ + 'https://github.com/google-gemini/gemini-cli/issues/15374', + 'https://github.com/google-gemini/gemini-cli/issues/15456', + 'https://github.com/google-gemini/gemini-cli/issues/15324', + 'https://github.com/google-gemini/gemini-cli/issues/17202', + 'https://github.com/google-gemini/gemini-cli/issues/17203' + ]; + + // Single issue processing (for event triggers) + async function processSingleIssue(owner, repo, number) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + number + parent { + url + parent { + url + parent { + url + parent { + url + parent { + url + } + } + } + } + } + } + } + } + `; + try { + const result = await github.graphql(query, { owner, repo, number }); + + if (!result || !result.repository || !result.repository.issue) { + console.log(`Issue #${number} not found or data missing.`); + return; + } + + const issue = result.repository.issue; + await checkAndLabel(issue, owner, repo); + } catch (error) { + console.error(`Failed to process issue #${number}:`, error); + throw error; // Re-throw to be caught by main execution + } + } + + // Bulk processing (for schedule/dispatch) + async function processAllOpenIssues(owner, repo) { + const query = ` + query($owner:String!, $repo:String!, $cursor:String) { + repository(owner:$owner, name:$repo) { + issues(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + parent { + url + parent { + url + parent { + url + parent { + url + parent { + url + } + } + } + } + } + } + } + } + } + `; + + let hasNextPage = true; + let cursor = null; + + while (hasNextPage) { + try { + const result = await github.graphql(query, { owner, repo, cursor }); + + if (!result || !result.repository || !result.repository.issues) { + console.error('Invalid response structure from GitHub API'); + break; + } + + const issues = result.repository.issues.nodes || []; + + console.log(`Processing batch of ${issues.length} issues...`); + for (const issue of issues) { + await checkAndLabel(issue, owner, repo); + } + + hasNextPage = result.repository.issues.pageInfo.hasNextPage; + cursor = result.repository.issues.pageInfo.endCursor; + } catch (error) { + console.error('Failed to fetch issues batch:', error); + throw error; // Re-throw to be caught by main execution + } + } + } + + async function checkAndLabel(issue, owner, repo) { + if (!issue || !issue.parent) return; + + let currentParent = issue.parent; + let tracedParents = []; + let matched = false; + + while (currentParent) { + tracedParents.push(currentParent.url); + + if (allowedParentUrls.includes(currentParent.url)) { + console.log(`SUCCESS: Issue #${issue.number} is a descendant of ${currentParent.url}. Trace: ${tracedParents.join(' -> ')}. Adding label.`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issue.number, + labels: [labelToAdd] + }); + matched = true; + break; + } + currentParent = currentParent.parent; + } + + if (!matched && context.eventName === 'issues') { + console.log(`Issue #${issue.number} did not match any allowed workstreams. Trace: ${tracedParents.join(' -> ') || 'None'}.`); + } + } + + // Main execution + try { + if (context.eventName === 'issues') { + console.log(`Processing single issue #${context.payload.issue.number}...`); + await processSingleIssue(context.repo.owner, context.repo.repo, context.payload.issue.number); + } else { + console.log(`Running for event: ${context.eventName}. Processing all open issues...`); + await processAllOpenIssues(context.repo.owner, context.repo.repo); + } + } catch (error) { + core.setFailed(`Workflow failed: ${error.message}`); + } diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index b4aa73aac4..1ed45019f9 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -12,6 +12,8 @@ on: jobs: linkChecker: + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' }} runs-on: 'ubuntu-latest' steps: - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 diff --git a/.github/workflows/pr-contribution-guidelines-notifier.yml b/.github/workflows/pr-contribution-guidelines-notifier.yml new file mode 100644 index 0000000000..fdabd20f3d --- /dev/null +++ b/.github/workflows/pr-contribution-guidelines-notifier.yml @@ -0,0 +1,83 @@ +name: '๐Ÿท๏ธ PR Contribution Guidelines Notifier' + +on: + pull_request: + types: + - 'opened' + +jobs: + notify-process-change: + runs-on: 'ubuntu-latest' + if: |- + github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' + permissions: + pull-requests: 'write' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Check membership and post comment' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const org = context.repo.owner; + const repo = context.repo.repo; + const username = context.payload.pull_request.user.login; + const pr_number = context.payload.pull_request.number; + + // 1. Check if the PR author is a maintainer + const authorAssociation = context.payload.pull_request.author_association; + if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation)) { + core.info(`${username} is a maintainer (Association: ${authorAssociation}). No notification needed.`); + return; + } + + // 2. Check if the PR is already associated with an issue + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + closingIssuesReferences(first: 1) { + totalCount + } + } + } + } + `; + const variables = { owner: org, repo: repo, number: pr_number }; + const result = await github.graphql(query, variables); + const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount; + + if (issueCount > 0) { + core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`); + return; + } + + // 3. Post the notification comment + core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`); + + const comment = ` + Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this. + + We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706). + + Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed. + + Thank you for your understanding and for being a part of our community! + `.trim().replace(/^[ ]+/gm, ''); + + await github.rest.issues.createComment({ + owner: org, + repo: repo, + issue_number: pr_number, + body: comment + }); diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 987cb42389..5fe7bca115 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -31,6 +31,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index fd79d914dc..4a975869f5 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -40,5 +40,5 @@ jobs: If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing! days-before-stale: 60 days-before-close: 14 - exempt-issue-labels: 'pinned,security' - exempt-pr-labels: 'pinned,security' + exempt-issue-labels: 'pinned,security,๐Ÿ”’ maintainer only,help wanted,๐Ÿ—“๏ธ Public Roadmap' + exempt-pr-labels: 'pinned,security,๐Ÿ”’ maintainer only,help wanted,๐Ÿ—“๏ธ Public Roadmap' diff --git a/.gitignore b/.gitignore index 1222895148..afacf2a947 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ .gemini/* !.gemini/config.yaml !.gemini/commands/ +!.gemini/skills/ +!.gemini/settings.json # Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images @@ -53,6 +55,9 @@ gha-creds-*.json # Log files patch_output.log +gemini-debug.log .genkit .gemini-clipboard/ +.eslintcache +evals/logs/ diff --git a/.prettierignore b/.prettierignore index 7b8a75a110..e8f035ad74 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,5 +17,7 @@ eslint.config.js **/generated gha-creds-*.json junit.xml +.gemini-linters/ Thumbs.db .pytest_cache +**/SKILL.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d96c25b5b7..d1848f143c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,13 @@ This project follows The process for contributing code is as follows: 1. **Find an issue** that you want to work on. If an issue is tagged as - "๐Ÿ”’Maintainers only", this means it is reserved for project maintainers. We - will not accept pull requests related to these issues. + `๐Ÿ”’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 + 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. 2. **Fork the repository** and create a new branch. 3. **Make your changes** in the `packages/` directory. 4. **Ensure all checks pass** by running `npm run preflight`. @@ -94,8 +99,11 @@ any code is written. - **For features:** The PR should be linked to the feature request or proposal issue that has been approved by a maintainer. -If an issue for your change doesn't exist, please **open one first** and wait -for feedback before you start coding. +If an issue for your change doesn't exist, we will automatically close your PR +along with a comment reminding you to associate the PR with an issue. The ideal +workflow starts with an issue that has been reviewed and approved by a +maintainer. Please **open the issue first** and wait for feedback before you +start coding. #### 2. Keep it small and focused diff --git a/GEMINI.md b/GEMINI.md index 3da20efb75..000e71e3a3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,409 +1,79 @@ -## Building and running - -Before submitting any changes, it is crucial to validate them by running the -full preflight check. This command will build the repository, run all tests, -check for type errors, and lint the code. - -To run the full suite of checks, execute the following command: - -```bash -npm run preflight -``` - -This single command ensures that your changes meet all the quality gates of the -project. While you can run the individual steps (`build`, `test`, `typecheck`, -`lint`) separately, it is highly recommended to use `npm run preflight` to -ensure a comprehensive validation. - -## Writing Tests - -This project uses **Vitest** as its primary testing framework. When writing -tests, aim to follow existing patterns. Key conventions include: - -### Test Structure and Framework - -- **Framework**: All tests are written using Vitest (`describe`, `it`, `expect`, - `vi`). -- **File Location**: Test files (`*.test.ts` for logic, `*.test.tsx` for React - components) are co-located with the source files they test. -- **Configuration**: Test environments are defined in `vitest.config.ts` files. -- **Setup/Teardown**: Use `beforeEach` and `afterEach`. Commonly, - `vi.resetAllMocks()` is called in `beforeEach` and `vi.restoreAllMocks()` in - `afterEach`. - -### Mocking (`vi` from Vitest) - -- **ES Modules**: Mock with - `vi.mock('module-name', async (importOriginal) => { ... })`. Use - `importOriginal` for selective mocking. - - _Example_: - `vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: vi.fn() }; });` -- **Mocking Order**: For critical dependencies (e.g., `os`, `fs`) that affect - module-level constants, place `vi.mock` at the _very top_ of the test file, - before other imports. -- **Hoisting**: Use `const myMock = vi.hoisted(() => vi.fn());` if a mock - function needs to be defined before its use in a `vi.mock` factory. -- **Mock Functions**: Create with `vi.fn()`. Define behavior with - `mockImplementation()`, `mockResolvedValue()`, or `mockRejectedValue()`. -- **Spying**: Use `vi.spyOn(object, 'methodName')`. Restore spies with - `mockRestore()` in `afterEach`. - -### Commonly Mocked Modules - -- **Node.js built-ins**: `fs`, `fs/promises`, `os` (especially `os.homedir()`), - `path`, `child_process` (`execSync`, `spawn`). -- **External SDKs**: `@google/genai`, `@modelcontextprotocol/sdk`. -- **Internal Project Modules**: Dependencies from other project packages are - often mocked. - -### React Component Testing (CLI UI - Ink) - -- Use `render()` from `ink-testing-library`. -- Assert output with `lastFrame()`. -- Wrap components in necessary `Context.Provider`s. -- Mock custom React hooks and complex child components using `vi.mock()`. - -### Asynchronous Testing - -- Use `async/await`. -- For timers, use `vi.useFakeTimers()`, `vi.advanceTimersByTimeAsync()`, - `vi.runAllTimersAsync()`. -- Test promise rejections with `await expect(promise).rejects.toThrow(...)`. - -### General Guidance - -- When adding tests, first examine existing tests to understand and conform to - established conventions. -- Pay close attention to the mocks at the top of existing test files; they - reveal critical dependencies and how they are managed in a test environment. - -## Git Repo - -The main branch for this project is called "main" - -## JavaScript/TypeScript - -When contributing to this React, Node, and TypeScript codebase, please -prioritize the use of plain JavaScript objects with accompanying TypeScript -interface or type declarations over JavaScript class syntax. This approach -offers significant advantages, especially concerning interoperability with React -and overall code maintainability. - -### Preferring Plain Objects over Classes - -JavaScript classes, by their nature, are designed to encapsulate internal state -and behavior. While this can be useful in some object-oriented paradigms, it -often introduces unnecessary complexity and friction when working with React's -component-based architecture. Here's why plain objects are preferred: - -- Seamless React Integration: React components thrive on explicit props and - state management. Classes' tendency to store internal state directly within - instances can make prop and state propagation harder to reason about and - maintain. Plain objects, on the other hand, are inherently immutable (when - used thoughtfully) and can be easily passed as props, simplifying data flow - and reducing unexpected side effects. - -- Reduced Boilerplate and Increased Conciseness: Classes often promote the use - of constructors, this binding, getters, setters, and other boilerplate that - can unnecessarily bloat code. TypeScript interface and type declarations - provide powerful static type checking without the runtime overhead or - verbosity of class definitions. This allows for more succinct and readable - code, aligning with JavaScript's strengths in functional programming. - -- Enhanced Readability and Predictability: Plain objects, especially when their - structure is clearly defined by TypeScript interfaces, are often easier to - read and understand. Their properties are directly accessible, and there's no - hidden internal state or complex inheritance chains to navigate. This - predictability leads to fewer bugs and a more maintainable codebase. - -- Simplified Immutability: While not strictly enforced, plain objects encourage - an immutable approach to data. When you need to modify an object, you - typically create a new one with the desired changes, rather than mutating the - original. This pattern aligns perfectly with React's reconciliation process - and helps prevent subtle bugs related to shared mutable state. - -- Better Serialization and Deserialization: Plain JavaScript objects are - naturally easy to serialize to JSON and deserialize back, which is a common - requirement in web development (e.g., for API communication or local storage). - Classes, with their methods and prototypes, can complicate this process. - -### Embracing ES Module Syntax for Encapsulation - -Rather than relying on Java-esque private or public class members, which can be -verbose and sometimes limit flexibility, we strongly prefer leveraging ES module -syntax (`import`/`export`) for encapsulating private and public APIs. - -- Clearer Public API Definition: With ES modules, anything that is exported is - part of the public API of that module, while anything not exported is - inherently private to that module. This provides a very clear and explicit way - to define what parts of your code are meant to be consumed by other modules. - -- Enhanced Testability (Without Exposing Internals): By default, unexported - functions or variables are not accessible from outside the module. This - encourages you to test the public API of your modules, rather than their - internal implementation details. If you find yourself needing to spy on or - stub an unexported function for testing purposes, it's often a "code smell" - indicating that the function might be a good candidate for extraction into its - own separate, testable module with a well-defined public API. This promotes a - more robust and maintainable testing strategy. - -- Reduced Coupling: Explicitly defined module boundaries through import/export - help reduce coupling between different parts of your codebase. This makes it - easier to refactor, debug, and understand individual components in isolation. - -### Avoiding `any` Types and Type Assertions; Preferring `unknown` - -TypeScript's power lies in its ability to provide static type checking, catching -potential errors before your code runs. To fully leverage this, it's crucial to -avoid the `any` type and be judicious with type assertions. - -- **The Dangers of `any`**: Using any effectively opts out of TypeScript's type - checking for that particular variable or expression. While it might seem - convenient in the short term, it introduces significant risks: - - **Loss of Type Safety**: You lose all the benefits of type checking, making - it easy to introduce runtime errors that TypeScript would otherwise have - caught. - - **Reduced Readability and Maintainability**: Code with `any` types is harder - to understand and maintain, as the expected type of data is no longer - explicitly defined. - - **Masking Underlying Issues**: Often, the need for any indicates a deeper - problem in the design of your code or the way you're interacting with - external libraries. It's a sign that you might need to refine your types or - refactor your code. - -- **Preferring `unknown` over `any`**: When you absolutely cannot determine the - type of a value at compile time, and you're tempted to reach for any, consider - using unknown instead. unknown is a type-safe counterpart to any. While a - variable of type unknown can hold any value, you must perform type narrowing - (e.g., using typeof or instanceof checks, or a type assertion) before you can - perform any operations on it. This forces you to handle the unknown type - explicitly, preventing accidental runtime errors. - - ```ts - function processValue(value: unknown) { - if (typeof value === 'string') { - // value is now safely a string - console.log(value.toUpperCase()); - } else if (typeof value === 'number') { - // value is now safely a number - console.log(value * 2); - } - // Without narrowing, you cannot access properties or methods on 'value' - // console.log(value.someProperty); // Error: Object is of type 'unknown'. - } - ``` - -- **Type Assertions (`as Type`) - Use with Caution**: Type assertions tell the - TypeScript compiler, "Trust me, I know what I'm doing; this is definitely of - this type." While there are legitimate use cases (e.g., when dealing with - external libraries that don't have perfect type definitions, or when you have - more information than the compiler), they should be used sparingly and with - extreme caution. - - **Bypassing Type Checking**: Like `any`, type assertions bypass TypeScript's - safety checks. If your assertion is incorrect, you introduce a runtime error - that TypeScript would not have warned you about. - - **Code Smell in Testing**: A common scenario where `any` or type assertions - might be tempting is when trying to test "private" implementation details - (e.g., spying on or stubbing an unexported function within a module). This - is a strong indication of a "code smell" in your testing strategy and - potentially your code structure. Instead of trying to force access to - private internals, consider whether those internal details should be - refactored into a separate module with a well-defined public API. This makes - them inherently testable without compromising encapsulation. - -### Type narrowing `switch` clauses - -Use the `checkExhaustive` helper in the default clause of a switch statement. -This will ensure that all of the possible options within the value or -enumeration are used. - -This helper method can be found in `packages/cli/src/utils/checks.ts` - -### Embracing JavaScript's Array Operators - -To further enhance code cleanliness and promote safe functional programming -practices, leverage JavaScript's rich set of array operators as much as -possible. Methods like `.map()`, `.filter()`, `.reduce()`, `.slice()`, -`.sort()`, and others are incredibly powerful for transforming and manipulating -data collections in an immutable and declarative way. - -Using these operators: - -- Promotes Immutability: Most array operators return new arrays, leaving the - original array untouched. This functional approach helps prevent unintended - side effects and makes your code more predictable. -- Improves Readability: Chaining array operators often lead to more concise and - expressive code than traditional for loops or imperative logic. The intent of - the operation is clear at a glance. -- Facilitates Functional Programming: These operators are cornerstones of - functional programming, encouraging the creation of pure functions that take - inputs and produce outputs without causing side effects. This paradigm is - highly beneficial for writing robust and testable code that pairs well with - React. - -By consistently applying these principles, we can maintain a codebase that is -not only efficient and performant but also a joy to work with, both now and in -the future. - -## React (mirrored and adjusted from [react-mcp-server](https://github.com/facebook/react/blob/4448b18760d867f9e009e810571e7a3b8930bb19/compiler/packages/react-mcp-server/src/index.ts#L376C1-L441C94)) - -### Role - -You are a React assistant that helps users write more efficient and optimizable -React code. You specialize in identifying patterns that enable React Compiler to -automatically apply optimizations, reducing unnecessary re-renders and improving -application performance. - -### Follow these guidelines in all code you produce and suggest - -Use functional components with Hooks: Do not generate class components or use -old lifecycle methods. Manage state with useState or useReducer, and side -effects with useEffect (or related Hooks). Always prefer functions and Hooks for -any new component logic. - -Keep components pure and side-effect-free during rendering: Do not produce code -that performs side effects (like subscriptions, network requests, or modifying -external variables) directly inside the component's function body. Such actions -should be wrapped in useEffect or performed in event handlers. Ensure your -render logic is a pure function of props and state. - -Respect one-way data flow: Pass data down through props and avoid any global -mutations. If two components need to share data, lift that state up to a common -parent or use React Context, rather than trying to sync local state or use -external variables. - -Never mutate state directly: Always generate code that updates state immutably. -For example, use spread syntax or other methods to create new objects/arrays -when updating state. Do not use assignments like state.someValue = ... or array -mutations like array.push() on state variables. Use the state setter (setState -from useState, etc.) to update state. - -Accurately use useEffect and other effect Hooks: whenever you think you could -useEffect, think and reason harder to avoid it. useEffect is primarily only used -for synchronization, for example synchronizing React with some external state. -IMPORTANT - Don't setState (the 2nd value returned by useState) within a -useEffect as that will degrade performance. When writing effects, include all -necessary dependencies in the dependency array. Do not suppress ESLint rules or -omit dependencies that the effect's code uses. Structure the effect callbacks to -handle changing values properly (e.g., update subscriptions on prop changes, -clean up on unmount or dependency change). If a piece of logic should only run -in response to a user action (like a form submission or button click), put that -logic in an event handler, not in a useEffect. Where possible, useEffects should -return a cleanup function. - -Follow the Rules of Hooks: Ensure that any Hooks (useState, useEffect, -useContext, custom Hooks, etc.) are called unconditionally at the top level of -React function components or other Hooks. Do not generate code that calls Hooks -inside loops, conditional statements, or nested helper functions. Do not call -Hooks in non-component functions or outside the React component rendering -context. - -Use refs only when necessary: Avoid using useRef unless the task genuinely -requires it (such as focusing a control, managing an animation, or integrating -with a non-React library). Do not use refs to store application state that -should be reactive. If you do use refs, never write to or read from ref.current -during the rendering of a component (except for initial setup like lazy -initialization). Any ref usage should not affect the rendered output directly. - -Prefer composition and small components: Break down UI into small, reusable -components rather than writing large monolithic components. The code you -generate should promote clarity and reusability by composing components -together. Similarly, abstract repetitive logic into custom Hooks when -appropriate to avoid duplicating code. - -Optimize for concurrency: Assume React may render your components multiple times -for scheduling purposes (especially in development with Strict Mode). Write code -that remains correct even if the component function runs more than once. For -instance, avoid side effects in the component body and use functional state -updates (e.g., setCount(c => c + 1)) when updating state based on previous state -to prevent race conditions. Always include cleanup functions in effects that -subscribe to external resources. Don't write useEffects for "do this when this -changes" side effects. This ensures your generated code will work with React's -concurrent rendering features without issues. - -Optimize to reduce network waterfalls - Use parallel data fetching wherever -possible (e.g., start multiple requests at once rather than one after another). -Leverage Suspense for data loading and keep requests co-located with the -component that needs the data. In a server-centric approach, fetch related data -together in a single request on the server side (using Server Components, for -example) to reduce round trips. Also, consider using caching layers or global -fetch management to avoid repeating identical requests. - -Rely on React Compiler - useMemo, useCallback, and React.memo can be omitted if -React Compiler is enabled. Avoid premature optimization with manual memoization. -Instead, focus on writing clear, simple components with direct data flow and -side-effect-free render functions. Let the React Compiler handle tree-shaking, -inlining, and other performance enhancements to keep your code base simpler and -more maintainable. - -Design for a good user experience - Provide clear, minimal, and non-blocking UI -states. When data is loading, show lightweight placeholders (e.g., skeleton -screens) rather than intrusive spinners everywhere. Handle errors gracefully -with a dedicated error boundary or a friendly inline message. Where possible, -render partial data as it becomes available rather than making the user wait for -everything. Suspense allows you to declare the loading states in your component -tree in a natural way, preventing โ€œflashโ€ states and improving perceived -performance. - -### Process - -1. Analyze the user's code for optimization opportunities: - - Check for React anti-patterns that prevent compiler optimization - - Look for component structure issues that limit compiler effectiveness - - Think about each suggestion you are making and consult React docs for best - practices - -2. Provide actionable guidance: - - Explain specific code changes with clear reasoning - - Show before/after examples when suggesting changes - - Only suggest changes that meaningfully improve optimization potential - -### Optimization Guidelines - -- State updates should be structured to enable granular updates -- Side effects should be isolated and dependencies clearly defined - -## Documentation guidelines - -When working in the `/docs` directory, follow the guidelines in this section: - -- **Role:** You are an expert technical writer and AI assistant for contributors - to Gemini CLI. Produce professional, accurate, and consistent documentation to - guide users of Gemini CLI. -- **Technical Accuracy:** Do not invent facts, commands, code, API names, or - output. All technical information specific to Gemini CLI must be based on code - found within this directory and its subdirectories. -- **Style Authority:** Your source for writing guidance and style is the - "Documentation contribution process" section in the root directory's - `CONTRIBUTING.md` file, as well as any guidelines provided this section. -- **Information Architecture Consideration:** Before proposing documentation - changes, consider the information architecture. If a change adds significant - new content to existing documents, evaluate if creating a new, more focused - page or changes to `sidebar.json` would provide a better user experience. -- **Proactive User Consideration:** The user experience should be a primary - concern when making changes to documentation. Aim to fill gaps in existing - knowledge whenever possible while keeping documentation concise and easy for - users to understand. If changes might hinder user understanding or - accessibility, proactively raise these concerns and propose alternatives. - -## Comments policy - -Only write high-value comments if at all. Avoid talking to the user through -comments. - -## Logging and Error Handling - -- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or - similar methods directly. -- **Non-User-Facing Logs:** For developer-facing debug messages, use - `debugLogger` (from `@google/gemini-cli-core`). -- **User-Facing Feedback:** To surface errors or warnings to the user, use - `coreEvents.emitFeedback` (from `@google/gemini-cli-core`). - -## General requirements - -- If there is something you do not understand or is ambiguous, seek confirmation - or clarification from the user before making changes based on assumptions. -- Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of - `my_flag`). -- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`. +# Gemini CLI Project Context + +Gemini CLI is an open-source AI agent that brings the power of Gemini directly +into the terminal. It is designed to be a terminal-first, extensible, and +powerful tool for developers. + +## Project Overview + +- **Purpose:** Provide a seamless terminal interface for Gemini models, + supporting code understanding, generation, automation, and integration via MCP + (Model Context Protocol). +- **Main Technologies:** + - **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development) + - **Language:** TypeScript + - **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink) + for CLI rendering) + - **Testing:** Vitest + - **Bundling:** esbuild + - **Linting/Formatting:** ESLint, Prettier +- **Architecture:** Monorepo structure using npm workspaces. + - `packages/cli`: User-facing terminal UI, input processing, and display + rendering. + - `packages/core`: Backend logic, Gemini API orchestration, prompt + construction, and tool execution. + - `packages/core/src/tools/`: Built-in tools for file system, shell, and web + operations. + - `packages/a2a-server`: Experimental Agent-to-Agent server. + - `packages/vscode-ide-companion`: VS Code extension pairing with the CLI. + +## Building and Running + +- **Install Dependencies:** `npm install` +- **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code + companion) +- **Build Packages:** `npm run build` +- **Run in Development:** `npm run start` +- **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector) +- **Bundle Project:** `npm run bundle` +- **Clean Artifacts:** `npm run clean` + +## Testing and Quality + +- **Test Commands:** + - **Unit (All):** `npm run test` + - **Integration (E2E):** `npm run test:e2e` + - **Workspace-Specific:** `npm test -w -- ` (Note: `` must + be relative to the workspace root, e.g., + `-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`) +- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install, + build, lint, type check, and tests. Recommended before submitting PRs.) +- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck` + +## Development Conventions + +- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires + signing the Google CLA. +- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue. +- **Commit Messages:** Follow the + [Conventional Commits](https://www.conventionalcommits.org/) standard. +- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink) + and `packages/core` (Backend logic). +- **Imports:** Use specific imports and avoid restricted relative imports + between packages (enforced by ESLint). + +## Testing Conventions + +- **Environment Variables:** When testing code that depends on environment + variables, use `vi.stubEnv('NAME', 'value')` in `beforeEach` and + `vi.unstubAllEnvs()` in `afterEach`. Avoid modifying `process.env` directly as + it can lead to test leakage and is less reliable. To "unset" a variable, use + an empty string `vi.stubEnv('NAME', '')`. + +## Documentation + +- Always use the `docs-writer` skill when you are asked to write, edit, or + review any documentation. +- Documentation is located in the `docs/` directory. +- Suggest documentation updates when code changes render existing documentation + obsolete or incomplete. diff --git a/README.md b/README.md index 3d3fe95f7e..22e258e289 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Gemini CLI E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml) [![Version](https://img.shields.io/npm/v/@google/gemini-cli)](https://www.npmjs.com/package/@google/gemini-cli) [![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE) -[![View Code Wiki](https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/YUi5dj2UWvE.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli) +[![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli) ![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png) @@ -18,7 +18,8 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). - **๐ŸŽฏ Free tier**: 60 requests/min and 1,000 requests/day with personal Google account. -- **๐Ÿง  Powerful Gemini 2.5 Pro**: Access to 1M token context window. +- **๐Ÿง  Powerful Gemini 3 models**: Access to improved reasoning and 1M token + context window. - **๐Ÿ”ง Built-in tools**: Google Search grounding, file operations, shell commands, web fetching. - **๐Ÿ”Œ Extensible**: MCP (Model Context Protocol) support for custom @@ -39,7 +40,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). ```bash # Using npx (no installation required) -npx https://github.com/google-gemini/gemini-cli +npx @google/gemini-cli ``` #### Install globally with npm @@ -54,6 +55,23 @@ npm install -g @google/gemini-cli brew install gemini-cli ``` +#### Install globally with MacPorts (macOS) + +```bash +sudo port install gemini-cli +``` + +#### Install with Anaconda (for restricted environments) + +```bash +# Create and activate a new environment +conda create -y -n gemini_env -c conda-forge nodejs +conda activate gemini_env + +# Install Gemini CLI globally via npm (inside the environment) +npm install -g @google/gemini-cli +``` + ## Release Cadence and Tags See [Releases](./docs/releases.md) for more details. @@ -140,7 +158,7 @@ for details) **Benefits:** - **Free tier**: 60 requests/min and 1,000 requests/day -- **Gemini 2.5 Pro** with 1M token context window +- **Gemini 3 models** with 1M token context window - **No API key management** - just sign in with your Google account - **Automatic updates** to latest models @@ -164,7 +182,7 @@ gemini **Benefits:** -- **Free tier**: 100 requests/day with Gemini 2.5 Pro +- **Free tier**: 1000 requests/day with Gemini 3 (mix of flash and pro) - **Model selection**: Choose specific Gemini models - **Usage-based billing**: Upgrade for higher limits when needed diff --git a/docs/architecture.md b/docs/architecture.md index 2ba454e172..cf6ac8359d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,11 +13,11 @@ input: as handling the initial user input, presenting the final output, and managing the overall user experience. - **Key functions contained in the package:** - - [Input processing](/docs/cli/commands.md) + - [Input processing](/docs/cli/commands) - History management - Display rendering - - [Theme and UI customization](/docs/cli/themes.md) - - [CLI configuration settings](/docs/get-started/configuration.md) + - [Theme and UI customization](/docs/cli/themes) + - [CLI configuration settings](/docs/get-started/configuration) 2. **Core package (`packages/core`):** - **Purpose:** This acts as the backend for the Gemini CLI. It receives diff --git a/docs/assets/monitoring-dashboard-logs.png b/docs/assets/monitoring-dashboard-logs.png new file mode 100644 index 0000000000..e0d36ad327 Binary files /dev/null and b/docs/assets/monitoring-dashboard-logs.png differ diff --git a/docs/assets/monitoring-dashboard-metrics.png b/docs/assets/monitoring-dashboard-metrics.png new file mode 100644 index 0000000000..7a4a1e221e Binary files /dev/null and b/docs/assets/monitoring-dashboard-metrics.png differ diff --git a/docs/assets/monitoring-dashboard-overview.png b/docs/assets/monitoring-dashboard-overview.png new file mode 100644 index 0000000000..d84f214766 Binary files /dev/null and b/docs/assets/monitoring-dashboard-overview.png differ diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index e89b2eddd9..ce41218bc2 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,157 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.26.0 - 2026-01-27 + +- **Agents and Skills:** We've introduced a new `skill-creator` skill + ([#16394](https://github.com/google-gemini/gemini-cli/pull/16394) by + @NTaylorMullen), enabled agent skills by default, and added a generalist agent + to improve task routing + ([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by + @joshualitt). +- **UI/UX Improvements:** You can now "Rewind" through your conversation history + ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by @Adib234) + and use a new `/introspect` command for debugging. +- **Core and Scheduler Refactoring:** The core scheduler has been significantly + refactored to improve performance and reliability + ([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by + @abhipatel12), and numerous performance and stability fixes have been + included. + +## Announcements: v0.25.0 - 2026-01-20 + +- **Skills and Agents Improvements:** We've enhanced the `activate_skill` tool, + added a new `pr-creator` skill + ([#16232](https://github.com/google-gemini/gemini-cli/pull/16232) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), enabled skills by + default, improved the `cli_help` agent + ([#16100](https://github.com/google-gemini/gemini-cli/pull/16100) by + [@scidomino](https://github.com/scidomino)), and added a new `/agents refresh` + command ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by + [@joshualitt](https://github.com/joshualitt)). +- **UI/UX Refinements:** You'll notice more transparent feedback for skills + ([#15954](https://github.com/google-gemini/gemini-cli/pull/15954) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), the ability to switch + focus between the shell and input with Tab + ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by + [@jacob314](https://github.com/jacob314)), and dynamic terminal tab titles + ([#16378](https://github.com/google-gemini/gemini-cli/pull/16378) by + [@NTaylorMullen](https://github.com/NTaylorMullen)). +- **Core Functionality & Performance:** This release includes support for + built-in agent skills + ([#16045](https://github.com/google-gemini/gemini-cli/pull/16045) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), refined Gemini 3 system + instructions ([#16139](https://github.com/google-gemini/gemini-cli/pull/16139) + by [@NTaylorMullen](https://github.com/NTaylorMullen)), caching for ignore + instances to improve performance + ([#16185](https://github.com/google-gemini/gemini-cli/pull/16185) by + [@EricRahm](https://github.com/EricRahm)), and enhanced retry mechanisms + ([#16489](https://github.com/google-gemini/gemini-cli/pull/16489) by + [@sehoon38](https://github.com/sehoon38)). +- **Bug Fixes and Stability:** We've squashed numerous bugs across the CLI, + core, and workflows, addressing issues with subagent delegation, unicode + character crashes, and sticky header regressions. + +## Announcements: v0.24.0 - 2026-01-14 + +- **Agent Skills:** We've introduced significant advancements in Agent Skills. + This includes initial documentation and tutorials to help you get started, + alongside enhanced support for remote agents, allowing for more distributed + and powerful automation within Gemini CLI. + ([#15869](https://github.com/google-gemini/gemini-cli/pull/15869) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#16013](https://github.com/google-gemini/gemini-cli/pull/16013) by + [@adamweidman](https://github.com/adamweidman)) +- **Improved UI/UX:** The user interface has received several updates, featuring + visual indicators for hook execution, a more refined display for settings, and + the ability to use the Tab key to effortlessly switch focus between the shell + and input areas. + ([#15408](https://github.com/google-gemini/gemini-cli/pull/15408) by + [@abhipatel12](https://github.com/abhipatel12)), + ([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by + [@galz10](https://github.com/galz10)) +- **Enhanced Security:** Security has been a major focus, with default folder + trust now set to untrusted for increased safety. The Policy Engine has been + improved to allow specific modes in user and administrator policies, and + granular allowlisting for shell commands has been implemented, providing finer + control over tool execution. + ([#15943](https://github.com/google-gemini/gemini-cli/pull/15943) by + [@galz10](https://github.com/galz10)), + ([#15977](https://github.com/google-gemini/gemini-cli/pull/15977) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) +- **Core Functionality:** This release includes a mandatory MessageBus + injection, marking Phase 3 of a hard migration to a more robust internal + communication system. We've also added support for built-in skills with the + CLI itself, and enhanced model routing to effectively utilize subagents. + ([#15776](https://github.com/google-gemini/gemini-cli/pull/15776) by + [@abhipatel12](https://github.com/abhipatel12)), + ([#16300](https://github.com/google-gemini/gemini-cli/pull/16300) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) +- **Terminal Features:** Terminal interactions are more seamless with new + features like OSC 52 paste support, along with fixes for Windows clipboard + paste issues and general improvements to pasting in Windows terminals. + ([#15336](https://github.com/google-gemini/gemini-cli/pull/15336) by + [@scidomino](https://github.com/scidomino)), + ([#15932](https://github.com/google-gemini/gemini-cli/pull/15932) by + [@scidomino](https://github.com/scidomino)) +- **New Commands:** To manage the new features, we've added several new + commands: `/agents refresh` to update agent configurations, `/skills reload` + to refresh skill definitions, and `/skills install/uninstall` for easier + management of your Agent Skills. + ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#15865](https://github.com/google-gemini/gemini-cli/pull/15865) by + [@NTaylorMullen](https://github.com/NTaylorMullen)), + ([#16377](https://github.com/google-gemini/gemini-cli/pull/16377) by + [@NTaylorMullen](https://github.com/NTaylorMullen)) + +## Announcements: v0.23.0 - 2026-01-07 + +- ๐ŸŽ‰ **Experimental Agent Skills Support in Preview:** Gemini CLI now supports + [Agent Skills](https://agentskills.io/home) in our preview builds. This is an + early preview where weโ€™re looking for feedback! + - Install Preview: `npm install -g @google/gemini-cli@preview` + - Enable in `/settings` + - Docs: + [https://geminicli.com/docs/cli/skills/](https://geminicli.com/docs/cli/skills/) +- **Gemini CLI wrapped:** Run `npx gemini-wrapped` to visualize your usage + stats, top models, languages, and more! +- **Windows clipboard image support:** Windows users can now paste images + directly from their clipboard into the CLI using `Alt`+`V`. + ([pr](https://github.com/google-gemini/gemini-cli/pull/13997) by + [@sgeraldes](https://github.com/sgeraldes)) +- **Terminal background color detection:** Automatically optimizes your + terminal's background color to select compatible themes and provide + accessibility warnings. + ([pr](https://github.com/google-gemini/gemini-cli/pull/15132) by + [@jacob314](https://github.com/jacob314)) +- **Session logout:** Use the new `/logout` command to instantly clear + credentials and reset your authentication state for seamless account + switching. ([pr](https://github.com/google-gemini/gemini-cli/pull/13383) by + [@CN-Scars](https://github.com/CN-Scars)) + +## Announcements: v0.22.0 - 2025-12-22 + +- ๐ŸŽ‰**Free Tier + Gemini 3:** Free tier users now all have access to Gemini 3 + Pro & Flash. Enable in `/settings` by toggling "Preview Features" to `true`. +- ๐ŸŽ‰**Gemini CLI + Colab:** Gemini CLI is now pre-installed. Can be used + headlessly in notebook cells or interactively in the built-in terminal + ([pic](https://imgur.com/a/G0Tn7vi)) +- ๐ŸŽ‰**Gemini CLI Extensions:** + - **Conductor:** Planning++, Gemini works with you to build out a detailed + plan, pull in extra details as needed, ultimately to give the LLM guardrails + with artifacts. Measure twice, implement once! + + `gemini extensions install https://github.com/gemini-cli-extensions/conductor` + + Blog: + [https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/](https://developers.googleblog.com/conductor-introducing-context-driven-development-for-gemini-cli/) + + - **Endor Labs:** Perform code analysis, vulnerability scanning, and + dependency checks using natural language. + + `gemini extensions install https://github.com/endorlabs/gemini-extension` + ## Announcements: v0.21.0 - 2025-12-15 - **โšก๏ธโšก๏ธโšก๏ธ Gemini 3 Flash + Gemini CLI:** Better, faster and cheaper than 2.5 diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 1fc9683861..4fb09c38e1 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.21.0 - v0.21.1 +# Latest stable release: v0.26.0 -Released: December 16, 2025 +Released: January 27, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,215 +11,328 @@ npm install -g @google/gemini-cli ## Highlights -- **โšก๏ธโšก๏ธโšก๏ธ Gemini 3 Flash + Gemini CLI:** If you are a paid user, you can now - enable Gemini 3 Pro and Gemini 3 Flash. Go to `/settings` and set **Preview - Features** to `true` to enable Gemini 3. For more information: - [Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/). +- **Enhanced Agent and Skill Capabilities:** This release introduces the new + `skill-creator` built-in skill, enables Agent Skills by default, and adds a + generalist agent to improve task routing. Security for skill installation has + also been enhanced with new consent prompts. +- **Improved UI and UX:** A new "Rewind" feature lets you walk back through + conversation history. We've also added an `/introspect` command for debugging + and unified various shell confirmation dialogs for a more consistent user + experience. +- **Core Stability and Performance:** This release includes significant + performance improvements, including a fix for PDF token estimation, + optimizations for large inputs, and prevention of OOM crashes. Key memory + management components like `LRUCache` have also been updated. +- **Scheduler and Policy Refactoring:** The core tool scheduler has been + decoupled into distinct orchestration, policy, and confirmation components, + and we've added an experimental event-driven scheduler to improve performance + and reliability. ## What's Changed -- refactor(stdio): always patch stdout and use createWorkingStdio for clean - output by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14159 -- chore(release): bump version to 0.21.0-nightly.20251202.2d935b379 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14409 -- implement fuzzy search inside settings by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/13864 -- feat: enable message bus integration by default by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14329 -- docs: Recommend using --debug intead of --verbose for CLI debugging by @bbiggs - in https://github.com/google-gemini/gemini-cli/pull/14334 -- feat: consolidate remote MCP servers to use `url` in config by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/13762 -- Restrict integration tests tools by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14403 -- track github repository names in telemetry events by @IamRiddhi in - https://github.com/google-gemini/gemini-cli/pull/13670 -- Allow telemetry exporters to GCP to utilize user's login credentials, if - requested by @mboshernitsan in - https://github.com/google-gemini/gemini-cli/pull/13778 -- refactor(editor): use const assertion for editor types with single source of - truth by @amsminn in https://github.com/google-gemini/gemini-cli/pull/8604 -- fix(security): Fix npm audit vulnerabilities in glob and body-parser by - @afarber in https://github.com/google-gemini/gemini-cli/pull/14090 -- Add new enterprise instructions by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/8641 -- feat(hooks): Hook Session Lifecycle & Compression Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14151 -- Avoid triggering refreshStatic unless there really is a banner to display. by - @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14328 -- feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14225 -- fix: Bundle default policies for npx distribution by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14457 -- feat(hooks): Hook System Documentation by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14307 -- Fix tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14458 -- feat: add scheduled workflow to close stale issues by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14404 -- feat: Support Extension Hooks with Security Warning by @abhipatel12 in - https://github.com/google-gemini/gemini-cli/pull/14460 -- feat: Add enableAgents experimental flag by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14371 -- docs: fix typo 'socus' to 'focus' in todos.md by @Viktor286 in - https://github.com/google-gemini/gemini-cli/pull/14374 -- Markdown export: move the emoji to the end of the line by @mhansen in - https://github.com/google-gemini/gemini-cli/pull/12278 -- fix(acp): prevent unnecessary credential cache clearing on re-authentโ€ฆ by - @h-michael in https://github.com/google-gemini/gemini-cli/pull/9410 -- fix(cli): Fix word navigation for CJK characters by @SandyTao520 in - https://github.com/google-gemini/gemini-cli/pull/14475 -- Remove example extension by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14376 -- Add commands for listing and updating per-extension settings by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/12664 -- chore(tests): remove obsolete test for hierarchical memory by @pareshjoshij in - https://github.com/google-gemini/gemini-cli/pull/13122 -- feat(cli): support /copy in remote sessions using OSC52 by @ismellpillows in - https://github.com/google-gemini/gemini-cli/pull/13471 -- Update setting search UX by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/14451 -- Fix(cli): Improve Homebrew update instruction to specify gemini-cli by - @DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/14502 -- do not toggle the setting item when entering space by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14489 -- fix: improve retry logic for fetch errors and network codes by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/14439 -- remove unused isSearching field by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14509 -- feat(mcp): add `--type` alias for `--transport` flag in gemini mcp add by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14503 -- feat(cli): Move key restore logic to core by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13013 -- feat: add auto-execute on Enter behavior to argumentless MCP prompts by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14510 -- fix(shell): cursor visibility when using interactive mode by @aswinashok44 in - https://github.com/google-gemini/gemini-cli/pull/14095 -- Adding session id as part of json o/p by @MJjainam in - https://github.com/google-gemini/gemini-cli/pull/14504 -- fix(extensions): resolve GitHub API 415 error for source tarballs by - @jpoehnelt in https://github.com/google-gemini/gemini-cli/pull/13319 -- fix(client): Correctly latch hasFailedCompressionAttempt flag by @pareshjoshij - in https://github.com/google-gemini/gemini-cli/pull/13002 -- Disable flaky extension reloading test on linux by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14528 -- Add support for MCP dynamic tool update by `notifications/tools/list_changed` - by @Adib234 in https://github.com/google-gemini/gemini-cli/pull/14375 -- Fix privacy screen for legacy tier users by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14522 -- feat: Exclude maintainer labeled issues from stale issue closer by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14532 -- Grant chained workflows proper permission. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14534 -- Make trigger_e2e manually fireable. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14547 -- Write e2e status to local repo not forked repo by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14549 -- Fixes [API Error: Cannot read properties of undefined (reading 'error')] by - @silviojr in https://github.com/google-gemini/gemini-cli/pull/14553 -- Trigger chained e2e tests on all pull requests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14551 -- Fix bug in the shellExecutionService resulting in both truncation and 3X bloat - by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14545 -- Fix issue where we were passing the model content reflecting terminal line - wrapping. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14566 -- chore/release: bump version to 0.21.0-nightly.20251204.3da4fd5f7 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14476 -- feat(sessions): use 1-line generated session summary to describe sessions by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14467 -- Use Robot PAT for chained e2e merge queue skipper by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14585 -- fix(core): improve API response error handling and retry logic by @mattKorwel - in https://github.com/google-gemini/gemini-cli/pull/14563 -- Docs: Model routing clarification by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14373 -- expose previewFeatures flag in a2a by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14550 -- Fix emoji width in debug console. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14593 -- Fully detach autoupgrade process by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14595 -- Docs: Update Gemini 3 on Gemini CLI documentation by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14601 -- Disallow floating promises. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14605 -- chore/release: bump version to 0.21.0-nightly.20251207.025e450ac by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14662 -- feat(modelAvailabilityService): integrate model availability service into - backend logic by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14470 -- Add prompt_id propagation in a2a-server task by @koxkox111 in - https://github.com/google-gemini/gemini-cli/pull/14581 -- Fix: Prevent freezing in non-interactive Gemini CLI when debug mode is enabled - by @parthasaradhie in https://github.com/google-gemini/gemini-cli/pull/14580 -- fix(audio): improve reading of audio files by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/14658 -- Update automated triage workflow to stop assigning priority labels by - @skeshive in https://github.com/google-gemini/gemini-cli/pull/14717 -- set failed status when chained e2e fails by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14725 -- feat(github action) Triage and Label Pull Requests by Size and Compleโ€ฆ by - @DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/5571 -- refactor(telemetry): Improve previous PR that allows telemetry to use the CLI - auth and add testing by @mboshernitsan in - https://github.com/google-gemini/gemini-cli/pull/14589 -- Always set status in chained_e2e workflow by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14730 -- feat: Add OTEL log event `gemini_cli.startup_stats` for startup stats. by - @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/14734 -- feat: auto-execute on slash command completion functions by @jackwotherspoon - in https://github.com/google-gemini/gemini-cli/pull/14584 -- Docs: Proper release notes by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14405 -- Add support for user-scoped extension settings by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/13748 -- refactor(core): Improve environment variable handling in shell execution by - @galz10 in https://github.com/google-gemini/gemini-cli/pull/14742 -- Remove old E2E Workflows by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14749 -- fix: handle missing local extension config and skip hooks when disabled by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14744 -- chore/release: bump version to 0.21.0-nightly.20251209.ec9a8c7a7 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14751 -- feat: Add support for MCP Resources by @MrLesk in - https://github.com/google-gemini/gemini-cli/pull/13178 -- Always set pending status in E2E tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14756 -- fix(lint): upgrade pip and use public pypi for yamllint by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14746 -- fix: use Gemini API supported image formats for clipboard by @jackwotherspoon - in https://github.com/google-gemini/gemini-cli/pull/14762 -- feat(a2a): Introduce restore command for a2a server by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13015 -- allow final:true to be returned on a2a server edit calls. by @DavidAPierce in - https://github.com/google-gemini/gemini-cli/pull/14747 -- (fix) Automated pr labeller by @DaanVersavel in - https://github.com/google-gemini/gemini-cli/pull/14788 -- Update CODEOWNERS by @kklashtorny1 in - https://github.com/google-gemini/gemini-cli/pull/14830 -- Docs: Fix errors preventing site rebuild. by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14842 -- chore(deps): bump express from 5.1.0 to 5.2.0 by @dependabot[bot] in - https://github.com/google-gemini/gemini-cli/pull/14325 -- fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch - version v0.21.0-preview.0 and create version 0.21.0-preview.1 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14851 -- fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch - version v0.21.0-preview.1 and create version 0.21.0-preview.2 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14908 -- fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14968 -- fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch - version v0.21.0-preview.3 and create version 0.21.0-preview.4 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15003 -- fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch - version v0.21.0-preview.4 and create version 0.21.0-preview.5 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15015 -- fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch - version v0.21.0-preview.5 and create version 0.21.0-preview.6 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15153 +- fix: PDF token estimation (#16494) by @korade-krushna in + [#16527](https://github.com/google-gemini/gemini-cli/pull/16527) +- chore(release): bump version to 0.26.0-nightly.20260114.bb6c57414 by + @gemini-cli-robot in + [#16604](https://github.com/google-gemini/gemini-cli/pull/16604) +- docs: clarify F12 to open debug console by @jackwotherspoon in + [#16570](https://github.com/google-gemini/gemini-cli/pull/16570) +- docs: Remove .md extension from internal links in architecture.md by + @medic-code in + [#12899](https://github.com/google-gemini/gemini-cli/pull/12899) +- Add an experimental setting for extension config by @chrstnb in + [#16506](https://github.com/google-gemini/gemini-cli/pull/16506) +- feat: add Rewind Confirmation dialog and Rewind Viewer component by @Adib234 + in [#15717](https://github.com/google-gemini/gemini-cli/pull/15717) +- fix(a2a): Don't throw errors for GeminiEventType Retry and InvalidStream. by + @ehedlund in [#16541](https://github.com/google-gemini/gemini-cli/pull/16541) +- prefactor: add rootCommands as array so it can be used for policy parsing by + @abhipatel12 in + [#16640](https://github.com/google-gemini/gemini-cli/pull/16640) +- remove unnecessary \x7f key bindings by @scidomino in + [#16646](https://github.com/google-gemini/gemini-cli/pull/16646) +- docs(skills): use body-file in pr-creator skill for better reliability by + @abhipatel12 in + [#16642](https://github.com/google-gemini/gemini-cli/pull/16642) +- chore(automation): recursive labeling for workstream descendants by @bdmorgan + in [#16609](https://github.com/google-gemini/gemini-cli/pull/16609) +- feat: introduce 'skill-creator' built-in skill and CJS management tools by + @NTaylorMullen in + [#16394](https://github.com/google-gemini/gemini-cli/pull/16394) +- chore(automation): remove automated PR size and complexity labeler by + @bdmorgan in [#16648](https://github.com/google-gemini/gemini-cli/pull/16648) +- refactor(skills): replace 'project' with 'workspace' scope by @NTaylorMullen + in [#16380](https://github.com/google-gemini/gemini-cli/pull/16380) +- Docs: Update release notes for 1/13/2026 by @jkcinouye in + [#16583](https://github.com/google-gemini/gemini-cli/pull/16583) +- Simplify paste handling by @scidomino in + [#16654](https://github.com/google-gemini/gemini-cli/pull/16654) +- chore(automation): improve scheduled issue triage discovery and throughput by + @bdmorgan in [#16652](https://github.com/google-gemini/gemini-cli/pull/16652) +- fix(acp): run exit cleanup when stdin closes by @codefromthecrypt in + [#14953](https://github.com/google-gemini/gemini-cli/pull/14953) +- feat(scheduler): add types needed for event driven scheduler by @abhipatel12 + in [#16641](https://github.com/google-gemini/gemini-cli/pull/16641) +- Remove unused rewind key binding by @scidomino in + [#16659](https://github.com/google-gemini/gemini-cli/pull/16659) +- Remove sequence binding by @scidomino in + [#16664](https://github.com/google-gemini/gemini-cli/pull/16664) +- feat(cli): undeprecate the --prompt flag by @alexaustin007 in + [#13981](https://github.com/google-gemini/gemini-cli/pull/13981) +- chore: update dependabot configuration by @cosmopax in + [#13507](https://github.com/google-gemini/gemini-cli/pull/13507) +- feat(config): add 'auto' alias for default model selection by @sehoon38 in + [#16661](https://github.com/google-gemini/gemini-cli/pull/16661) +- Enable & disable agents by @sehoon38 in + [#16225](https://github.com/google-gemini/gemini-cli/pull/16225) +- cleanup: Improve keybindings by @scidomino in + [#16672](https://github.com/google-gemini/gemini-cli/pull/16672) +- Add timeout for shell-utils to prevent hangs. by @jacob314 in + [#16667](https://github.com/google-gemini/gemini-cli/pull/16667) +- feat(plan): add experimental plan flag by @jerop in + [#16650](https://github.com/google-gemini/gemini-cli/pull/16650) +- feat(cli): add security consent prompts for skill installation by + @NTaylorMullen in + [#16549](https://github.com/google-gemini/gemini-cli/pull/16549) +- fix: replace 3 consecutive periods with ellipsis character by @Vist233 in + [#16587](https://github.com/google-gemini/gemini-cli/pull/16587) +- chore(automation): ensure status/need-triage is applied and never cleared + automatically by @bdmorgan in + [#16657](https://github.com/google-gemini/gemini-cli/pull/16657) +- fix: Handle colons in skill description frontmatter by @maru0804 in + [#16345](https://github.com/google-gemini/gemini-cli/pull/16345) +- refactor(core): harden skill frontmatter parsing by @NTaylorMullen in + [#16705](https://github.com/google-gemini/gemini-cli/pull/16705) +- feat(skills): add conflict detection and warnings for skill overrides by + @NTaylorMullen in + [#16709](https://github.com/google-gemini/gemini-cli/pull/16709) +- feat(scheduler): add SchedulerStateManager for reactive tool state by + @abhipatel12 in + [#16651](https://github.com/google-gemini/gemini-cli/pull/16651) +- chore(automation): enforce 'help wanted' label permissions and update + guidelines by @bdmorgan in + [#16707](https://github.com/google-gemini/gemini-cli/pull/16707) +- fix(core): resolve circular dependency via tsconfig paths by @sehoon38 in + [#16730](https://github.com/google-gemini/gemini-cli/pull/16730) +- chore/release: bump version to 0.26.0-nightly.20260115.6cb3ae4e0 by + @gemini-cli-robot in + [#16738](https://github.com/google-gemini/gemini-cli/pull/16738) +- fix(automation): correct status/need-issue label matching wildcard by + @bdmorgan in [#16727](https://github.com/google-gemini/gemini-cli/pull/16727) +- fix(automation): prevent label-enforcer loop by ignoring all bots by @bdmorgan + in [#16746](https://github.com/google-gemini/gemini-cli/pull/16746) +- Add links to supported locations and minor fixes by @g-samroberts in + [#16476](https://github.com/google-gemini/gemini-cli/pull/16476) +- feat(policy): add source tracking to policy rules by @allenhutchison in + [#16670](https://github.com/google-gemini/gemini-cli/pull/16670) +- feat(automation): enforce '๐Ÿ”’ maintainer only' and fix bot loop by @bdmorgan + in [#16751](https://github.com/google-gemini/gemini-cli/pull/16751) +- Make merged settings non-nullable and fix all lints related to that. by + @jacob314 in [#16647](https://github.com/google-gemini/gemini-cli/pull/16647) +- fix(core): prevent ModelInfo event emission on aborted signal by @sehoon38 in + [#16752](https://github.com/google-gemini/gemini-cli/pull/16752) +- Replace relative paths to fix website build by @chrstnb in + [#16755](https://github.com/google-gemini/gemini-cli/pull/16755) +- Restricting to localhost by @cocosheng-g in + [#16548](https://github.com/google-gemini/gemini-cli/pull/16548) +- fix(cli): add explicit dependency on color-convert by @sehoon38 in + [#16757](https://github.com/google-gemini/gemini-cli/pull/16757) +- fix(automation): robust label enforcement with permission checks by @bdmorgan + in [#16762](https://github.com/google-gemini/gemini-cli/pull/16762) +- fix(cli): prevent OOM crash by limiting file search traversal and adding + timeout by @galz10 in + [#16696](https://github.com/google-gemini/gemini-cli/pull/16696) +- fix(cli): safely handle /dev/tty access on macOS by @korade-krushna in + [#16531](https://github.com/google-gemini/gemini-cli/pull/16531) +- docs: clarify workspace test execution in GEMINI.md by @mattKorwel in + [#16764](https://github.com/google-gemini/gemini-cli/pull/16764) +- Add support for running available commands prior to MCP servers loading by + @Adib234 in [#15596](https://github.com/google-gemini/gemini-cli/pull/15596) +- feat(plan): add experimental 'plan' approval mode by @jerop in + [#16753](https://github.com/google-gemini/gemini-cli/pull/16753) +- feat(scheduler): add functional awaitConfirmation utility by @abhipatel12 in + [#16721](https://github.com/google-gemini/gemini-cli/pull/16721) +- fix(infra): update maintainer rollup label to 'workstream-rollup' by @bdmorgan + in [#16809](https://github.com/google-gemini/gemini-cli/pull/16809) +- fix(infra): use GraphQL to detect direct parents in rollup workflow by + @bdmorgan in [#16811](https://github.com/google-gemini/gemini-cli/pull/16811) +- chore(workflows): rename label-workstream-rollup workflow by @bdmorgan in + [#16818](https://github.com/google-gemini/gemini-cli/pull/16818) +- skip simple-mcp-server.test.ts by @scidomino in + [#16842](https://github.com/google-gemini/gemini-cli/pull/16842) +- Steer outer agent to use expert subagents when present by @gundermanc in + [#16763](https://github.com/google-gemini/gemini-cli/pull/16763) +- Fix race condition by awaiting scheduleToolCalls by @chrstnb in + [#16759](https://github.com/google-gemini/gemini-cli/pull/16759) +- cleanup: Organize key bindings by @scidomino in + [#16798](https://github.com/google-gemini/gemini-cli/pull/16798) +- feat(core): Add generalist agent. by @joshualitt in + [#16638](https://github.com/google-gemini/gemini-cli/pull/16638) +- perf(ui): optimize text buffer and highlighting for large inputs by + @NTaylorMullen in + [#16782](https://github.com/google-gemini/gemini-cli/pull/16782) +- fix(core): fix PTY descriptor shell leak by @galz10 in + [#16773](https://github.com/google-gemini/gemini-cli/pull/16773) +- feat(plan): enforce strict read-only policy and halt execution on violation by + @jerop in [#16849](https://github.com/google-gemini/gemini-cli/pull/16849) +- remove need-triage label from bug_report template by @sehoon38 in + [#16864](https://github.com/google-gemini/gemini-cli/pull/16864) +- fix(core): truncate large telemetry log entries by @sehoon38 in + [#16769](https://github.com/google-gemini/gemini-cli/pull/16769) +- docs(extensions): add Agent Skills support and mark feature as experimental by + @NTaylorMullen in + [#16859](https://github.com/google-gemini/gemini-cli/pull/16859) +- fix(core): surface warnings for invalid hook event names in configuration + (#16788) by @sehoon38 in + [#16873](https://github.com/google-gemini/gemini-cli/pull/16873) +- feat(plan): remove read_many_files from approval mode policies by @jerop in + [#16876](https://github.com/google-gemini/gemini-cli/pull/16876) +- feat(admin): implement admin controls polling and restart prompt by @skeshive + in [#16627](https://github.com/google-gemini/gemini-cli/pull/16627) +- Remove LRUCache class migrating to mnemoist by @jacob314 in + [#16872](https://github.com/google-gemini/gemini-cli/pull/16872) +- feat(settings): rename negative settings to positive naming (disable* -> + enable*) by @afarber in + [#14142](https://github.com/google-gemini/gemini-cli/pull/14142) +- refactor(cli): unify shell confirmation dialogs by @NTaylorMullen in + [#16828](https://github.com/google-gemini/gemini-cli/pull/16828) +- feat(agent): enable agent skills by default by @NTaylorMullen in + [#16736](https://github.com/google-gemini/gemini-cli/pull/16736) +- refactor(core): foundational truncation refactoring and token estimation + optimization by @NTaylorMullen in + [#16824](https://github.com/google-gemini/gemini-cli/pull/16824) +- fix(hooks): enable /hooks disable to reliably stop single hooks by + @abhipatel12 in + [#16804](https://github.com/google-gemini/gemini-cli/pull/16804) +- Don't commit unless user asks us to. by @gundermanc in + [#16902](https://github.com/google-gemini/gemini-cli/pull/16902) +- chore: remove a2a-adapter and bump @a2a-js/sdk to 0.3.8 by @adamfweidman in + [#16800](https://github.com/google-gemini/gemini-cli/pull/16800) +- fix: Show experiment values in settings UI for compressionThreshold by + @ishaanxgupta in + [#16267](https://github.com/google-gemini/gemini-cli/pull/16267) +- feat(cli): replace relative keyboard shortcuts link with web URL by + @imaliabbas in + [#16479](https://github.com/google-gemini/gemini-cli/pull/16479) +- fix(core): resolve PKCE length issue and stabilize OAuth redirect port by + @sehoon38 in [#16815](https://github.com/google-gemini/gemini-cli/pull/16815) +- Delete rewind documentation for now by @Adib234 in + [#16932](https://github.com/google-gemini/gemini-cli/pull/16932) +- Stabilize skill-creator CI and package format by @NTaylorMullen in + [#17001](https://github.com/google-gemini/gemini-cli/pull/17001) +- Stabilize the git evals by @gundermanc in + [#16989](https://github.com/google-gemini/gemini-cli/pull/16989) +- fix(core): attempt compression before context overflow check by @NTaylorMullen + in [#16914](https://github.com/google-gemini/gemini-cli/pull/16914) +- Fix inverted logic. by @gundermanc in + [#17007](https://github.com/google-gemini/gemini-cli/pull/17007) +- chore(scripts): add duplicate issue closer script and fix lint errors by + @bdmorgan in [#16997](https://github.com/google-gemini/gemini-cli/pull/16997) +- docs: update README and config guide to reference Gemini 3 by @JayadityaGit in + [#15806](https://github.com/google-gemini/gemini-cli/pull/15806) +- fix(cli): correct Homebrew installation detection by @kij in + [#14727](https://github.com/google-gemini/gemini-cli/pull/14727) +- Demote git evals to nightly run. by @gundermanc in + [#17030](https://github.com/google-gemini/gemini-cli/pull/17030) +- fix(cli): use OSC-52 clipboard copy in Windows Terminal by @Thomas-Shephard in + [#16920](https://github.com/google-gemini/gemini-cli/pull/16920) +- Fix: Process all parts in response chunks when thought is first by @pyrytakala + in [#13539](https://github.com/google-gemini/gemini-cli/pull/13539) +- fix(automation): fix jq quoting error in pr-triage.sh by @Kimsoo0119 in + [#16958](https://github.com/google-gemini/gemini-cli/pull/16958) +- refactor(core): decouple scheduler into orchestration, policy, and + confirmation by @abhipatel12 in + [#16895](https://github.com/google-gemini/gemini-cli/pull/16895) +- feat: add /introspect slash command by @NTaylorMullen in + [#17048](https://github.com/google-gemini/gemini-cli/pull/17048) +- refactor(cli): centralize tool mapping and decouple legacy scheduler by + @abhipatel12 in + [#17044](https://github.com/google-gemini/gemini-cli/pull/17044) +- fix(ui): ensure rationale renders before tool calls by @NTaylorMullen in + [#17043](https://github.com/google-gemini/gemini-cli/pull/17043) +- fix(workflows): use author_association for maintainer check by @bdmorgan in + [#17060](https://github.com/google-gemini/gemini-cli/pull/17060) +- fix return type of fireSessionStartEvent to defaultHookOutput by @ved015 in + [#16833](https://github.com/google-gemini/gemini-cli/pull/16833) +- feat(cli): add experiment gate for event-driven scheduler by @abhipatel12 in + [#17055](https://github.com/google-gemini/gemini-cli/pull/17055) +- feat(core): improve shell redirection transparency and security by + @NTaylorMullen in + [#16486](https://github.com/google-gemini/gemini-cli/pull/16486) +- fix(core): deduplicate ModelInfo emission in GeminiClient by @NTaylorMullen in + [#17075](https://github.com/google-gemini/gemini-cli/pull/17075) +- docs(themes): remove unsupported DiffModified color key by @jw409 in + [#17073](https://github.com/google-gemini/gemini-cli/pull/17073) +- fix: update currentSequenceModel when modelChanged by @adamfweidman in + [#17051](https://github.com/google-gemini/gemini-cli/pull/17051) +- feat(core): enhanced anchored iterative context compression with + self-verification by @rmedranollamas in + [#15710](https://github.com/google-gemini/gemini-cli/pull/15710) +- Fix mcp instructions by @chrstnb in + [#16439](https://github.com/google-gemini/gemini-cli/pull/16439) +- [A2A] Disable checkpointing if git is not installed by @cocosheng-g in + [#16896](https://github.com/google-gemini/gemini-cli/pull/16896) +- feat(admin): set admin.skills.enabled based on advancedFeaturesEnabled setting + by @skeshive in + [#17095](https://github.com/google-gemini/gemini-cli/pull/17095) +- Test coverage for hook exit code cases by @gundermanc in + [#17041](https://github.com/google-gemini/gemini-cli/pull/17041) +- Revert "Revert "Update extension examples"" by @chrstnb in + [#16445](https://github.com/google-gemini/gemini-cli/pull/16445) +- fix(core): Provide compact, actionable errors for agent delegation failures by + @SandyTao520 in + [#16493](https://github.com/google-gemini/gemini-cli/pull/16493) +- fix: migrate BeforeModel and AfterModel hooks to HookSystem by @ved015 in + [#16599](https://github.com/google-gemini/gemini-cli/pull/16599) +- feat(admin): apply admin settings to gemini skills/mcp/extensions commands by + @skeshive in [#17102](https://github.com/google-gemini/gemini-cli/pull/17102) +- fix(core): update telemetry token count after session resume by @psinha40898 + in [#15491](https://github.com/google-gemini/gemini-cli/pull/15491) +- Demote the subagent test to nightly by @gundermanc in + [#17105](https://github.com/google-gemini/gemini-cli/pull/17105) +- feat(plan): telemetry to track adoption and usage of plan mode by @Adib234 in + [#16863](https://github.com/google-gemini/gemini-cli/pull/16863) +- feat: Add flash lite utility fallback chain by @adamfweidman in + [#17056](https://github.com/google-gemini/gemini-cli/pull/17056) +- Fixes Windows crash: "Cannot resize a pty that has already exited" by @dzammit + in [#15757](https://github.com/google-gemini/gemini-cli/pull/15757) +- feat(core): Add initial eval for generalist agent. by @joshualitt in + [#16856](https://github.com/google-gemini/gemini-cli/pull/16856) +- feat(core): unify agent enabled and disabled flags by @SandyTao520 in + [#17127](https://github.com/google-gemini/gemini-cli/pull/17127) +- fix(core): resolve auto model in default strategy by @sehoon38 in + [#17116](https://github.com/google-gemini/gemini-cli/pull/17116) +- docs: update project context and pr-creator workflow by @NTaylorMullen in + [#17119](https://github.com/google-gemini/gemini-cli/pull/17119) +- fix(cli): send gemini-cli version as mcp client version by @dsp in + [#13407](https://github.com/google-gemini/gemini-cli/pull/13407) +- fix(cli): resolve Ctrl+Enter and Ctrl+J newline issues by @imadraude in + [#17021](https://github.com/google-gemini/gemini-cli/pull/17021) +- Remove missing sidebar item by @chrstnb in + [#17145](https://github.com/google-gemini/gemini-cli/pull/17145) +- feat(core): Ensure all properties in hooks object are event names. by + @joshualitt in + [#16870](https://github.com/google-gemini/gemini-cli/pull/16870) +- fix(cli): fix newline support broken in previous PR by @scidomino in + [#17159](https://github.com/google-gemini/gemini-cli/pull/17159) +- Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. + by @gsquared94 in + [#16231](https://github.com/google-gemini/gemini-cli/pull/16231) +- Add Esc-Esc to clear prompt when it's not empty by @Adib234 in + [#17131](https://github.com/google-gemini/gemini-cli/pull/17131) +- Avoid spurious warnings about unexpected renders triggered by appEvents and + coreEvents. by @jacob314 in + [#17160](https://github.com/google-gemini/gemini-cli/pull/17160) +- fix(cli): resolve home/end keybinding conflict by @scidomino in + [#17124](https://github.com/google-gemini/gemini-cli/pull/17124) +- fix(cli): display 'http' type on mcp list by @pamanta in + [#16915](https://github.com/google-gemini/gemini-cli/pull/16915) +- fix bad fallback logic external editor logic by @scidomino in + [#17166](https://github.com/google-gemini/gemini-cli/pull/17166) +- Fix bug where System scopes weren't migrated. by @jacob314 in + [#17174](https://github.com/google-gemini/gemini-cli/pull/17174) +- Fix mcp tool lookup in tool registry by @werdnum in + [#17054](https://github.com/google-gemini/gemini-cli/pull/17054) -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.20.2...v0.21.0 +**Full changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.25.2...v0.26.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 299c90fbf3..f80d8db80e 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: Release v0.22.0-preview.0 +# Preview release: Release v0.27.0-preview.0 -Released: December 16, 2025 +Released: January 27, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -11,119 +11,427 @@ To install the preview release: npm install -g @google/gemini-cli@preview ``` +## Highlights + +- **Event-Driven Architecture:** The tool execution scheduler is now + event-driven, improving performance and reliability. +- **System Prompt Override:** Now supports dynamic variable substitution. +- **Rewind Command:** The `/rewind` command has been implemented. +- **Linux Clipboard:** Image pasting capabilities for Wayland and X11 on Linux. + ## What's Changed -- feat(ide): fallback to GEMINI_CLI_IDE_AUTH_TOKEN env var by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14843 -- feat: display quota stats for unused models in /stats by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14764 -- feat: ensure codebase investigator uses preview model when main agent does by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14412 -- chore: add closing reason to stale bug workflow by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14861 -- Send the model and CLI version with the user agent by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14865 -- refactor(sessions): move session summary generation to startup by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14691 -- Limit search depth in path corrector by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14869 -- Fix: Correct typo in code comment by @kuishou68 in - https://github.com/google-gemini/gemini-cli/pull/14671 -- feat(core): Plumbing for late resolution of model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/14597 -- feat: attempt more error parsing by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14899 -- Add missing await. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14910 -- feat(core): Add support for transcript_path in hooks for git-ai/Gemini - extension by @svarlamov in - https://github.com/google-gemini/gemini-cli/pull/14663 -- refactor: implement DelegateToAgentTool with discriminated union by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14769 -- feat: reset availabilityService on /auth by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14911 -- chore/release: bump version to 0.21.0-nightly.20251211.8c83e1ea9 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14924 -- Fix: Correctly detect MCP tool errors by @kevin-ramdass in - https://github.com/google-gemini/gemini-cli/pull/14937 -- increase labeler timeout by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14922 -- tool(cli): tweak the frontend tool to be aware of more core files from the cli - by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14962 -- feat(cli): polish cached token stats and simplify stats display when quota is - present. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14961 -- feat(settings-validation): add validation for settings schema by @lifefloating - in https://github.com/google-gemini/gemini-cli/pull/12929 -- fix(ide): Update IDE extension to write auth token in env var by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14999 -- Revert "chore(deps): bump express from 5.1.0 to 5.2.0" by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14998 -- feat(a2a): Introduce /init command for a2a server by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13419 -- feat: support multi-file drag and drop of images by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/14832 -- fix(policy): allow codebase_investigator by default in read-only policy by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15000 -- refactor(ide ext): Update port file name + switch to 1-based index for - characters + remove truncation text by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/10501 -- fix(vscode-ide-companion): correct license generation for workspace - dependencies by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/15004 -- fix: temp fix for subagent invocation until subagent delegation is merged to - stable by @abhipatel12 in - https://github.com/google-gemini/gemini-cli/pull/15007 -- test: update ide detection tests to make them more robust when run in an ide - by @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/15008 -- Remove flex from stats display. See snapshots for diffs. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14983 -- Add license field into package.json by @jb-perez in - https://github.com/google-gemini/gemini-cli/pull/14473 -- feat: Persistent "Always Allow" policies with granular shell & MCP support by - @allenhutchison in https://github.com/google-gemini/gemini-cli/pull/14737 -- chore/release: bump version to 0.21.0-nightly.20251212.54de67536 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14969 -- fix(core): commandPrefix word boundary and compound command safety by - @allenhutchison in https://github.com/google-gemini/gemini-cli/pull/15006 -- chore(docs): add 'Maintainers only' label info to CONTRIBUTING.md by @jacob314 - in https://github.com/google-gemini/gemini-cli/pull/14914 -- Refresh hooks when refreshing extensions. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14918 -- Add clarity to error messages by @gsehgal in - https://github.com/google-gemini/gemini-cli/pull/14879 -- chore : remove a redundant tip by @JayadityaGit in - https://github.com/google-gemini/gemini-cli/pull/14947 -- chore/release: bump version to 0.21.0-nightly.20251213.977248e09 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15029 -- Disallow redundant typecasts. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/15030 -- fix(auth): prioritize GEMINI_API_KEY env var and skip unnecessary keyโ€ฆ by - @galz10 in https://github.com/google-gemini/gemini-cli/pull/14745 -- fix: use zod for safety check result validation by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/15026 -- update(telemetry): add hashed_extension_name to field to extension events by - @kiranani in https://github.com/google-gemini/gemini-cli/pull/15025 -- fix: similar to policy-engine, throw error in case of requiring tool execution - confirmation for non-interactive mode by @MayV in - https://github.com/google-gemini/gemini-cli/pull/14702 -- Clean up processes in integration tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/15102 -- docs: update policy engine getting started and defaults by @NTaylorMullen in - https://github.com/google-gemini/gemini-cli/pull/15105 -- Fix tool output fragmentation by encapsulating content in functionResponse by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/13082 -- Simplify method signature. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/15114 -- Show raw input token counts in json output. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/15021 -- fix: Mark A2A requests as interactive by @MayV in - https://github.com/google-gemini/gemini-cli/pull/15108 -- use previewFeatures to determine which pro model to use for A2A by @sehoon38 - in https://github.com/google-gemini/gemini-cli/pull/15131 -- refactor(cli): fix settings merging so that settings using the new json format - take priority over ones using the old format by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/15116 +- remove fireAgent and beforeAgent hook by @ishaanxgupta in + [#16919](https://github.com/google-gemini/gemini-cli/pull/16919) +- Remove unused modelHooks and toolHooks by @ved015 in + [#17115](https://github.com/google-gemini/gemini-cli/pull/17115) +- feat(cli): sanitize ANSI escape sequences in non-interactive output by + @sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172) +- Update Attempt text to Retry when showing the retry happening to the โ€ฆ by + @sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178) +- chore(skills): update pr-creator skill workflow by @sehoon38 in + [#17180](https://github.com/google-gemini/gemini-cli/pull/17180) +- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in + [#17078](https://github.com/google-gemini/gemini-cli/pull/17078) +- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by + @gemini-cli-robot in + [#17181](https://github.com/google-gemini/gemini-cli/pull/17181) +- Remove other rewind reference in docs by @chrstnb in + [#17149](https://github.com/google-gemini/gemini-cli/pull/17149) +- feat(skills): add code-reviewer skill by @sehoon38 in + [#17187](httpshttps://github.com/google-gemini/gemini-cli/pull/17187) +- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in + [#17177](https://github.com/google-gemini/gemini-cli/pull/17177) +- feat(plan): refactor TestRig and eval helper to support configurable approval + modes by @jerop in + [#17171](https://github.com/google-gemini/gemini-cli/pull/17171) +- feat(workflows): support recursive workstream labeling and new IDs by + @bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207) +- Run evals for all models. by @gundermanc in + [#17123](https://github.com/google-gemini/gemini-cli/pull/17123) +- fix(github): improve label-workstream-rollup efficiency with GraphQL by + @bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217) +- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by + @g-samroberts in + [#17215](https://github.com/google-gemini/gemini-cli/pull/17215) +- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in + [#17204](https://github.com/google-gemini/gemini-cli/pull/17204) +- fix(github): improve label-workstream-rollup efficiency and fix bugs by + @bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219) +- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in + [#17192](https://github.com/google-gemini/gemini-cli/pull/17192) +- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in + [#17218](https://github.com/google-gemini/gemini-cli/pull/17218) +- feat(core): support dynamic variable substitution in system prompt override by + @NTaylorMullen in + [#17042](https://github.com/google-gemini/gemini-cli/pull/17042) +- fix(core,cli): enable recursive directory access for by @galz10 in + [#17094](https://github.com/google-gemini/gemini-cli/pull/17094) +- Docs: Marking for experimental features by @jkcinouye in + [#16760](https://github.com/google-gemini/gemini-cli/pull/16760) +- Support command/ctrl/alt backspace correctly by @scidomino in + [#17175](https://github.com/google-gemini/gemini-cli/pull/17175) +- feat(plan): add approval mode instructions to system prompt by @jerop in + [#17151](https://github.com/google-gemini/gemini-cli/pull/17151) +- feat(core): enable disableLLMCorrection by default by @SandyTao520 in + [#17223](https://github.com/google-gemini/gemini-cli/pull/17223) +- Remove unused slug from sidebar by @chrstnb in + [#17229](https://github.com/google-gemini/gemini-cli/pull/17229) +- drain stdin on exit by @scidomino in + [#17241](https://github.com/google-gemini/gemini-cli/pull/17241) +- refactor(cli): decouple UI from live tool execution via ToolActionsContext by + @abhipatel12 in + [#17183](https://github.com/google-gemini/gemini-cli/pull/17183) +- fix(core): update token count and telemetry on /chat resume history load by + @psinha40898 in + [#16279](https://github.com/google-gemini/gemini-cli/pull/16279) +- fix: /policy to display policies according to mode by @ishaanxgupta in + [#16772](https://github.com/google-gemini/gemini-cli/pull/16772) +- fix(core): simplify replace tool error message by @SandyTao520 in + [#17246](https://github.com/google-gemini/gemini-cli/pull/17246) +- feat(cli): consolidate shell inactivity and redirection monitoring by + @NTaylorMullen in + [#17086](https://github.com/google-gemini/gemini-cli/pull/17086) +- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by + @abhipatel12 in + [#17227](https://github.com/google-gemini/gemini-cli/pull/17227) +- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in + [#17211](https://github.com/google-gemini/gemini-cli/pull/17211) +- feat(hooks): enable hooks system by default by @abhipatel12 in + [#17247](https://github.com/google-gemini/gemini-cli/pull/17247) +- feat(core): Enable AgentRegistry to track all discovered subagents by + @SandyTao520 in + [#17253](https://github.com/google-gemini/gemini-cli/pull/17253) +- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in + [#17152](https://github.com/google-gemini/gemini-cli/pull/17152) +- feat: replace large text pastes with [Pasted Text: X lines] placeholder by + @jackwotherspoon in + [#16422](https://github.com/google-gemini/gemini-cli/pull/16422) +- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul + in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237) +- Enable the ability to queue specific nightly eval tests by @gundermanc in + [#17262](https://github.com/google-gemini/gemini-cli/pull/17262) +- docs(hooks): comprehensive update of hook documentation and specs by + @abhipatel12 in + [#16816](https://github.com/google-gemini/gemini-cli/pull/16816) +- refactor: improve large text paste placeholder by @jacob314 in + [#17269](https://github.com/google-gemini/gemini-cli/pull/17269) +- feat: implement /rewind command by @Adib234 in + [#15720](https://github.com/google-gemini/gemini-cli/pull/15720) +- Feature/jetbrains ide detection by @SoLoHiC in + [#16243](https://github.com/google-gemini/gemini-cli/pull/16243) +- docs: update typo in mcp-server.md file by @schifferl in + [#17099](https://github.com/google-gemini/gemini-cli/pull/17099) +- Sanitize command names and descriptions by @ehedlund in + [#17228](https://github.com/google-gemini/gemini-cli/pull/17228) +- fix(auth): don't crash when initial auth fails by @skeshive in + [#17308](https://github.com/google-gemini/gemini-cli/pull/17308) +- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in + [#17144](https://github.com/google-gemini/gemini-cli/pull/17144) +- feat: add AskUser tool schema by @jackwotherspoon in + [#16988](https://github.com/google-gemini/gemini-cli/pull/16988) +- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in + [#16256](https://github.com/google-gemini/gemini-cli/pull/16256) +- fix: show whitespace changes in edit tool diffs by @Ujjiyara in + [#17213](https://github.com/google-gemini/gemini-cli/pull/17213) +- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool() + now handles the caching internally by @jacob314 in + [#17320](https://github.com/google-gemini/gemini-cli/pull/17320) +- ci: allow failure in evals-nightly run step by @gundermanc in + [#17319](https://github.com/google-gemini/gemini-cli/pull/17319) +- feat(cli): Add state management and plumbing for agent configuration dialog by + @SandyTao520 in + [#17259](https://github.com/google-gemini/gemini-cli/pull/17259) +- bug: fix ide-client connection to ide-companion when inside docker via + ssh/devcontainer by @kapsner in + [#15049](https://github.com/google-gemini/gemini-cli/pull/15049) +- Emit correct newline type return by @scidomino in + [#17331](https://github.com/google-gemini/gemini-cli/pull/17331) +- New skill: docs-writer by @g-samroberts in + [#17268](https://github.com/google-gemini/gemini-cli/pull/17268) +- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by + @spencer426 in + [#16735](https://github.com/google-gemini/gemini-cli/pull/16735) +- Disable tips after 10 runs by @Adib234 in + [#17101](https://github.com/google-gemini/gemini-cli/pull/17101) +- Fix so rewind starts at the bottom and loadHistory refreshes static content. + by @jacob314 in + [#17335](https://github.com/google-gemini/gemini-cli/pull/17335) +- feat(core): Remove legacy settings. by @joshualitt in + [#17244](https://github.com/google-gemini/gemini-cli/pull/17244) +- feat(plan): add 'communicate' tool kind by @jerop in + [#17341](https://github.com/google-gemini/gemini-cli/pull/17341) +- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by + @mattKorwel in + [#16041](https://github.com/google-gemini/gemini-cli/pull/16041) +- feat(plan): update UI Theme for Plan Mode by @Adib234 in + [#17243](https://github.com/google-gemini/gemini-cli/pull/17243) +- fix(ui): stabilize rendering during terminal resize in alternate buffer by + @lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783) +- feat(cli): add /agents config command and improve agent discovery by + @SandyTao520 in + [#17342](https://github.com/google-gemini/gemini-cli/pull/17342) +- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb + in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299) +- fix(cli)!: Default to interactive mode for positional arguments by + @ishaanxgupta in + [#16329](https://github.com/google-gemini/gemini-cli/pull/16329) +- Fix issue #17080 by @jacob314 in + [#17100](https://github.com/google-gemini/gemini-cli/pull/17100) +- feat(core): Refresh agents after loading an extension. by @joshualitt in + [#17355](https://github.com/google-gemini/gemini-cli/pull/17355) +- fix(cli): include source in policy rule display by @allenhutchison in + [#17358](https://github.com/google-gemini/gemini-cli/pull/17358) +- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by + @gsquared94 in + [#17236](https://github.com/google-gemini/gemini-cli/pull/17236) +- Refactor subagent delegation to be one tool per agent by @gundermanc in + [#17346](https://github.com/google-gemini/gemini-cli/pull/17346) +- fix(core): Include MCP server name in OAuth message by @jerop in + [#17351](https://github.com/google-gemini/gemini-cli/pull/17351) +- Fix pr-triage.sh script to update pull requests with tags "help wanted" and + "maintainer only" by @jacob314 in + [#17324](https://github.com/google-gemini/gemini-cli/pull/17324) +- feat(plan): implement simple workflow for planning in main agent by @jerop in + [#17326](https://github.com/google-gemini/gemini-cli/pull/17326) +- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in + [#16967](https://github.com/google-gemini/gemini-cli/pull/16967) +- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by + @medic-code in + [#12038](https://github.com/google-gemini/gemini-cli/pull/12038) +- fix(core): add alternative command names for Antigravity editor detecโ€ฆ by + @BaeSeokJae in + [#16829](https://github.com/google-gemini/gemini-cli/pull/16829) +- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in + [#15737](https://github.com/google-gemini/gemini-cli/pull/15737) +- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in + [#17390](https://github.com/google-gemini/gemini-cli/pull/17390) +- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in + [#16714](https://github.com/google-gemini/gemini-cli/pull/16714) +- fix(core): gracefully handle disk full errors in chat recording by + @godwiniheuwa in + [#17305](https://github.com/google-gemini/gemini-cli/pull/17305) +- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in + [#17388](https://github.com/google-gemini/gemini-cli/pull/17388) +- fix(core): use RFC 9728 compliant path-based OAuth protected resource + discovery by @vrv in + [#15756](https://github.com/google-gemini/gemini-cli/pull/15756) +- Update Code Wiki README badge by @PatoBeltran in + [#15229](https://github.com/google-gemini/gemini-cli/pull/15229) +- Add conda installation instructions for Gemini CLI by @ishaanxgupta in + [#16921](https://github.com/google-gemini/gemini-cli/pull/16921) +- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in + [#17369](https://github.com/google-gemini/gemini-cli/pull/17369) +- fix(cli): preserve input text when declining tool approval (#15624) by + @ManojINaik in + [#15659](https://github.com/google-gemini/gemini-cli/pull/15659) +- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in + [#17403](https://github.com/google-gemini/gemini-cli/pull/17403) +- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon + in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344) +- feat(ui): display user tier in about command by @sehoon38 in + [#17400](https://github.com/google-gemini/gemini-cli/pull/17400) +- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in + [#16574](https://github.com/google-gemini/gemini-cli/pull/16574) +- fix(cli): change image paste location to global temp directory (#17396) by + @devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396) +- Fix line endings issue with Notice file by @scidomino in + [#17417](https://github.com/google-gemini/gemini-cli/pull/17417) +- feat(plan): implement persistent approvalMode setting by @Adib234 in + [#17350](https://github.com/google-gemini/gemini-cli/pull/17350) +- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in + [#17404](https://github.com/google-gemini/gemini-cli/pull/17404) +- Allow prompt queueing during MCP initialization by @Adib234 in + [#17395](https://github.com/google-gemini/gemini-cli/pull/17395) +- feat: implement AgentConfigDialog for /agents config command by @SandyTao520 + in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370) +- fix(agents): default to all tools when tool list is omitted in subagents by + @gundermanc in + [#17422](https://github.com/google-gemini/gemini-cli/pull/17422) +- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in + [#17276](https://github.com/google-gemini/gemini-cli/pull/17276) +- fix(core): hide user tier name by @sehoon38 in + [#17418](https://github.com/google-gemini/gemini-cli/pull/17418) +- feat: Enforce unified folder trust for /directory add by @galz10 in + [#17359](https://github.com/google-gemini/gemini-cli/pull/17359) +- migrate fireToolNotificationHook to hookSystem by @ved015 in + [#17398](https://github.com/google-gemini/gemini-cli/pull/17398) +- Clean up dead code by @scidomino in + [#17443](https://github.com/google-gemini/gemini-cli/pull/17443) +- feat(workflow): add stale pull request closer with linked-issue enforcement by + @bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449) +- feat(workflow): expand stale-exempt labels to include help wanted and Public + Roadmap by @bdmorgan in + [#17459](https://github.com/google-gemini/gemini-cli/pull/17459) +- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in + [#17460](https://github.com/google-gemini/gemini-cli/pull/17460) +- Resolves the confusing error message `ripgrep exited with code null that + occurs when a search operation is cancelled or aborted by @maximmasiutin in + [#14267](https://github.com/google-gemini/gemini-cli/pull/14267) +- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in + [#15254](https://github.com/google-gemini/gemini-cli/pull/15254) +- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by + @breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412) +- docs(hooks): clarify mandatory 'type' field and update hook schema + documentation by @abhipatel12 in + [#17499](https://github.com/google-gemini/gemini-cli/pull/17499) +- Improve error messages on failed onboarding by @gsquared94 in + [#17357](https://github.com/google-gemini/gemini-cli/pull/17357) +- Follow up to "enableInteractiveShell for external tooling relying on a2a + server" by @DavidAPierce in + [#17130](https://github.com/google-gemini/gemini-cli/pull/17130) +- Fix/issue 17070 by @alih552 in + [#17242](https://github.com/google-gemini/gemini-cli/pull/17242) +- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin + in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476) +- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in + [#17470](https://github.com/google-gemini/gemini-cli/pull/17470) +- feat(core): optimize shell tool llmContent output format by @SandyTao520 in + [#17538](https://github.com/google-gemini/gemini-cli/pull/17538) +- Fix bug in detecting already added paths. by @jacob314 in + [#17430](https://github.com/google-gemini/gemini-cli/pull/17430) +- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs + by @abhipatel12 in + [#17429](https://github.com/google-gemini/gemini-cli/pull/17429) +- feat(agents): implement first-run experience for project-level sub-agents by + @gundermanc in + [#17266](https://github.com/google-gemini/gemini-cli/pull/17266) +- Update extensions docs by @chrstnb in + [#16093](https://github.com/google-gemini/gemini-cli/pull/16093) +- Docs: Refactor left nav on the website by @jkcinouye in + [#17558](https://github.com/google-gemini/gemini-cli/pull/17558) +- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in + [#17146](https://github.com/google-gemini/gemini-cli/pull/17146) +- feat(plan): add persistent plan file storage by @jerop in + [#17563](https://github.com/google-gemini/gemini-cli/pull/17563) +- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in + [#17567](https://github.com/google-gemini/gemini-cli/pull/17567) +- Fix extensions config error by @chrstnb in + [#17580](https://github.com/google-gemini/gemini-cli/pull/17580) +- fix(plan): remove subagent invocation from plan mode by @jerop in + [#17593](https://github.com/google-gemini/gemini-cli/pull/17593) +- feat(ui): add solid background color option for input prompt by @jacob314 in + [#16563](https://github.com/google-gemini/gemini-cli/pull/16563) +- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by + @jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585) +- feat(cli): add global setting to disable UI spinners by @galz10 in + [#17234](https://github.com/google-gemini/gemini-cli/pull/17234) +- fix(security): enforce strict policy directory permissions by @yunaseoul in + [#17353](https://github.com/google-gemini/gemini-cli/pull/17353) +- test(core): fix tests in windows by @scidomino in + [#17592](https://github.com/google-gemini/gemini-cli/pull/17592) +- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers + included in an extension( Issue #11057 & #17402) by @jasmeetsb in + [#17434](https://github.com/google-gemini/gemini-cli/pull/17434) +- Always map mac keys, even on other platforms by @scidomino in + [#17618](https://github.com/google-gemini/gemini-cli/pull/17618) +- Ctrl-O by @jacob314 in + [#17617](https://github.com/google-gemini/gemini-cli/pull/17617) +- feat(plan): update cycling order of approval modes by @Adib234 in + [#17622](https://github.com/google-gemini/gemini-cli/pull/17622) +- fix(cli): restore 'Modify with editor' option in external terminals by + @abhipatel12 in + [#17621](https://github.com/google-gemini/gemini-cli/pull/17621) +- Slash command for helping in debugging by @gundermanc in + [#17609](https://github.com/google-gemini/gemini-cli/pull/17609) +- feat: add double-click to expand/collapse large paste placeholders by + @jackwotherspoon in + [#17471](https://github.com/google-gemini/gemini-cli/pull/17471) +- refactor(cli): migrate non-interactive flow to event-driven scheduler by + @abhipatel12 in + [#17572](https://github.com/google-gemini/gemini-cli/pull/17572) +- fix: loadcodeassist eligible tiers getting ignored for unlicensed users + (regression) by @gsquared94 in + [#17581](https://github.com/google-gemini/gemini-cli/pull/17581) +- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in + [#17573](https://github.com/google-gemini/gemini-cli/pull/17573) +- feat(core): enforce server prefixes for MCP tools in agent definitions by + @abhipatel12 in + [#17574](https://github.com/google-gemini/gemini-cli/pull/17574) +- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in + [#14863](https://github.com/google-gemini/gemini-cli/pull/14863) +- feat(ui): pretty JSON rendering tool outputs by @medic-code in + [#9767](https://github.com/google-gemini/gemini-cli/pull/9767) +- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in + [#17634](https://github.com/google-gemini/gemini-cli/pull/17634) +- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in + [#14479](https://github.com/google-gemini/gemini-cli/pull/14479) +- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in + [#17293](https://github.com/google-gemini/gemini-cli/pull/17293) +- paste transform followup by @jacob314 in + [#17624](https://github.com/google-gemini/gemini-cli/pull/17624) +- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in + [#14997](https://github.com/google-gemini/gemini-cli/pull/14997) +- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon + in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663) +- feat(cli): add oncall command for issue triage by @sehoon38 in + [#17661](https://github.com/google-gemini/gemini-cli/pull/17661) +- Fix sidebar issue for extensions link by @chrstnb in + [#17668](https://github.com/google-gemini/gemini-cli/pull/17668) +- Change formatting to prevent UI redressing attacks by @scidomino in + [#17611](https://github.com/google-gemini/gemini-cli/pull/17611) +- Fix cluster of bugs in the settings dialog. by @jacob314 in + [#17628](https://github.com/google-gemini/gemini-cli/pull/17628) +- Update sidebar to resolve site build issues by @chrstnb in + [#17674](https://github.com/google-gemini/gemini-cli/pull/17674) +- fix(admin): fix a few bugs related to admin controls by @skeshive in + [#17590](https://github.com/google-gemini/gemini-cli/pull/17590) +- revert bad changes to tests by @scidomino in + [#17673](https://github.com/google-gemini/gemini-cli/pull/17673) +- feat(cli): show candidate issue state reason and duplicate status in triage by + @sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676) +- Fix missing slash commands when Gemini CLI is in a project with a package.json + that doesn't follow semantic versioning by @Adib234 in + [#17561](https://github.com/google-gemini/gemini-cli/pull/17561) +- feat(core): Model family-specific system prompts by @joshualitt in + [#17614](https://github.com/google-gemini/gemini-cli/pull/17614) +- Sub-agents documentation. by @gundermanc in + [#16639](https://github.com/google-gemini/gemini-cli/pull/16639) +- feat: wire up AskUserTool with dialog by @jackwotherspoon in + [#17411](https://github.com/google-gemini/gemini-cli/pull/17411) +- Load extension settings for hooks, agents, skills by @chrstnb in + [#17245](https://github.com/google-gemini/gemini-cli/pull/17245) +- Fix issue where Gemini CLI can make changes when simply asked a question by + @gundermanc in + [#17608](https://github.com/google-gemini/gemini-cli/pull/17608) +- Update docs-writer skill for editing and add style guide for reference. by + @g-samroberts in + [#17669](https://github.com/google-gemini/gemini-cli/pull/17669) +- fix(ux): have user message display a short path for pasted images by @devr0306 + in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613) +- feat(plan): enable AskUser tool in Plan mode for clarifying questions by + @jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694) +- GEMINI.md polish by @jacob314 in + [#17680](https://github.com/google-gemini/gemini-cli/pull/17680) +- refactor(core): centralize path validation and allow temp dir access for tools + by @NTaylorMullen in + [#17185](https://github.com/google-gemini/gemini-cli/pull/17185) +- feat(skills): promote Agent Skills to stable by @abhipatel12 in + [#17693](https://github.com/google-gemini/gemini-cli/pull/17693) +- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in + [#17414](https://github.com/google-gemini/gemini-cli/pull/17414) +- docs: Add Experimental Remote Agent Docs by @adamfweidman in + [#17697](https://github.com/google-gemini/gemini-cli/pull/17697) +- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in + [#17712](https://github.com/google-gemini/gemini-cli/pull/17712) +- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer + mode. by @jacob314 in + [#17640](https://github.com/google-gemini/gemini-cli/pull/17640) +- feat(skills): promote skills settings to stable by @abhipatel12 in + [#17713](https://github.com/google-gemini/gemini-cli/pull/17713) +- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in + [#17701](https://github.com/google-gemini/gemini-cli/pull/17701) +- feat(ui): add terminal cursor support by @jacob314 in + [#17711](https://github.com/google-gemini/gemini-cli/pull/17711) +- docs(skills): remove experimental labels and update tutorials by @abhipatel12 + in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714) +- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in + [#17660](https://github.com/google-gemini/gemini-cli/pull/17660) +- Add support for an additional exclusion file besides .gitignore and + .geminiignore by @alisa-alisa in + [#16487](https://github.com/google-gemini/gemini-cli/pull/16487) +- feat: add review-frontend-and-fix command by @galz10 in + [#17707](https://github.com/google-gemini/gemini-cli/pull/17707) -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.21.0-preview.6...v0.22.0-preview.0 +**Full changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.26.0-preview.5...v0.27.0-preview.0 diff --git a/docs/changelogs/releases.md b/docs/changelogs/releases.md deleted file mode 100644 index 9793f75c7d..0000000000 --- a/docs/changelogs/releases.md +++ /dev/null @@ -1,896 +0,0 @@ -# Gemini CLI changelog - -Gemini CLI has three major release channels: nightly, preview, and stable. For -most users, we recommend the stable release. - -On this page, you can find information regarding the current releases and -highlights from each release. - -For the full changelog, including nightly releases, refer to -[Releases - google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli/releases) -on GitHub. - -## Current Releases - -| Release channel | Notes | -| :------------------------------------------ | :---------------------------------------------- | -| Nightly | Nightly release with the most recent changes. | -| [Preview](#release-v0220-preview-0-preview) | Experimental features ready for early feedback. | -| [Latest](#release-v0210---v0211-latest) | Stable, recommended for general use. | - -## Release v0.21.0 - v0.21.1 (Latest) - -### Highlights - -- **โšก๏ธโšก๏ธโšก๏ธ Gemini 3 Flash + Gemini CLI:** If you are a paid user, you can now - enable Gemini 3 Pro and Gemini 3 Flash. Go to `/settings` and set **Preview - Features** to `true` to enable Gemini 3. For more information: - [Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/). - -### What's Changed - -- refactor(stdio): always patch stdout and use createWorkingStdio for clean - output by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14159 -- chore(release): bump version to 0.21.0-nightly.20251202.2d935b379 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14409 -- implement fuzzy search inside settings by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/13864 -- feat: enable message bus integration by default by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14329 -- docs: Recommend using --debug intead of --verbose for CLI debugging by @bbiggs - in https://github.com/google-gemini/gemini-cli/pull/14334 -- feat: consolidate remote MCP servers to use `url` in config by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/13762 -- Restrict integration tests tools by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14403 -- track github repository names in telemetry events by @IamRiddhi in - https://github.com/google-gemini/gemini-cli/pull/13670 -- Allow telemetry exporters to GCP to utilize user's login credentials, if - requested by @mboshernitsan in - https://github.com/google-gemini/gemini-cli/pull/13778 -- refactor(editor): use const assertion for editor types with single source of - truth by @amsminn in https://github.com/google-gemini/gemini-cli/pull/8604 -- fix(security): Fix npm audit vulnerabilities in glob and body-parser by - @afarber in https://github.com/google-gemini/gemini-cli/pull/14090 -- Add new enterprise instructions by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/8641 -- feat(hooks): Hook Session Lifecycle & Compression Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14151 -- Avoid triggering refreshStatic unless there really is a banner to display. by - @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14328 -- feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14225 -- fix: Bundle default policies for npx distribution by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14457 -- feat(hooks): Hook System Documentation by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/14307 -- Fix tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14458 -- feat: add scheduled workflow to close stale issues by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14404 -- feat: Support Extension Hooks with Security Warning by @abhipatel12 in - https://github.com/google-gemini/gemini-cli/pull/14460 -- feat: Add enableAgents experimental flag by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14371 -- docs: fix typo 'socus' to 'focus' in todos.md by @Viktor286 in - https://github.com/google-gemini/gemini-cli/pull/14374 -- Markdown export: move the emoji to the end of the line by @mhansen in - https://github.com/google-gemini/gemini-cli/pull/12278 -- fix(acp): prevent unnecessary credential cache clearing on re-authentโ€ฆ by - @h-michael in https://github.com/google-gemini/gemini-cli/pull/9410 -- fix(cli): Fix word navigation for CJK characters by @SandyTao520 in - https://github.com/google-gemini/gemini-cli/pull/14475 -- Remove example extension by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14376 -- Add commands for listing and updating per-extension settings by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/12664 -- chore(tests): remove obsolete test for hierarchical memory by @pareshjoshij in - https://github.com/google-gemini/gemini-cli/pull/13122 -- feat(cli): support /copy in remote sessions using OSC52 by @ismellpillows in - https://github.com/google-gemini/gemini-cli/pull/13471 -- Update setting search UX by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/14451 -- Fix(cli): Improve Homebrew update instruction to specify gemini-cli by - @DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/14502 -- do not toggle the setting item when entering space by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14489 -- fix: improve retry logic for fetch errors and network codes by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/14439 -- remove unused isSearching field by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14509 -- feat(mcp): add `--type` alias for `--transport` flag in gemini mcp add by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14503 -- feat(cli): Move key restore logic to core by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13013 -- feat: add auto-execute on Enter behavior to argumentless MCP prompts by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14510 -- fix(shell): cursor visibility when using interactive mode by @aswinashok44 in - https://github.com/google-gemini/gemini-cli/pull/14095 -- Adding session id as part of json o/p by @MJjainam in - https://github.com/google-gemini/gemini-cli/pull/14504 -- fix(extensions): resolve GitHub API 415 error for source tarballs by - @jpoehnelt in https://github.com/google-gemini/gemini-cli/pull/13319 -- fix(client): Correctly latch hasFailedCompressionAttempt flag by @pareshjoshij - in https://github.com/google-gemini/gemini-cli/pull/13002 -- Disable flaky extension reloading test on linux by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14528 -- Add support for MCP dynamic tool update by `notifications/tools/list_changed` - by @Adib234 in https://github.com/google-gemini/gemini-cli/pull/14375 -- Fix privacy screen for legacy tier users by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14522 -- feat: Exclude maintainer labeled issues from stale issue closer by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14532 -- Grant chained workflows proper permission. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14534 -- Make trigger_e2e manually fireable. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14547 -- Write e2e status to local repo not forked repo by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14549 -- Fixes [API Error: Cannot read properties of undefined (reading 'error')] by - @silviojr in https://github.com/google-gemini/gemini-cli/pull/14553 -- Trigger chained e2e tests on all pull requests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14551 -- Fix bug in the shellExecutionService resulting in both truncation and 3X bloat - by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14545 -- Fix issue where we were passing the model content reflecting terminal line - wrapping. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14566 -- chore/release: bump version to 0.21.0-nightly.20251204.3da4fd5f7 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14476 -- feat(sessions): use 1-line generated session summary to describe sessions by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14467 -- Use Robot PAT for chained e2e merge queue skipper by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14585 -- fix(core): improve API response error handling and retry logic by @mattKorwel - in https://github.com/google-gemini/gemini-cli/pull/14563 -- Docs: Model routing clarification by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14373 -- expose previewFeatures flag in a2a by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14550 -- Fix emoji width in debug console. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14593 -- Fully detach autoupgrade process by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14595 -- Docs: Update Gemini 3 on Gemini CLI documentation by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14601 -- Disallow floating promises. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14605 -- chore/release: bump version to 0.21.0-nightly.20251207.025e450ac by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14662 -- feat(modelAvailabilityService): integrate model availability service into - backend logic by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14470 -- Add prompt_id propagation in a2a-server task by @koxkox111 in - https://github.com/google-gemini/gemini-cli/pull/14581 -- Fix: Prevent freezing in non-interactive Gemini CLI when debug mode is enabled - by @parthasaradhie in https://github.com/google-gemini/gemini-cli/pull/14580 -- fix(audio): improve reading of audio files by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/14658 -- Update automated triage workflow to stop assigning priority labels by - @skeshive in https://github.com/google-gemini/gemini-cli/pull/14717 -- set failed status when chained e2e fails by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14725 -- feat(github action) Triage and Label Pull Requests by Size and Compleโ€ฆ by - @DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/5571 -- refactor(telemetry): Improve previous PR that allows telemetry to use the CLI - auth and add testing by @mboshernitsan in - https://github.com/google-gemini/gemini-cli/pull/14589 -- Always set status in chained_e2e workflow by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14730 -- feat: Add OTEL log event `gemini_cli.startup_stats` for startup stats. by - @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/14734 -- feat: auto-execute on slash command completion functions by @jackwotherspoon - in https://github.com/google-gemini/gemini-cli/pull/14584 -- Docs: Proper release notes by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14405 -- Add support for user-scoped extension settings by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/13748 -- refactor(core): Improve environment variable handling in shell execution by - @galz10 in https://github.com/google-gemini/gemini-cli/pull/14742 -- Remove old E2E Workflows by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14749 -- fix: handle missing local extension config and skip hooks when disabled by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14744 -- chore/release: bump version to 0.21.0-nightly.20251209.ec9a8c7a7 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14751 -- feat: Add support for MCP Resources by @MrLesk in - https://github.com/google-gemini/gemini-cli/pull/13178 -- Always set pending status in E2E tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14756 -- fix(lint): upgrade pip and use public pypi for yamllint by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/14746 -- fix: use Gemini API supported image formats for clipboard by @jackwotherspoon - in https://github.com/google-gemini/gemini-cli/pull/14762 -- feat(a2a): Introduce restore command for a2a server by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13015 -- allow final:true to be returned on a2a server edit calls. by @DavidAPierce in - https://github.com/google-gemini/gemini-cli/pull/14747 -- (fix) Automated pr labeller by @DaanVersavel in - https://github.com/google-gemini/gemini-cli/pull/14788 -- Update CODEOWNERS by @kklashtorny1 in - https://github.com/google-gemini/gemini-cli/pull/14830 -- Docs: Fix errors preventing site rebuild. by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/14842 -- chore(deps): bump express from 5.1.0 to 5.2.0 by @dependabot[bot] in - https://github.com/google-gemini/gemini-cli/pull/14325 -- fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch - version v0.21.0-preview.0 and create version 0.21.0-preview.1 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14851 -- fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch - version v0.21.0-preview.1 and create version 0.21.0-preview.2 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14908 -- fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14968 -- fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch - version v0.21.0-preview.3 and create version 0.21.0-preview.4 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15003 -- fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch - version v0.21.0-preview.4 and create version 0.21.0-preview.5 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15015 -- fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch - version v0.21.0-preview.5 and create version 0.21.0-preview.6 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15153 - -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.20.2...v0.21.0 - -## Release v0.22.0-preview-0 (Preview) - -### What's Changed - -- feat(ide): fallback to GEMINI_CLI_IDE_AUTH_TOKEN env var by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14843 -- feat: display quota stats for unused models in /stats by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/14764 -- feat: ensure codebase investigator uses preview model when main agent does by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14412 -- chore: add closing reason to stale bug workflow by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/14861 -- Send the model and CLI version with the user agent by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14865 -- refactor(sessions): move session summary generation to startup by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14691 -- Limit search depth in path corrector by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14869 -- Fix: Correct typo in code comment by @kuishou68 in - https://github.com/google-gemini/gemini-cli/pull/14671 -- feat(core): Plumbing for late resolution of model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/14597 -- feat: attempt more error parsing by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14899 -- Add missing await. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/14910 -- feat(core): Add support for transcript_path in hooks for git-ai/Gemini - extension by @svarlamov in - https://github.com/google-gemini/gemini-cli/pull/14663 -- refactor: implement DelegateToAgentTool with discriminated union by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14769 -- feat: reset availabilityService on /auth by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/14911 -- chore/release: bump version to 0.21.0-nightly.20251211.8c83e1ea9 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14924 -- Fix: Correctly detect MCP tool errors by @kevin-ramdass in - https://github.com/google-gemini/gemini-cli/pull/14937 -- increase labeler timeout by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14922 -- tool(cli): tweak the frontend tool to be aware of more core files from the cli - by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14962 -- feat(cli): polish cached token stats and simplify stats display when quota is - present. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14961 -- feat(settings-validation): add validation for settings schema by @lifefloating - in https://github.com/google-gemini/gemini-cli/pull/12929 -- fix(ide): Update IDE extension to write auth token in env var by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14999 -- Revert "chore(deps): bump express from 5.1.0 to 5.2.0" by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/14998 -- feat(a2a): Introduce /init command for a2a server by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/13419 -- feat: support multi-file drag and drop of images by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/14832 -- fix(policy): allow codebase_investigator by default in read-only policy by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15000 -- refactor(ide ext): Update port file name + switch to 1-based index for - characters + remove truncation text by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/10501 -- fix(vscode-ide-companion): correct license generation for workspace - dependencies by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/15004 -- fix: temp fix for subagent invocation until subagent delegation is merged to - stable by @abhipatel12 in - https://github.com/google-gemini/gemini-cli/pull/15007 -- test: update ide detection tests to make them more robust when run in an ide - by @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/15008 -- Remove flex from stats display. See snapshots for diffs. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/14983 -- Add license field into package.json by @jb-perez in - https://github.com/google-gemini/gemini-cli/pull/14473 -- feat: Persistent "Always Allow" policies with granular shell & MCP support by - @allenhutchison in https://github.com/google-gemini/gemini-cli/pull/14737 -- chore/release: bump version to 0.21.0-nightly.20251212.54de67536 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14969 -- fix(core): commandPrefix word boundary and compound command safety by - @allenhutchison in https://github.com/google-gemini/gemini-cli/pull/15006 -- chore(docs): add 'Maintainers only' label info to CONTRIBUTING.md by @jacob314 - in https://github.com/google-gemini/gemini-cli/pull/14914 -- Refresh hooks when refreshing extensions. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14918 -- Add clarity to error messages by @gsehgal in - https://github.com/google-gemini/gemini-cli/pull/14879 -- chore : remove a redundant tip by @JayadityaGit in - https://github.com/google-gemini/gemini-cli/pull/14947 -- chore/release: bump version to 0.21.0-nightly.20251213.977248e09 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15029 -- Disallow redundant typecasts. by @gundermanc in - https://github.com/google-gemini/gemini-cli/pull/15030 -- fix(auth): prioritize GEMINI_API_KEY env var and skip unnecessary keyโ€ฆ by - @galz10 in https://github.com/google-gemini/gemini-cli/pull/14745 -- fix: use zod for safety check result validation by @allenhutchison in - https://github.com/google-gemini/gemini-cli/pull/15026 -- update(telemetry): add hashed_extension_name to field to extension events by - @kiranani in https://github.com/google-gemini/gemini-cli/pull/15025 -- fix: similar to policy-engine, throw error in case of requiring tool execution - confirmation for non-interactive mode by @MayV in - https://github.com/google-gemini/gemini-cli/pull/14702 -- Clean up processes in integration tests by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/15102 -- docs: update policy engine getting started and defaults by @NTaylorMullen in - https://github.com/google-gemini/gemini-cli/pull/15105 -- Fix tool output fragmentation by encapsulating content in functionResponse by - @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/13082 -- Simplify method signature. by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/15114 -- Show raw input token counts in json output. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/15021 -- fix: Mark A2A requests as interactive by @MayV in - https://github.com/google-gemini/gemini-cli/pull/15108 -- use previewFeatures to determine which pro model to use for A2A by @sehoon38 - in https://github.com/google-gemini/gemini-cli/pull/15131 -- refactor(cli): fix settings merging so that settings using the new json format - take priority over ones using the old format by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/15116 - -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.21.0-preview.6...v0.22.0-preview.0 - -## Release v0.20.0 - v0.20.2 - -### What's Changed - -- Update error codes when process exiting the gemini cli by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13728 -- chore(release): bump version to 0.20.0-nightly.20251126.d2a6cff4d by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13835 -- feat(core): Improve request token calculation accuracy by @SandyTao520 in - https://github.com/google-gemini/gemini-cli/pull/13824 -- Changes in system instruction to adapt to gemini 3.0 to ensure that the CLI - explains its actions before calling tools by @silviojr in - https://github.com/google-gemini/gemini-cli/pull/13810 -- feat(hooks): Hook Tool Execution Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9108 -- Add support for MCP server instructions behind config option by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/13432 -- Update System Instructions for interactive vs non-interactive mode. by - @aishaneeshah in https://github.com/google-gemini/gemini-cli/pull/12315 -- Add consent flag to Link command by @kevinjwang1 in - https://github.com/google-gemini/gemini-cli/pull/13832 -- feat(mcp): Inject GoogleCredentialProvider headers in McpClient by - @sai-sunder-s in https://github.com/google-gemini/gemini-cli/pull/13783 -- feat(core): implement towards policy-driven model fallback mechanism by - @adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13781 -- feat(core): Add configurable inactivity timeout for shell commands by @galz10 - in https://github.com/google-gemini/gemini-cli/pull/13531 -- fix(auth): improve API key authentication flow by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/13829 -- feat(hooks): Hook LLM Request/Response Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9110 -- feat(ui): Show waiting MCP servers in ConfigInitDisplay by @werdnum in - https://github.com/google-gemini/gemini-cli/pull/13721 -- Add usage limit remaining in /stats by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/13843 -- feat(shell): Standardize pager to 'cat' for shell execution by model by - @galz10 in https://github.com/google-gemini/gemini-cli/pull/13878 -- chore/release: bump version to 0.20.0-nightly.20251127.5bed97064 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13877 -- Revert to default LICENSE (Revert #13449) by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13876 -- update(telemetry): OTel API response event with finish reasons by @kiranani in - https://github.com/google-gemini/gemini-cli/pull/13849 -- feat(hooks): Hooks Comprehensive Integration Testing by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9112 -- chore: fix session browser test and skip hook system tests by @jackwotherspoon - in https://github.com/google-gemini/gemini-cli/pull/14099 -- feat(telemetry): Add Semantic logging for to ApiRequestEvents by @kiranani in - https://github.com/google-gemini/gemini-cli/pull/13912 -- test: Add verification for $schema property in settings schema by - @maryamariyan in https://github.com/google-gemini/gemini-cli/pull/13497 -- Fixes `/clear` command to preserve input history for up-arrow navigation while - still clearing the context window and screen by @korade-krushna in - https://github.com/google-gemini/gemini-cli/pull/14182 -- fix(core): handle EPIPE error in hook runner when writing to stdin by - @SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/14231 -- fix: Exclude web-fetch tool from executing in default non-interactive mode to - avoid CLI hang. by @MayV in - https://github.com/google-gemini/gemini-cli/pull/14244 -- Always use MCP server instructions by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14297 -- feat: auto-execute simple slash commands on Enter by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/13985 -- chore/release: bump version to 0.20.0-nightly.20251201.2fe609cb6 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14304 -- feat: Add startup profiler to measure and record application initialization - phases. by @kevin-ramdass in - https://github.com/google-gemini/gemini-cli/pull/13638 -- bug(core): Avoid stateful tool use in `executor`. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/14305 -- feat(themes): add built-in holiday theme ๐ŸŽ by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/14301 -- Updated ToC on docs intro; updated title casing to match Google style by - @pcoet in https://github.com/google-gemini/gemini-cli/pull/13717 -- feat(a2a): Urgent fix - Process modelInfo agent message by @cocosheng-g in - https://github.com/google-gemini/gemini-cli/pull/14315 -- feat(core): enhance availability routing with wrapped fallback and - single-model policies by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/13874 -- chore(logging): log the problematic event for #12122 by @briandealwis in - https://github.com/google-gemini/gemini-cli/pull/14092 -- fix: remove invalid type key in bug_report.yml by @fancive in - https://github.com/google-gemini/gemini-cli/pull/13576 -- update screenshot by @Transient-Onlooker in - https://github.com/google-gemini/gemini-cli/pull/13976 -- docs: Fix grammar error in Release Cadence (Nightly section) by @JuanCS-Dev in - https://github.com/google-gemini/gemini-cli/pull/13866 -- fix(async): prevent missed async errors from bypassing catch handlers by - @amsminn in https://github.com/google-gemini/gemini-cli/pull/13714 -- fix(zed-integration): remove extra field from acp auth request by - @marcocondrache in https://github.com/google-gemini/gemini-cli/pull/13646 -- feat(cli): Documentation for model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/12967 -- fix(ui): misaligned markdown table rendering by @dumbbellcode in - https://github.com/google-gemini/gemini-cli/pull/8336 -- docs: Update 4 files by @g-samroberts in - https://github.com/google-gemini/gemini-cli/pull/13628 -- fix: Conditionally add set -eEuo pipefail in setup-github command by @Smetalo - in https://github.com/google-gemini/gemini-cli/pull/8550 -- fix(cli): fix issue updating a component while rendering a different component - by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14319 -- Increase flakey test timeout by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14377 -- Remove references to deleted kind/bug label by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14383 -- Don't fail test if we can't cleanup by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/14389 -- feat(core): Implement JIT context manager and setting by @SandyTao520 in - https://github.com/google-gemini/gemini-cli/pull/14324 -- Use polling for extensions-reload integration test by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/14391 -- Add docs directive to GEMINI.md by @g-samroberts in - https://github.com/google-gemini/gemini-cli/pull/14327 -- Hide sessions that don't have user messages by @bl-ue in - https://github.com/google-gemini/gemini-cli/pull/13994 -- chore(ci): mark GitHub release as pre-release if not on "latest" npm channel - by @ljxfstorm in https://github.com/google-gemini/gemini-cli/pull/7386 -- fix(patch): cherry-pick d284fa6 to release/v0.20.0-preview.0-pr-14545 - [CONFLICTS] by @gemini-cli-robot in - https://github.com/google-gemini/gemini-cli/pull/14559 -- fix(patch): cherry-pick 828afe1 to release/v0.20.0-preview.1-pr-14159 to patch - version v0.20.0-preview.1 and create version 0.20.0-preview.2 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14733 -- fix(patch): cherry-pick 171103a to release/v0.20.0-preview.2-pr-14742 to patch - version v0.20.0-preview.2 and create version 0.20.0-preview.5 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14752 - -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.19.4...v0.20.0 - -## Release v0.19.0 - v0.19.4 - -## Highlights - -- **Zed integration:** Users can now leverage Gemini 3 within the Zed - integration after enabling "Preview Features" in their CLIโ€™s `/settings`. -- **Interactive shell:** - - **Click-to-Focus:** Go to `/settings` and enable **Use Alternate Buffer** - When "Use Alternate Buffer" setting is enabled users can click within the - embedded shell output to focus it for input. - - **Loading phrase:** Clearly indicates when the interactive shell is awaiting - user input. ([vid](https://imgur.com/a/kjK8bUK) - [pr](https://github.com/google-gemini/gemini-cli/pull/12535) by - [@jackwotherspoon](https://github.com/jackwotherspoon)) - -### What's Changed - -- Use lenient MCP output schema validator by @cornmander in - https://github.com/google-gemini/gemini-cli/pull/13521 -- Update persistence state to track counts of messages instead of times banner - has been displayed by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13428 -- update docs for http proxy by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13538 -- move stdio by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13528 -- chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13540 -- Skip pre-commit hooks for shadow repo (#13331) by @vishvananda in - https://github.com/google-gemini/gemini-cli/pull/13488 -- fix(ui): Correct mouse click cursor positioning for wide characters by - @SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/13537 -- fix(core): correct bash @P prompt transformation detection by @pyrytakala in - https://github.com/google-gemini/gemini-cli/pull/13544 -- Optimize and improve test coverage for cli/src/config by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13485 -- Improve code coverage for cli/src/ui/privacy package by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13493 -- docs: fix typos in source code and documentation by @fancive in - https://github.com/google-gemini/gemini-cli/pull/13577 -- Improved code coverage for cli/src/zed-integration by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13570 -- feat(ui): build interactive session browser component by @bl-ue in - https://github.com/google-gemini/gemini-cli/pull/13351 -- Fix multiple bugs with auth flow including using the implemented but unused - restart support. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13565 -- feat(core): add modelAvailabilityService for managing and tracking model - health by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/13426 -- docs: fix grammar typo "a MCP" to "an MCP" by @noahacgn in - https://github.com/google-gemini/gemini-cli/pull/13595 -- feat: custom loading phrase when interactive shell requires input by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/12535 -- docs: Update uninstall command to reflect multiple extension support by - @JayadityaGit in https://github.com/google-gemini/gemini-cli/pull/13582 -- bug(core): Ensure we use thinking budget on fallback to 2.5 by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13596 -- Remove useModelRouter experimental flag by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13593 -- feat(docs): Ensure multiline JS objects are rendered properly. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13535 -- Fix exp id logging by @owenofbrien in - https://github.com/google-gemini/gemini-cli/pull/13430 -- Moved client id logging into createBasicLogEvent by @owenofbrien in - https://github.com/google-gemini/gemini-cli/pull/13607 -- Restore bracketed paste mode after external editor exit by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13606 -- feat(core): Add support for custom aliases for model configs. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13546 -- feat(core): Add `BaseLlmClient.generateContent`. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13591 -- Turn off alternate buffer mode by default. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13623 -- fix(cli): Prevent stdout/stderr patching for extension commands by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/13600 -- Improve test coverage for cli/src/ui/components by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13598 -- Update ink version to 6.4.6 by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13631 -- chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13637 -- chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13675 -- chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13713 -- fix(core): Fix context window overflow warning for PDF files by @kkitase in - https://github.com/google-gemini/gemini-cli/pull/13548 -- feat :rephrasing the extension logging messages to run the explore command - when there are no extensions installed by @JayadityaGit in - https://github.com/google-gemini/gemini-cli/pull/13740 -- Improve code coverage for cli package by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13724 -- Add session subtask in /stats command by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13750 -- feat(core): Migrate chatCompressionService to model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/12863 -- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9082 -- fix: (some minor improvements to configs and getPackageJson return behaviour) - by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in - https://github.com/google-gemini/gemini-cli/pull/12510 -- feat(hooks): Hook Event Handling by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9097 -- feat(hooks): Hook Agent Lifecycle Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9105 -- feat(core): Land bool for alternate system prompt. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13764 -- bug(core): Add default chat compression config. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13766 -- feat(model-availability): introduce ModelPolicy and PolicyCatalog by - @adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13751 -- feat(hooks): Hook System Orchestration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9102 -- feat(config): add isModelAvailabilityServiceEnabled setting by @adamfweidman - in https://github.com/google-gemini/gemini-cli/pull/13777 -- chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13782 -- chore: remove console.error by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/13779 -- fix: Add $schema property to settings.schema.json by @sacrosanctic in - https://github.com/google-gemini/gemini-cli/pull/12763 -- fix(cli): allow non-GitHub SCP-styled URLs for extension installation by @m0ps - in https://github.com/google-gemini/gemini-cli/pull/13800 -- fix(resume): allow passing a prompt via stdin while resuming using --resume by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13520 -- feat(sessions): add /resume slash command to open the session browser by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13621 -- docs(sessions): add documentation for chat recording and session management by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13667 -- Fix TypeError: "URL.parse is not a function" for Node.js < v22 by @macarronesc - in https://github.com/google-gemini/gemini-cli/pull/13698 -- fallback to flash for TerminalQuota errors by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/13791 -- Update Code Wiki README badge by @PatoBeltran in - https://github.com/google-gemini/gemini-cli/pull/13768 -- Add Databricks auth support and custom header option to gemini cli by - @AarushiShah in https://github.com/google-gemini/gemini-cli/pull/11893 -- Update dependency for modelcontextprotocol/sdk to 1.23.0 by @bbiggs in - https://github.com/google-gemini/gemini-cli/pull/13827 -- fix(patch): cherry-pick 576fda1 to release/v0.19.0-preview.0-pr-14099 - [CONFLICTS] by @gemini-cli-robot in - https://github.com/google-gemini/gemini-cli/pull/14402 - -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0 - -## Release v0.19.0-preview.0 - -### What's Changed - -- Use lenient MCP output schema validator by @cornmander in - https://github.com/google-gemini/gemini-cli/pull/13521 -- Update persistence state to track counts of messages instead of times banner - has been displayed by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13428 -- update docs for http proxy by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13538 -- move stdio by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13528 -- chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13540 -- Skip pre-commit hooks for shadow repo (#13331) by @vishvananda in - https://github.com/google-gemini/gemini-cli/pull/13488 -- fix(ui): Correct mouse click cursor positioning for wide characters by - @SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/13537 -- fix(core): correct bash @P prompt transformation detection by @pyrytakala in - https://github.com/google-gemini/gemini-cli/pull/13544 -- Optimize and improve test coverage for cli/src/config by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13485 -- Improve code coverage for cli/src/ui/privacy package by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13493 -- docs: fix typos in source code and documentation by @fancive in - https://github.com/google-gemini/gemini-cli/pull/13577 -- Improved code coverage for cli/src/zed-integration by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13570 -- feat(ui): build interactive session browser component by @bl-ue in - https://github.com/google-gemini/gemini-cli/pull/13351 -- Fix multiple bugs with auth flow including using the implemented but unused - restart support. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13565 -- feat(core): add modelAvailabilityService for managing and tracking model - health by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/13426 -- docs: fix grammar typo "a MCP" to "an MCP" by @noahacgn in - https://github.com/google-gemini/gemini-cli/pull/13595 -- feat: custom loading phrase when interactive shell requires input by - @jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/12535 -- docs: Update uninstall command to reflect multiple extension support by - @JayadityaGit in https://github.com/google-gemini/gemini-cli/pull/13582 -- bug(core): Ensure we use thinking budget on fallback to 2.5 by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13596 -- Remove useModelRouter experimental flag by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13593 -- feat(docs): Ensure multiline JS objects are rendered properly. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13535 -- Fix exp id logging by @owenofbrien in - https://github.com/google-gemini/gemini-cli/pull/13430 -- Moved client id logging into createBasicLogEvent by @owenofbrien in - https://github.com/google-gemini/gemini-cli/pull/13607 -- Restore bracketed paste mode after external editor exit by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13606 -- feat(core): Add support for custom aliases for model configs. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13546 -- feat(core): Add `BaseLlmClient.generateContent`. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13591 -- Turn off alternate buffer mode by default. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13623 -- fix(cli): Prevent stdout/stderr patching for extension commands by @chrstnb in - https://github.com/google-gemini/gemini-cli/pull/13600 -- Improve test coverage for cli/src/ui/components by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13598 -- Update ink version to 6.4.6 by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13631 -- chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13637 -- chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13675 -- chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13713 -- fix(core): Fix context window overflow warning for PDF files by @kkitase in - https://github.com/google-gemini/gemini-cli/pull/13548 -- feat :rephrasing the extension logging messages to run the explore command - when there are no extensions installed by @JayadityaGit in - https://github.com/google-gemini/gemini-cli/pull/13740 -- Improve code coverage for cli package by @megha1188 in - https://github.com/google-gemini/gemini-cli/pull/13724 -- Add session subtask in /stats command by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13750 -- feat(core): Migrate chatCompressionService to model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/12863 -- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9082 -- fix: (some minor improvements to configs and getPackageJson return behaviour) - by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in - https://github.com/google-gemini/gemini-cli/pull/12510 -- feat(hooks): Hook Event Handling by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9097 -- feat(hooks): Hook Agent Lifecycle Integration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9105 -- feat(core): Land bool for alternate system prompt. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13764 -- bug(core): Add default chat compression config. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13766 -- feat(model-availability): introduce ModelPolicy and PolicyCatalog by - @adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13751 -- feat(hooks): Hook System Orchestration by @Edilmo in - https://github.com/google-gemini/gemini-cli/pull/9102 -- feat(config): add isModelAvailabilityServiceEnabled setting by @adamfweidman - in https://github.com/google-gemini/gemini-cli/pull/13777 -- chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13782 -- chore: remove console.error by @adamfweidman in - https://github.com/google-gemini/gemini-cli/pull/13779 -- fix: Add $schema property to settings.schema.json by @sacrosanctic in - https://github.com/google-gemini/gemini-cli/pull/12763 -- fix(cli): allow non-GitHub SCP-styled URLs for extension installation by @m0ps - in https://github.com/google-gemini/gemini-cli/pull/13800 -- fix(resume): allow passing a prompt via stdin while resuming using --resume by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13520 -- feat(sessions): add /resume slash command to open the session browser by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13621 -- docs(sessions): add documentation for chat recording and session management by - @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13667 -- Fix TypeError: "URL.parse is not a function" for Node.js < v22 by @macarronesc - in https://github.com/google-gemini/gemini-cli/pull/13698 -- fallback to flash for TerminalQuota errors by @sehoon38 in - https://github.com/google-gemini/gemini-cli/pull/13791 -- Update Code Wiki README badge by @PatoBeltran in - https://github.com/google-gemini/gemini-cli/pull/13768 -- Add Databricks auth support and custom header option to gemini cli by - @AarushiShah in https://github.com/google-gemini/gemini-cli/pull/11893 -- Update dependency for modelcontextprotocol/sdk to 1.23.0 by @bbiggs in - https://github.com/google-gemini/gemini-cli/pull/13827 - -**Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.18.0-preview.4...v0.19.0-preview.0 - -## Release v0.18.0 - v0.18.4 - -### Highlights - -- **Experimental permission improvements**: We're experimenting with a new - policy engine in Gemini CLI, letting users and administrators create - fine-grained policies for tool calls. This setting is currently behind a flag. - See our [policy engine documentation](../core/policy-engine.md) to learn how - to use this feature. -- **Gemini 3 support rolled out for some users**: Some users can now enable - Gemini 3 by using the `/settings` flag and toggling **Preview Features**. See - our [Gemini 3 on Gemini CLI documentation](../get-started/gemini-3.md) to find - out more about using Gemini 3. -- **Updated UI rollback:** We've temporarily rolled back a previous UI update, - which enabled embedded scrolling and mouse support. This can be re-enabled by - using the `/settings` command and setting **Use Alternate Screen Buffer** to - `true`. -- **Display your model in your chat history**: You can now go use `/settings` - and turn on **Show Model in Chat** to display the model in your chat history. -- **Uninstall multiple extensions**: You can uninstall multiple extensions with - a single command: `gemini extensions uninstall`. - -![Uninstalling Gemini extensions with a single command](https://i.imgur.com/pi7nEBI.png) - -### What's changed - -- Remove obsolete reference to "help wanted" label in CONTRIBUTING.md by - @aswinashok44 in https://github.com/google-gemini/gemini-cli/pull/13291 -- chore(release): v0.18.0-nightly.20251118.86828bb56 by @skeshive in - https://github.com/google-gemini/gemini-cli/pull/13309 -- Docs: Access clarification. by @jkcinouye in - https://github.com/google-gemini/gemini-cli/pull/13304 -- Fix links in Gemini 3 Pro documentation by @gmackall in - https://github.com/google-gemini/gemini-cli/pull/13312 -- Improve keyboard code parsing by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13307 -- fix(core): Ensure `read_many_files` tool is available to zed. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13338 -- Support 3-parameter modifyOtherKeys sequences by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13342 -- Improve pty resize error handling for Windows by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/13353 -- fix(ui): Clear input prompt on Escape key press by @SandyTao520 in - https://github.com/google-gemini/gemini-cli/pull/13335 -- bug(ui) showLineNumbers had the wrong default value. by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13356 -- fix(cli): fix crash on startup in NO_COLOR mode (#13343) due to unguaโ€ฆ by - @avilladsen in https://github.com/google-gemini/gemini-cli/pull/13352 -- fix: allow MCP prompts with spaces in name by @jackwotherspoon in - https://github.com/google-gemini/gemini-cli/pull/12910 -- Refactor createTransport to duplicate less code by @davidmcwherter in - https://github.com/google-gemini/gemini-cli/pull/13010 -- Followup from #10719 by @bl-ue in - https://github.com/google-gemini/gemini-cli/pull/13243 -- Capturing github action workflow name if present and send it to clearcut by - @MJjainam in https://github.com/google-gemini/gemini-cli/pull/13132 -- feat(sessions): record interactive-only errors and warnings to chat recording - JSON files by @bl-ue in https://github.com/google-gemini/gemini-cli/pull/13300 -- fix(zed-integration): Correctly handle cancellation errors by @benbrandt in - https://github.com/google-gemini/gemini-cli/pull/13399 -- docs: Add Code Wiki link to README by @holtskinner in - https://github.com/google-gemini/gemini-cli/pull/13289 -- Restore keyboard mode when exiting the editor by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13350 -- feat(core, cli): Bump genai version to 1.30.0 by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13435 -- [cli-ui] Keep header ASCII art colored on non-gradient terminals (#13373) by - @bniladridas in https://github.com/google-gemini/gemini-cli/pull/13374 -- Fix Copyright line in LICENSE by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13449 -- Fix typo in write_todos methodology instructions by @Smetalo in - https://github.com/google-gemini/gemini-cli/pull/13411 -- feat: update thinking mode support to exclude gemini-2.0 models and simplify - logic. by @kevin-ramdass in - https://github.com/google-gemini/gemini-cli/pull/13454 -- remove unneeded log by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13456 -- feat: add click-to-focus support for interactive shell by @galz10 in - https://github.com/google-gemini/gemini-cli/pull/13341 -- Add User email detail to about box by @ptone in - https://github.com/google-gemini/gemini-cli/pull/13459 -- feat(core): Wire up chat code path for model configs. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/12850 -- chore/release: bump version to 0.18.0-nightly.20251120.2231497b1 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13476 -- feat(core): Fix bug with incorrect model overriding. by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13477 -- Use synchronous writes when detecting keyboard modes by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13478 -- fix(cli): prevent race condition when restoring prompt after context overflow - by @SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/13473 -- Revert "feat(core): Fix bug with incorrect model overriding." by @adamfweidman - in https://github.com/google-gemini/gemini-cli/pull/13483 -- Fix: Update system instruction when GEMINI.md memory is loaded or refreshed by - @lifefloating in https://github.com/google-gemini/gemini-cli/pull/12136 -- fix(zed-integration): Ensure that the zed integration is classified as - interactive by @benbrandt in - https://github.com/google-gemini/gemini-cli/pull/13394 -- Copy commands as part of setup-github by @gsehgal in - https://github.com/google-gemini/gemini-cli/pull/13464 -- Update banner design by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13420 -- Protect stdout and stderr so JavaScript code can't accidentally write to - stdout corrupting ink rendering by @jacob314 in - https://github.com/google-gemini/gemini-cli/pull/13247 -- Enable switching preview features on/off without restart by @Adib234 in - https://github.com/google-gemini/gemini-cli/pull/13515 -- feat(core): Use thinking level for Gemini 3 by @joshualitt in - https://github.com/google-gemini/gemini-cli/pull/13445 -- Change default compress threshold to 0.5 for api key users by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13517 -- remove duplicated mouse code by @scidomino in - https://github.com/google-gemini/gemini-cli/pull/13525 -- feat(zed-integration): Use default model routing for Zed integration by - @benbrandt in https://github.com/google-gemini/gemini-cli/pull/13398 -- feat(core): Incorporate Gemini 3 into model config hierarchy. by @joshualitt - in https://github.com/google-gemini/gemini-cli/pull/13447 -- fix(patch): cherry-pick 5e218a5 to release/v0.18.0-preview.0-pr-13623 to patch - version v0.18.0-preview.0 and create version 0.18.0-preview.1 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13626 -- fix(patch): cherry-pick d351f07 to release/v0.18.0-preview.1-pr-12535 to patch - version v0.18.0-preview.1 and create version 0.18.0-preview.2 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13813 -- fix(patch): cherry-pick 3e50be1 to release/v0.18.0-preview.2-pr-13428 to patch - version v0.18.0-preview.2 and create version 0.18.0-preview.3 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13821 -- fix(patch): cherry-pick d8a3d08 to release/v0.18.0-preview.3-pr-13791 to patch - version v0.18.0-preview.3 and create version 0.18.0-preview.4 by - @gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13826 - - **Full Changelog**: - https://github.com/google-gemini/gemini-cli/compare/v0.17.1...v0.18.0 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md new file mode 100644 index 0000000000..337867c5ed --- /dev/null +++ b/docs/cli/cli-reference.md @@ -0,0 +1,101 @@ +# CLI cheatsheet + +This page provides a reference for commonly used Gemini CLI commands, options, +and parameters. + +## CLI commands + +| Command | Description | Example | +| ---------------------------------- | ---------------------------------- | --------------------------------------------------- | +| `gemini` | Start interactive REPL | `gemini` | +| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | +| `gemini -p "query"` | Query via SDK, then exit | `gemini -p "explain this function"` | +| `cat file \| gemini -p "query"` | Process piped content | `cat logs.txt \| gemini -p "explain"` | +| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | +| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | +| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` | +| `gemini -r "" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` | +| `gemini update` | Update to latest version | `gemini update` | +| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | +| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | + +### Positional arguments + +| Argument | Type | Description | +| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | +| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. | + +## CLI Options + +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--allowed-tools` | - | array | - | Tools that are allowed to run without confirmation (comma-separated or multiple flags) | +| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | +| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | +| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | +| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | +| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | + +## Model selection + +The `--model` (or `-m`) flag allows you to specify which Gemini model to use. +You can use either model aliases (user-friendly names) or concrete model names. + +### Model aliases + +These are convenient shortcuts that map to specific models: + +| Alias | Resolves To | Description | +| ------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `auto` | `gemini-2.5-pro` or `gemini-3-pro-preview` | **Default.** Resolves to the preview model if preview features are enabled, otherwise resolves to the standard pro model. | +| `pro` | `gemini-2.5-pro` or `gemini-3-pro-preview` | For complex reasoning tasks. Uses preview model if enabled. | +| `flash` | `gemini-2.5-flash` | Fast, balanced model for most tasks. | +| `flash-lite` | `gemini-2.5-flash-lite` | Fastest model for simple tasks. | + +## Extensions management + +| Command | Description | Example | +| -------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | +| `gemini extensions install ` | Install extension from Git URL or local path | `gemini extensions install https://github.com/user/my-extension` | +| `gemini extensions install --ref ` | Install from specific branch/tag/commit | `gemini extensions install https://github.com/user/my-extension --ref develop` | +| `gemini extensions install --auto-update` | Install with auto-update enabled | `gemini extensions install https://github.com/user/my-extension --auto-update` | +| `gemini extensions uninstall ` | Uninstall one or more extensions | `gemini extensions uninstall my-extension` | +| `gemini extensions list` | List all installed extensions | `gemini extensions list` | +| `gemini extensions update ` | Update a specific extension | `gemini extensions update my-extension` | +| `gemini extensions update --all` | Update all extensions | `gemini extensions update --all` | +| `gemini extensions enable ` | Enable an extension | `gemini extensions enable my-extension` | +| `gemini extensions disable ` | Disable an extension | `gemini extensions disable my-extension` | +| `gemini extensions link ` | Link local extension for development | `gemini extensions link /path/to/extension` | +| `gemini extensions new ` | Create new extension from template | `gemini extensions new ./my-extension` | +| `gemini extensions validate ` | Validate extension structure | `gemini extensions validate ./my-extension` | + +See [Extensions Documentation](../extensions/index.md) for more details. + +## MCP server management + +| Command | Description | Example | +| ------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `gemini mcp add ` | Add stdio-based MCP server | `gemini mcp add github npx -y @modelcontextprotocol/server-github` | +| `gemini mcp add --transport http` | Add HTTP-based MCP server | `gemini mcp add api-server http://localhost:3000 --transport http` | +| `gemini mcp add --env KEY=value` | Add with environment variables | `gemini mcp add slack node server.js --env SLACK_TOKEN=xoxb-xxx` | +| `gemini mcp add --scope user` | Add with user scope | `gemini mcp add db node db-server.js --scope user` | +| `gemini mcp add --include-tools tool1,tool2` | Add with specific tools | `gemini mcp add github npx -y @modelcontextprotocol/server-github --include-tools list_repos,get_pr` | +| `gemini mcp remove ` | Remove an MCP server | `gemini mcp remove github` | +| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` | + +See [MCP Server Integration](../tools/mcp-server.md) for more details. diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 979ca59dfc..fe0198d626 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -10,6 +10,14 @@ Slash commands provide meta-level control over the CLI itself. ### Built-in Commands +- **`/about`** + - **Description:** Show version info. Please share this information when + filing issues. + +- **`/auth`** + - **Description:** Open a dialog that lets you change the authentication + method. + - **`/bug`** - **Description:** File an issue about Gemini CLI. By default, the issue is filed within the GitHub repository for Gemini CLI. The string you enter @@ -22,10 +30,21 @@ Slash commands provide meta-level control over the CLI itself. conversation state interactively, or resuming a previous state from a later session. - **Sub-commands:** - - **`save`** + - **`delete `** + - **Description:** Deletes a saved conversation checkpoint. + - **`list`** + - **Description:** Lists available tags for chat state resumption. + - **Note:** This command only lists chats saved within the current + project. Because chat history is project-scoped, chats saved in other + project directories will not be displayed. + - **`resume `** + - **Description:** Resumes a conversation from a previous save. + - **Note:** You can only resume chats that were saved within the current + project. To resume a chat from a different project, you must run the + Gemini CLI from that project's directory. + - **`save `** - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. - - **Usage:** `/chat save ` - **Details on checkpoint location:** The default locations for saved chat checkpoints are: - Linux/macOS: `~/.gemini/tmp//` @@ -37,25 +56,11 @@ Slash commands provide meta-level control over the CLI itself. conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../cli/checkpointing.md). - - **`resume`** - - **Description:** Resumes a conversation from a previous save. - - **Usage:** `/chat resume ` - - **Note:** You can only resume chats that were saved within the current - project. To resume a chat from a different project, you must run the - Gemini CLI from that project's directory. - - **`list`** - - **Description:** Lists available tags for chat state resumption. - - **Note:** This command only lists chats saved within the current - project. Because chat history is project-scoped, chats saved in other - project directories will not be displayed. - - **`delete`** - - **Description:** Deletes a saved conversation checkpoint. - - **Usage:** `/chat delete ` - - **`share`** + - **`share [filename]`** - **Description** Writes the current conversation to a provided Markdown - or JSON file. - - **Usage** `/chat share file.md` or `/chat share file.json`. If no - filename is provided, then the CLI will generate one. + or JSON file. If no filename is provided, then the CLI will generate + one. + - **Usage** `/chat share file.md` or `/chat share file.json`. - **`/clear`** - **Description:** Clear the terminal screen, including the visible session @@ -73,6 +78,9 @@ Slash commands provide meta-level control over the CLI itself. - **`/copy`** - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. + - **Behavior:** + - Local sessions use system clipboard tools (pbcopy/xclip/clip). + - Remote sessions (SSH/WSL) use OSC 52 and require terminal support. - **Note:** This command requires platform-specific clipboard tools to be installed. - On Linux, it requires `xclip` or `xsel`. You can typically install them @@ -95,6 +103,9 @@ Slash commands provide meta-level control over the CLI itself. `--include-directories`. - **Usage:** `/directory show` +- **`/docs`** + - **Description:** Open the Gemini CLI documentation in your browser. + - **`/editor`** - **Description:** Open a dialog for selecting supported editors. @@ -106,30 +117,65 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Display help information about Gemini CLI, including available commands and their usage. +- **`/hooks`** + - **Description:** Manage hooks, which allow you to intercept and customize + Gemini CLI behavior at specific lifecycle events. + - **Sub-commands:** + - **`disable-all`**: + - **Description:** Disable all enabled hooks. + - **`disable `**: + - **Description:** Disable a hook by name. + - **`enable-all`**: + - **Description:** Enable all disabled hooks. + - **`enable `**: + - **Description:** Enable a hook by name. + - **`list`** (or `show`, `panel`): + - **Description:** Display all registered hooks with their status. + +- **`/ide`** + - **Description:** Manage IDE integration. + - **Sub-commands:** + - **`disable`**: + - **Description:** Disable IDE integration. + - **`enable`**: + - **Description:** Enable IDE integration. + - **`install`**: + - **Description:** Install required IDE companion. + - **`status`**: + - **Description:** Check status of IDE integration. + +- **`/init`** + - **Description:** To help users easily create a `GEMINI.md` file, this + command analyzes the current directory and generates a tailored context + file, making it simpler for them to provide project-specific instructions to + the Gemini agent. + +- **`/introspect`** + - **Description:** Provide debugging information about the current Gemini CLI + session, including the state of loaded sub-agents and active hooks. This + command is primarily for advanced users and developers. + - **`/mcp`** - **Description:** Manage configured Model Context Protocol (MCP) servers. - **Sub-commands:** - - **`list`** or **`ls`**: - - **Description:** List configured MCP servers and tools. This is the - default action if no subcommand is specified. - - **`desc`** - - **Description:** List configured MCP servers and tools with - descriptions. - - **`schema`**: - - **Description:** List configured MCP servers and tools with descriptions - and schemas. - **`auth`**: - **Description:** Authenticate with an OAuth-enabled MCP server. - **Usage:** `/mcp auth ` - **Details:** If `` is provided, it initiates the OAuth flow for that server. If no server name is provided, it lists all configured servers that support OAuth authentication. + - **`desc`** + - **Description:** List configured MCP servers and tools with + descriptions. + - **`list`** or **`ls`**: + - **Description:** List configured MCP servers and tools. This is the + default action if no subcommand is specified. - **`refresh`**: - **Description:** Restarts all MCP servers and re-discovers their available tools. - -- [**`/model`**](./model.md) - - **Description:** Opens a dialog to choose your Gemini model. + - **`schema`**: + - **Description:** List configured MCP servers and tools with descriptions + and schemas. - **`/memory`** - **Description:** Manage the AI's instructional context (hierarchical memory @@ -138,23 +184,40 @@ Slash commands provide meta-level control over the CLI itself. - **`add`**: - **Description:** Adds the following text to the AI's memory. Usage: `/memory add ` - - **`show`**: - - **Description:** Display the full, concatenated content of the current - hierarchical memory that has been loaded from all `GEMINI.md` files. - This lets you inspect the instructional context being provided to the - Gemini model. + - **`list`**: + - **Description:** Lists the paths of the GEMINI.md files in use for + hierarchical memory. - **`refresh`**: - **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content. - - **`list`**: - - **Description:** Lists the paths of the GEMINI.md files in use for - hierarchical memory. + - **`show`**: + - **Description:** Display the full, concatenated content of the current + hierarchical memory that has been loaded from all `GEMINI.md` files. + This lets you inspect the instructional context being provided to the + Gemini model. - **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](../get-started/configuration.md). +- [**`/model`**](./model.md) + - **Description:** Opens a dialog to choose your Gemini model. + +- **`/policies`** + - **Description:** Manage policies. + - **Sub-commands:** + - **`list`**: + - **Description:** List all active policies grouped by mode. + +- **`/privacy`** + - **Description:** Display the Privacy Notice and allow users to select + whether they consent to the collection of their data for service improvement + purposes. + +- **`/quit`** (or **`/exit`**) + - **Description:** Exit Gemini CLI. + - **`/restore`** - **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file @@ -164,18 +227,25 @@ Slash commands provide meta-level control over the CLI itself. - **Note:** Only available if checkpointing is configured via [settings](../get-started/configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. + +- **`/rewind`** + - **Description:** Navigates backward through the conversation history, + allowing you to review past interactions and potentially revert to a + previous state. This feature helps in managing complex or branched + conversations. + - **`/resume`** - **Description:** Browse and resume previous conversation sessions. Opens an interactive session browser where you can search, filter, and select from automatically saved conversations. - **Features:** - - **Session Browser:** Interactive interface showing all saved sessions with - timestamps, message counts, and first user message for context - - **Search:** Use `/` to search through conversation content across all - sessions - - **Sorting:** Sort sessions by date or message count - **Management:** Delete unwanted sessions directly from the browser - **Resume:** Select any session to resume and continue the conversation + - **Search:** Use `/` to search through conversation content across all + sessions + - **Session Browser:** Interactive interface showing all saved sessions with + timestamps, message counts, and first user message for context + - **Sorting:** Sort sessions by date or message count - **Note:** All conversations are automatically saved as you chat - no manual saving required. See [Session Management](../cli/session-management.md) for complete details. @@ -194,6 +264,30 @@ 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. + +- [**`/skills`**](./skills.md) + - **Description:** Manage Agent Skills, which provide on-demand expertise and + specialized workflows. + - **Sub-commands:** + - **`disable `**: + - **Description:** Disable a specific skill by name. + - **Usage:** `/skills disable ` + - **`enable `**: + - **Description:** Enable a specific skill by name. + - **Usage:** `/skills enable ` + - **`list`**: + - **Description:** List all discovered skills and their current status + (enabled/disabled). + - **`reload`**: + - **Description:** Refresh the list of discovered skills from all tiers + (workspace, user, and extensions). + - **`/stats`** - **Description:** Display detailed statistics for the current Gemini CLI session, including token usage, cached token savings (when available), and @@ -201,18 +295,14 @@ Slash commands provide meta-level control over the CLI itself. cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time. +- **`/terminal-setup`** + - **Description:** Configure terminal keybindings for multiline input (VS + Code, Cursor, Windsurf). + - [**`/theme`**](./themes.md) - **Description:** Open a dialog that lets you change the visual theme of Gemini CLI. -- **`/auth`** - - **Description:** Open a dialog that lets you change the authentication - method. - -- **`/about`** - - **Description:** Show version info. Please share this information when - filing issues. - - [**`/tools`**](../tools/index.md) - **Description:** Display a list of tools that are currently available within Gemini CLI. @@ -224,37 +314,23 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. -- **`/privacy`** - - **Description:** Display the Privacy Notice and allow users to select - whether they consent to the collection of their data for service improvement - purposes. - -- **`/quit`** (or **`/exit`**) - - **Description:** Exit Gemini CLI. - - **`/vim`** - **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes. - **Features:** + - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) + - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, + `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` + - **INSERT mode:** Standard text input with escape to return to NORMAL mode - **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line) - - **INSERT mode:** Standard text input with escape to return to NORMAL mode - - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, - `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` - - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) - - **Repeat last command:** Use `.` to repeat the last editing operation - **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions - - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the - footer - -- **`/init`** - - **Description:** To help users easily create a `GEMINI.md` file, this - command analyzes the current directory and generates a tailored context - file, making it simpler for them to provide project-specific instructions to - the Gemini agent. + - **Repeat last command:** Use `.` to repeat the last editing operation + - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the + footer ### Custom commands @@ -267,12 +343,12 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md). These shortcuts apply directly to the input prompt for text manipulation. - **Undo:** - - **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input - prompt. + - **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action + in the input prompt. - **Redo:** - - **Keyboard shortcut:** Press **Ctrl+Shift+Z** to redo the last undone action - in the input prompt. + - **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the + last undone action in the input prompt. ## At commands (`@`) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md deleted file mode 100644 index a03a325afd..0000000000 --- a/docs/cli/configuration.md +++ /dev/null @@ -1,780 +0,0 @@ -# Gemini CLI configuration - -Gemini CLI offers several ways to configure its behavior, including environment -variables, command-line arguments, and settings files. This document outlines -the different configuration methods and available settings. - -## Configuration layers - -Configuration is applied in the following order of precedence (lower numbers are -overridden by higher numbers): - -1. **Default values:** Hardcoded defaults within the application. -2. **User settings file:** Global settings for the current user. -3. **Project settings file:** Project-specific settings. -4. **System settings file:** System-wide settings. -5. **Environment variables:** System-wide or session-specific variables, - potentially loaded from `.env` files. -6. **Command-line arguments:** Values passed when launching the CLI. - -## Settings files - -Gemini CLI uses `settings.json` files for persistent configuration. There are -three locations for these files: - -- **User settings file:** - - **Location:** `~/.gemini/settings.json` (where `~` is your home directory). - - **Scope:** Applies to all Gemini CLI sessions for the current user. -- **Project settings file:** - - **Location:** `.gemini/settings.json` within your project's root directory. - - **Scope:** Applies only when running Gemini CLI from that specific project. - Project settings override user settings. -- **System settings file:** - - **Location:** `/etc/gemini-cli/settings.json` (Linux), - `C:\ProgramData\gemini-cli\settings.json` (Windows) or - `/Library/Application Support/GeminiCli/settings.json` (macOS). The path can - be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment - variable. - - **Scope:** Applies to all Gemini CLI sessions on the system, for all users. - System settings override user and project settings. May be useful for system - administrators at enterprises to have controls over users' Gemini CLI - setups. - -**Note on environment variables in settings:** String values within your -`settings.json` files can reference environment variables using either -`$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically -resolved when the settings are loaded. For example, if you have an environment -variable `MY_API_TOKEN`, you could use it in `settings.json` like this: -`"apiKey": "$MY_API_TOKEN"`. - -### The `.gemini` directory in your project - -In addition to a project settings file, a project's `.gemini` directory can -contain other project-specific files related to Gemini CLI's operation, such as: - -- [Custom sandbox profiles](#sandboxing) (e.g., - `.gemini/sandbox-macos-custom.sb`, `.gemini/sandbox.Dockerfile`). - -### Available settings in `settings.json`: - -- **`contextFileName`** (string or array of strings): - - **Description:** Specifies the filename for context files (e.g., - `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted - filenames. - - **Default:** `GEMINI.md` - - **Example:** `"contextFileName": "AGENTS.md"` - -- **`bugCommand`** (object): - - **Description:** Overrides the default URL for the `/bug` command. - - **Default:** - `"urlTemplate": "https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}"` - - **Properties:** - - **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` - placeholders. - - **Example:** - ```json - "bugCommand": { - "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" - } - ``` - -- **`fileFiltering`** (object): - - **Description:** Controls git-aware file filtering behavior for @ commands - and file discovery tools. - - **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true` - - **Properties:** - - **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns - when discovering files. When set to `true`, git-ignored files (like - `node_modules/`, `dist/`, `.env`) are automatically excluded from @ - commands and file listing operations. - - **`enableRecursiveFileSearch`** (boolean): Whether to enable searching - recursively for filenames under the current tree when completing @ - prefixes in the prompt. - - **Example:** - ```json - "fileFiltering": { - "respectGitIgnore": true, - "enableRecursiveFileSearch": false - } - ``` - -- **`coreTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should - be made available to the model. This can be used to restrict the set of - built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) - for a list of core tools. You can also specify command-specific restrictions - for tools that support it, like the `ShellTool`. For example, - `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to - be executed. - - **Default:** All tools available for use by the Gemini model. - - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. - -- **`excludeTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should - be excluded from the model. A tool listed in both `excludeTools` and - `coreTools` is excluded. You can also specify command-specific restrictions - for tools that support it, like the `ShellTool`. For example, - `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command. - - **Default**: No tools excluded. - - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. - - **Security Note:** Command-specific restrictions in `excludeTools` for - `run_shell_command` are based on simple string matching and can be easily - bypassed. This feature is **not a security mechanism** and should not be - relied upon to safely execute untrusted code. It is recommended to use - `coreTools` to explicitly select commands that can be executed. - -- **`allowMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that - should be made available to the model. This can be used to restrict the set - of MCP servers to connect to. Note that this will be ignored if - `--allowed-mcp-server-names` is set. - - **Default:** All MCP servers are available for use by the Gemini model. - - **Example:** `"allowMCPServers": ["myPythonServer"]`. - - **Security Note:** This uses simple string matching on MCP server names, - which can be modified. If you're a system administrator looking to prevent - users from bypassing this, consider configuring the `mcpServers` at the - system settings level such that the user will not be able to configure any - MCP servers of their own. This should not be used as an airtight security - mechanism. - -- **`excludeMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that - should be excluded from the model. A server listed in both - `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will - be ignored if `--allowed-mcp-server-names` is set. - - **Default**: No MCP servers excluded. - - **Example:** `"excludeMCPServers": ["myNodeServer"]`. - - **Security note:** This uses simple string matching on MCP server names, - which can be modified. If you're a system administrator looking to prevent - users from bypassing this, consider configuring the `mcpServers` at the - system settings level such that the user will not be able to configure any - 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](./themes.md) for Gemini CLI. - - **Default:** `"Default"` - - **Example:** `"theme": "GitHub"` - -- **`vimMode`** (boolean): - - **Description:** Enables or disables vim mode for input editing. When - enabled, the input area supports vim-style navigation and editing commands - with NORMAL and INSERT modes. The vim mode status is displayed in the footer - and persists between sessions. - - **Default:** `false` - - **Example:** `"vimMode": true` - -- **`sandbox`** (boolean or string): - - **Description:** Controls whether and how to use sandboxing for tool - execution. If set to `true`, Gemini CLI uses a pre-built - `gemini-cli-sandbox` Docker image. For more information, see - [Sandboxing](#sandboxing). - - **Default:** `false` - - **Example:** `"sandbox": "docker"` - -- **`toolDiscoveryCommand`** (string): - - **Description:** Defines a custom shell command for discovering tools from - your project. The shell command must return on `stdout` a JSON array of - [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). - Tool wrappers are optional. - - **Default:** Empty - - **Example:** `"toolDiscoveryCommand": "bin/get_tools"` - -- **`toolCallCommand`** (string): - - **Description:** Defines a custom shell command for calling a specific tool - that was discovered using `toolDiscoveryCommand`. The shell command must - meet the following criteria: - - It must take function `name` (exactly as in - [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) - as first command line argument. - - It must read function arguments as JSON on `stdin`, analogous to - [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). - - It must return function output as JSON on `stdout`, analogous to - [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). - - **Default:** Empty - - **Example:** `"toolCallCommand": "bin/call_tool"` - -- **`mcpServers`** (object): - - **Description:** Configures connections to one or more Model-Context - Protocol (MCP) servers for discovering and using custom tools. Gemini CLI - attempts to connect to each configured MCP server to discover available - tools. If multiple MCP servers expose a tool with the same name, the tool - names will be prefixed with the server alias you defined in the - configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note - that the system might strip certain schema properties from MCP tool - definitions for compatibility. - - **Default:** Empty - - **Properties:** - - **``** (object): The server parameters for the named server. - - `command` (string, required): The command to execute to start the MCP - server. - - `args` (array of strings, optional): Arguments to pass to the command. - - `env` (object, optional): Environment variables to set for the server - process. - - `cwd` (string, optional): The working directory in which to start the - server. - - `timeout` (number, optional): Timeout in milliseconds for requests to - this MCP server. - - `trust` (boolean, optional): Trust this server and bypass all tool call - confirmations. - - `includeTools` (array of strings, optional): List of tool names to - include from this MCP server. When specified, only the tools listed here - will be available from this server (whitelist behavior). If not - specified, all tools from the server are enabled by default. - - `excludeTools` (array of strings, optional): List of tool names to - exclude from this MCP server. Tools listed here will not be available to - the model, even if they are exposed by the server. **Note:** - `excludeTools` takes precedence over `includeTools` - if a tool is in - both lists, it will be excluded. - - **Example:** - ```json - "mcpServers": { - "myPythonServer": { - "command": "python", - "args": ["mcp_server.py", "--port", "8080"], - "cwd": "./mcp_tools/python", - "timeout": 5000, - "includeTools": ["safe_tool", "file_reader"], - }, - "myNodeServer": { - "command": "node", - "args": ["mcp_server.js"], - "cwd": "./mcp_tools/node", - "excludeTools": ["dangerous_tool", "file_deleter"] - }, - "myDockerServer": { - "command": "docker", - "args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"], - "env": { - "API_KEY": "$MY_API_TOKEN" - } - } - } - ``` - -- **`checkpointing`** (object): - - **Description:** Configures the checkpointing feature, which allows you to - save and restore conversation and file states. See the - [Checkpointing documentation](./checkpointing.md) for more details. - - **Default:** `{"enabled": false}` - - **Properties:** - - **`enabled`** (boolean): When `true`, the `/restore` command is available. - -- **`preferredEditor`** (string): - - **Description:** Specifies the preferred editor to use for viewing diffs. - - **Default:** `vscode` - - **Example:** `"preferredEditor": "vscode"` - -- **`telemetry`** (object) - - **Description:** Configures logging and metrics collection for Gemini CLI. - For more information, see [Telemetry](./telemetry.md). - - **Default:** - `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}` - - **Properties:** - - **`enabled`** (boolean): Whether or not telemetry is enabled. - - **`target`** (string): The destination for collected telemetry. Supported - values are `local` and `gcp`. - - **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter. - - **`logPrompts`** (boolean): Whether or not to include the content of user - prompts in the logs. - - **Example:** - ```json - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:16686", - "logPrompts": false - } - ``` -- **`usageStatisticsEnabled`** (boolean): - - **Description:** Enables or disables the collection of usage statistics. See - [Usage Statistics](#usage-statistics) for more information. - - **Default:** `true` - - **Example:** - ```json - "usageStatisticsEnabled": false - ``` - -- **`hideTips`** (boolean): - - **Description:** Enables or disables helpful tips in the CLI interface. - - **Default:** `false` - - **Example:** - - ```json - "hideTips": true - ``` - -- **`hideBanner`** (boolean): - - **Description:** Enables or disables the startup banner (ASCII art logo) in - the CLI interface. - - **Default:** `false` - - **Example:** - - ```json - "hideBanner": true - ``` - -- **`maxSessionTurns`** (number): - - **Description:** Sets the maximum number of turns for a session. If the - session exceeds this limit, the CLI will stop processing and start a new - chat. - - **Default:** `-1` (unlimited) - - **Example:** - ```json - "maxSessionTurns": 10 - ``` - -- **`summarizeToolOutput`** (object): - - **Description:** Enables or disables the summarization of tool output. You - can specify the token budget for the summarization using the `tokenBudget` - setting. - - Note: Currently only the `run_shell_command` tool is supported. - - **Default:** `{}` (Disabled by default) - - **Example:** - ```json - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 2000 - } - } - ``` - -- **`excludedProjectEnvVars`** (array of strings): - - **Description:** Specifies environment variables that should be excluded - from being loaded from project `.env` files. This prevents project-specific - environment variables (like `DEBUG=true`) from interfering with gemini-cli - behavior. Variables from `.gemini/.env` files are never excluded. - - **Default:** `["DEBUG", "DEBUG_MODE"]` - - **Example:** - ```json - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] - ``` - -- **`includeDirectories`** (array of strings): - - **Description:** Specifies an array of additional absolute or relative paths - to include in the workspace context. This allows you to work with files - across multiple directories as if they were one. Paths can use `~` to refer - to the user's home directory. This setting can be combined with the - `--include-directories` command-line flag. - - **Default:** `[]` - - **Example:** - ```json - "includeDirectories": [ - "/path/to/another/project", - "../shared-library", - "~/common-utils" - ] - ``` - -- **`loadMemoryFromIncludeDirectories`** (boolean): - - **Description:** Controls the behavior of the `/memory refresh` command. If - set to `true`, `GEMINI.md` files should be loaded from all directories that - are added. If set to `false`, `GEMINI.md` should only be loaded from the - current directory. - - **Default:** `false` - - **Example:** - ```json - "loadMemoryFromIncludeDirectories": true - ``` - -### Example `settings.json`: - -```json -{ - "theme": "GitHub", - "sandbox": "docker", - "toolDiscoveryCommand": "bin/get_tools", - "toolCallCommand": "bin/call_tool", - "mcpServers": { - "mainServer": { - "command": "bin/mcp_server.py" - }, - "anotherServer": { - "command": "node", - "args": ["mcp_server.js", "--verbose"] - } - }, - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:4317", - "logPrompts": true - }, - "usageStatisticsEnabled": true, - "hideTips": false, - "hideBanner": false, - "maxSessionTurns": 10, - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 100 - } - }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], - "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], - "loadMemoryFromIncludeDirectories": true -} -``` - -## Shell history - -The CLI keeps a history of shell commands you run. To avoid conflicts between -different projects, this history is stored in a project-specific directory -within your user's home folder. - -- **Location:** `~/.gemini/tmp//shell_history` - - `` is a unique identifier generated from your project's root - path. - - The history is stored in a file named `shell_history`. - -## Environment variables and `.env` files - -Environment variables are a common way to configure applications, especially for -sensitive information like API keys or for settings that might change between -environments. - -The CLI automatically loads environment variables from an `.env` file. The -loading order is: - -1. `.env` file in the current working directory. -2. If not found, it searches upwards in parent directories until it finds an - `.env` file or reaches the project root (identified by a `.git` folder) or - the home directory. -3. If still not found, it looks for `~/.env` (in the user's home directory). - -**Environment variable exclusion:** Some environment variables (like `DEBUG` and -`DEBUG_MODE`) are automatically excluded from being loaded from project `.env` -files to prevent interference with gemini-cli behavior. Variables from -`.gemini/.env` files are never excluded. You can customize this behavior using -the `excludedProjectEnvVars` setting in your `settings.json` file. - -- **`GEMINI_API_KEY`** (Required): - - Your API key for the Gemini API. - - **Crucial for operation.** The CLI will not function without it. - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` - file. -- **`GEMINI_MODEL`**: - - Specifies the default Gemini model to use. - - Overrides the hardcoded default - - Example: `export GEMINI_MODEL="gemini-2.5-flash"` -- **`GEMINI_CLI_CUSTOM_HEADERS`**: - - Adds extra HTTP headers to Gemini API and Code Assist requests. - - Accepts a comma-separated list of `Name: value` pairs. - - Example: - `export GEMINI_CLI_CUSTOM_HEADERS="X-My-Header: foo, X-Trace-ID: abc123"`. -- **`GEMINI_API_KEY_AUTH_MECHANISM`**: - - Specifies how the API key should be sent for authentication when using - `AuthType.USE_GEMINI` or `AuthType.USE_VERTEX_AI`. - - Valid values are `x-goog-api-key` (default) or `bearer`. - - If set to `bearer`, the API key will be sent in the - `Authorization: Bearer ` header. - - Example: `export GEMINI_API_KEY_AUTH_MECHANISM="bearer"` -- **`GOOGLE_API_KEY`**: - - Your Google Cloud API key. - - Required for using Vertex AI in express mode. - - Ensure you have the necessary permissions. - - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`. -- **`GOOGLE_CLOUD_PROJECT`**: - - Your Google Cloud Project ID. - - Required for using Code Assist or Vertex AI. - - If using Vertex AI, ensure you have the necessary permissions in this - project. - - **Cloud shell note:** When running in a Cloud Shell environment, this - variable defaults to a special project allocated for Cloud Shell users. If - you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud - Shell, it will be overridden by this default. To use a different project in - Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file. - - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. -- **`GOOGLE_APPLICATION_CREDENTIALS`** (string): - - **Description:** The path to your Google Application Credentials JSON file. - - **Example:** - `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` -- **`OTLP_GOOGLE_CLOUD_PROJECT`**: - - Your Google Cloud Project ID for Telemetry in Google Cloud - - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. -- **`GOOGLE_CLOUD_LOCATION`**: - - Your Google Cloud Project Location (e.g., us-central1). - - Required for using Vertex AI in non express mode. - - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`. -- **`GEMINI_SANDBOX`**: - - Alternative to the `sandbox` setting in `settings.json`. - - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. -- **`HTTP_PROXY` / `HTTPS_PROXY`**: - - Specifies the proxy server to use for outgoing HTTP/HTTPS requests. - - Example: `export HTTPS_PROXY="http://proxy.example.com:8080"` -- **`SEATBELT_PROFILE`** (macOS specific): - - Switches the Seatbelt (`sandbox-exec`) profile on macOS. - - `permissive-open`: (Default) Restricts writes to the project folder (and a - few other folders, see - `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other - operations. - - `strict`: Uses a strict profile that declines operations by default. - - ``: Uses a custom profile. To define a custom profile, create - a file named `sandbox-macos-.sb` in your project's `.gemini/` - directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). -- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI - itself): - - Set to `true` or `1` to enable verbose debug logging, which can be helpful - for troubleshooting. - - **Note:** These variables are automatically excluded from project `.env` - files by default to prevent interference with gemini-cli behavior. Use - `.gemini/.env` files if you need to set these for gemini-cli specifically. -- **`NO_COLOR`**: - - Set to any value to disable all color output in the CLI. -- **`CLI_TITLE`**: - - Set to a string to customize the title of the CLI. -- **`CODE_ASSIST_ENDPOINT`**: - - Specifies the endpoint for the code assist server. - - This is useful for development and testing. -- **`GEMINI_SYSTEM_MD`**: - - Overrides the base system prompt with the contents of a Markdown file. - - If set to `1` or `true`, it uses the file at `.gemini/system.md`. - - If set to a file path, it uses that file. The path can be absolute or - relative. `~` is supported for the home directory. - - The specified file must exist. -- **`GEMINI_WRITE_SYSTEM_MD`**: - - Writes the default system prompt to a file. This is useful for getting a - template to customize. - - If set to `1` or `true`, it writes to `.gemini/system.md`. - - If set to a file path, it writes to that path. The path can be absolute or - relative. `~` is supported for the home directory. **Note: This will - overwrite the file if it already exists.** - -## Command-line arguments - -Arguments passed directly when running the CLI can override other configurations -for that specific session. - -- **`--model `** (**`-m `**): - - Specifies the Gemini model to use for this session. - - Example: `npm start -- --model gemini-1.5-pro-latest` -- **`--prompt `** (**`-p `**): - - Used to pass a prompt directly to the command. This invokes Gemini CLI in a - non-interactive mode. -- **`--prompt-interactive `** (**`-i `**): - - Starts an interactive session with the provided prompt as the initial input. - - The prompt is processed within the interactive session, not before it. - - Cannot be used when piping input from stdin. - - Example: `gemini -i "explain this code"` -- **`--sandbox`** (**`-s`**): - - Enables sandbox mode for this session. -- **`--sandbox-image`**: - - Sets the sandbox image URI. -- **`--debug`** (**`-d`**): - - Enables debug mode for this session, providing more verbose output. -- **`--all-files`** (**`-a`**): - - If set, recursively includes all files within the current directory as - context for the prompt. -- **`--help`** (or **`-h`**): - - Displays help information about command-line arguments. -- **`--show-memory-usage`**: - - Displays the current memory usage. -- **`--yolo`**: - - Enables YOLO mode, which automatically approves all tool calls. -- **`--telemetry`**: - - Enables [telemetry](./telemetry.md). -- **`--telemetry-target`**: - - Sets the telemetry target. See [telemetry](./telemetry.md) for more - information. -- **`--telemetry-otlp-endpoint`**: - - Sets the OTLP endpoint for telemetry. See [telemetry](./telemetry.md) for - more information. -- **`--telemetry-log-prompts`**: - - Enables logging of prompts for telemetry. See [telemetry](./telemetry.md) - for more information. -- **`--extensions `** (**`-e `**): - - Specifies a list of extensions to use for the session. If not provided, all - available extensions are used. - - Use the special term `gemini -e none` to disable all extensions. - - Example: `gemini -e my-extension -e my-other-extension` -- **`--list-extensions`** (**`-l`**): - - Lists all available extensions and exits. -- **`--include-directories `**: - - Includes additional directories in the workspace for multi-directory - support. - - Can be specified multiple times or as comma-separated values. - - 5 directories can be added at maximum. - - Example: `--include-directories /path/to/project1,/path/to/project2` or - `--include-directories /path/to/project1 --include-directories /path/to/project2` -- **`--version`**: - - Displays the version of the CLI. - -## Context files (hierarchical instructional context) - -While not strictly configuration for the CLI's _behavior_, context files -(defaulting to `GEMINI.md` but configurable via the `contextFileName` setting) -are crucial for configuring the _instructional context_ (also referred to as -"memory") provided to the Gemini model. This powerful feature allows you to give -project-specific instructions, coding style guides, or any relevant background -information to the AI, making its responses more tailored and accurate to your -needs. The CLI includes UI elements, such as an indicator in the footer showing -the number of loaded context files, to keep you informed about the active -context. - -- **Purpose:** These Markdown files contain instructions, guidelines, or context - that you want the Gemini model to be aware of during your interactions. The - system is designed to manage this instructional context hierarchically. - -### Example context file content (e.g., `GEMINI.md`) - -Here's a conceptual example of what a context file at the root of a TypeScript -project might contain: - -```markdown -# Project: My Awesome TypeScript Library - -## General Instructions: - -- When generating new TypeScript code, please follow the existing coding style. -- Ensure all new functions and classes have JSDoc comments. -- Prefer functional programming paradigms where appropriate. -- All code should be compatible with TypeScript 5.0 and Node.js 20+. - -## Coding Style: - -- Use 2 spaces for indentation. -- Interface names should be prefixed with `I` (e.g., `IUserService`). -- Private class members should be prefixed with an underscore (`_`). -- Always use strict equality (`===` and `!==`). - -## Specific Component: `src/api/client.ts` - -- This file handles all outbound API requests. -- When adding new API call functions, ensure they include robust error handling - and logging. -- Use the existing `fetchWithRetry` utility for all GET requests. - -## Regarding Dependencies: - -- Avoid introducing new external dependencies unless absolutely necessary. -- If a new dependency is required, please state the reason. -``` - -This example demonstrates how you can provide general project context, specific -coding conventions, and even notes about particular files or components. The -more relevant and precise your context files are, the better the AI can assist -you. Project-specific context files are highly encouraged to establish -conventions and context. - -- **Hierarchical loading and precedence:** The CLI implements a sophisticated - hierarchical memory system by loading context files (e.g., `GEMINI.md`) from - several locations. Content from files lower in this list (more specific) - typically overrides or supplements content from files higher up (more - general). The exact concatenation order and final context can be inspected - using the `/memory show` command. The typical loading order is: - 1. **Global context file:** - - Location: `~/.gemini/` (e.g., `~/.gemini/GEMINI.md` in - your user home directory). - - Scope: Provides default instructions for all your projects. - 2. **Project root and ancestors context files:** - - Location: The CLI searches for the configured context file in the - current working directory and then in each parent directory up to either - the project root (identified by a `.git` folder) or your home directory. - - Scope: Provides context relevant to the entire project or a significant - portion of it. - 3. **Sub-directory context files (contextual/local):** - - Location: The CLI also scans for the configured context file in - subdirectories _below_ the current working directory (respecting common - ignore patterns like `node_modules`, `.git`, etc.). The breadth of this - search is limited to 200 directories by default, but can be configured - with a `memoryDiscoveryMaxDirs` field in your `settings.json` file. - - Scope: Allows for highly specific instructions relevant to a particular - component, module, or subsection of your project. -- **Concatenation and UI indication:** The contents of all found context files - are concatenated (with separators indicating their origin and path) and - provided as part of the system prompt to the Gemini model. The CLI footer - displays the count of loaded context files, giving you a quick visual cue - about the active instructional context. -- **Importing content:** You can modularize your context files by importing - other Markdown files using the `@path/to/file.md` syntax. For more details, - see the [Memory Import Processor documentation](../core/memport.md). -- **Commands for memory management:** - - Use `/memory refresh` to force a re-scan and reload of all context files - from all configured locations. This updates the AI's instructional context. - - Use `/memory show` to display the combined instructional context currently - loaded, allowing you to verify the hierarchy and content being used by the - AI. - - See the [Commands documentation](./commands.md#memory) for full details on - the `/memory` command and its sub-commands (`show` and `refresh`). - -By understanding and utilizing these configuration layers and the hierarchical -nature of context files, you can effectively manage the AI's memory and tailor -the Gemini CLI's responses to your specific needs and projects. - -## Sandboxing - -The Gemini CLI can execute potentially unsafe operations (like shell commands -and file modifications) within a sandboxed environment to protect your system. - -Sandboxing is disabled by default, but you can enable it in a few ways: - -- Using `--sandbox` or `-s` flag. -- Setting `GEMINI_SANDBOX` environment variable. -- Sandbox is enabled in `--yolo` mode by default. - -By default, it uses a pre-built `gemini-cli-sandbox` Docker image. - -For project-specific sandboxing needs, you can create a custom Dockerfile at -`.gemini/sandbox.Dockerfile` in your project's root directory. This Dockerfile -can be based on the base sandbox image: - -```dockerfile -FROM gemini-cli-sandbox - -# Add your custom dependencies or configurations here -# For example: -# RUN apt-get update && apt-get install -y some-package -# COPY ./my-config /app/my-config -``` - -When `.gemini/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` -environment variable when running Gemini CLI to automatically build the custom -sandbox image: - -```bash -BUILD_SANDBOX=1 gemini -s -``` - -## Usage statistics - -To help us improve the Gemini CLI, we collect anonymized usage statistics. This -data helps us understand how the CLI is used, identify common issues, and -prioritize new features. - -**What we collect:** - -- **Tool calls:** We log the names of the tools that are called, whether they - succeed or fail, and how long they take to execute. We do not collect the - arguments passed to the tools or any data returned by them. -- **API requests:** We log the Gemini model used for each request, the duration - of the request, and whether it was successful. We do not collect the content - of the prompts or responses. -- **Session information:** We collect information about the configuration of the - CLI, such as the enabled tools and the approval mode. - -**What we DON'T collect:** - -- **Personally identifiable information (PII):** We do not collect any personal - information, such as your name, email address, or API keys. -- **Prompt and response content:** We do not log the content of your prompts or - the responses from the Gemini model. -- **File content:** We do not log the content of any files that are read or - written by the CLI. - -**How to opt out:** - -You can opt out of usage statistics collection at any time by setting the -`usageStatisticsEnabled` property to `false` in your `settings.json` file: - -```json -{ - "usageStatisticsEnabled": false -} -``` diff --git a/docs/cli/creating-skills.md b/docs/cli/creating-skills.md new file mode 100644 index 0000000000..9826ddbfce --- /dev/null +++ b/docs/cli/creating-skills.md @@ -0,0 +1,80 @@ +# Creating Agent Skills + +This guide provides an overview of how to create your own Agent Skills to extend +the capabilities of Gemini CLI. + +## Getting started: The `skill-creator` skill + +The recommended way to create a new skill is to use the built-in `skill-creator` +skill. To use it, ask Gemini CLI to create a new skill for you. + +**Example prompt:** + +> "create a new skill called 'code-reviewer'" + +Gemini CLI will then use the `skill-creator` to generate the skill: + +1. Generate a new directory for your skill (e.g., `my-new-skill/`). +2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and + `description`). +3. Create the standard resource directories: `scripts/`, `references/`, and + `assets/`. + +## Manual skill creation + +If you prefer to create skills manually: + +1. **Create a directory** for your skill (e.g., `my-new-skill/`). +2. **Create a `SKILL.md` file** inside the new directory. + +To add additional resources that support the skill, refer to the skill +structure. + +## Skill structure + +A skill is a directory containing a `SKILL.md` file at its root. + +### Folder structure + +While a `SKILL.md` file is the only required component, we recommend the +following structure for organizing your skill's resources: + +```text +my-skill/ +โ”œโ”€โ”€ SKILL.md (Required) Instructions and metadata +โ”œโ”€โ”€ scripts/ (Optional) Executable scripts +โ”œโ”€โ”€ references/ (Optional) Static documentation +โ””โ”€โ”€ assets/ (Optional) Templates and other resources +``` + +### `SKILL.md` file + +The `SKILL.md` file is the core of your skill. This file uses YAML frontmatter +for metadata and Markdown for instructions. For example: + +```markdown +--- +name: code-reviewer +description: + Use this skill to review code. It supports both local changes and remote Pull + Requests. +--- + +# Code Reviewer + +This skill guides the agent in conducting thorough code reviews. + +## Workflow + +### 1. Determine Review Target + +- **Remote PR**: If the user gives a PR number or URL, target that remote PR. +- **Local Changes**: If changes are local... ... +``` + +- **`name`**: A unique identifier for the skill. This should match the directory + name. +- **`description`**: A description of what the skill does and when Gemini should + use it. +- **Body**: The Markdown body of the file contains the instructions that guide + the agent's behavior when the skill is active. diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index 2d251fc373..b70be823f1 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -50,7 +50,7 @@ Your command definition files must be written in the TOML format and use the ## Handling arguments Custom commands support two powerful methods for handling arguments. The CLI -automatically chooses the correct method based on the content of your command\'s +automatically chooses the correct method based on the content of your command's `prompt`. ### 1. Context-aware injection with `{{args}}` @@ -96,13 +96,13 @@ Search Results: """ ``` -When you run `/grep-code It\'s complicated`: +When you run `/grep-code It's complicated`: 1. The CLI sees `{{args}}` used both outside and inside `!{...}`. -2. Outside: The first `{{args}}` is replaced raw with `It\'s complicated`. +2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. 3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on Linux: `"It\'s complicated"`). -4. The command executed is `grep -r "It\'s complicated" .`. +4. The command executed is `grep -r "It's complicated" .`. 5. The CLI prompts you to confirm this exact, secure command before execution. 6. The final prompt is sent. @@ -129,13 +129,13 @@ format and behavior. # In: /.gemini/commands/changelog.toml # Invoked via: /changelog 1.2.0 added "Support for default argument parsing." -description = "Adds a new entry to the project\'s CHANGELOG.md file." +description = "Adds a new entry to the project's CHANGELOG.md file." prompt = """ # Task: Update Changelog You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog. -**The user\'s raw command is appended below your instructions.** +**The user's raw command is appended below your instructions.** Your task is to parse the ``, ``, and `` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file. @@ -147,7 +147,7 @@ The command follows this format: `/changelog ` 1. Read the `CHANGELOG.md` file. 2. Find the section for the specified ``. 3. Add the `` under the correct `` heading. -4. If the version or type section doesn\'t exist, create it. +4. If the version or type section doesn't exist, create it. 5. Adhere strictly to the "Keep a Changelog" format. """ ``` @@ -241,7 +241,7 @@ operate on specific files. **Example (`review.toml`):** This command injects the content of a _fixed_ best practices file -(`docs/best-practices.md`) and uses the user\'s arguments to provide context for +(`docs/best-practices.md`) and uses the user's arguments to provide context for the review. ```toml @@ -293,7 +293,7 @@ practice. description = "Asks the model to refactor the current context into a pure function." prompt = """ -Please analyze the code I\'ve provided in the current context. +Please analyze the code I've provided in the current context. Refactor it into a pure function. Your response should include: diff --git a/docs/cli/index.md b/docs/cli/index.md index 069c802411..437038d478 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -25,10 +25,12 @@ overview of Gemini CLI, see the [main documentation page](../index.md). - **[Checkpointing](./checkpointing.md):** Automatically save and restore snapshots of your session and files. -- **[Enterprise configuration](./enterprise.md):** Deploying and manage Gemini - CLI in an enterprise environment. +- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI + in an enterprise environment. - **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure, containerized environment. +- **[Agent Skills](./skills.md):** Extend the CLI with specialized expertise and + procedural workflows. - **[Telemetry](./telemetry.md):** Configure observability to monitor usage and performance. - **[Token caching](./token-caching.md):** Optimize API costs by caching tokens. diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 22ce5866c0..a1a28665b9 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -8,43 +8,50 @@ available combinations. #### Basic Controls -| Action | Keys | -| -------------------------------------------- | ------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc` | +| Action | Keys | +| --------------------------------------------------------------- | ---------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc` | +| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | +| Exit the CLI when the input buffer is empty. | `Ctrl + D` | #### Cursor Movement -| Action | Keys | -| ----------------------------------------- | ---------------------- | -| Move the cursor to the start of the line. | `Ctrl + A`
`Home` | -| Move the cursor to the end of the line. | `Ctrl + E`
`End` | +| Action | Keys | +| ------------------------------------------- | ------------------------------------------------------------ | +| Move the cursor to the start of the line. | `Ctrl + A`
`Home (no Shift, Ctrl)` | +| Move the cursor to the end of the line. | `Ctrl + E`
`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)` | +| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` | +| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | +| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | #### Editing -| Action | Keys | -| ------------------------------------------------ | ----------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl + K` | -| Delete from the cursor to the start of the line. | `Ctrl + U` | -| Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Cmd + Backspace` | - -#### Screen Control - -| Action | Keys | -| -------------------------------------------- | ---------- | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | +| Action | Keys | +| ------------------------------------------------ | ---------------------------------------------------------------- | +| Delete from the cursor to the end of the line. | `Ctrl + K` | +| Delete from the cursor to the start of the line. | `Ctrl + U` | +| Clear all text in the input field. | `Ctrl + C` | +| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | +| Delete the next word. | `Ctrl + Delete`
`Alt + Delete` | +| Delete the character to the left. | `Backspace`
`Ctrl + H` | +| Delete the character to the right. | `Delete`
`Ctrl + D` | +| Undo the most recent text edit. | `Cmd + Z (no Shift)`
`Alt + Z (no Shift)` | +| Redo the most recent undone text edit. | `Shift + Ctrl + Z`
`Shift + Cmd + Z`
`Shift + Alt + Z` | #### Scrolling -| Action | Keys | -| ------------------------ | -------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Home` | -| Scroll to the bottom. | `End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Action | Keys | +| ------------------------ | --------------------------------- | +| Scroll content up. | `Shift + Up Arrow` | +| Scroll content down. | `Shift + Down Arrow` | +| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | +| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | #### History & Search @@ -53,17 +60,20 @@ available combinations. | Show the previous entry in history. | `Ctrl + P (no Shift)` | | Show the next entry in history. | `Ctrl + N (no Shift)` | | Start reverse search through history. | `Ctrl + R` | -| Insert the selected reverse-search match. | `Enter (no Ctrl)` | +| Submit the selected reverse-search match. | `Enter (no Ctrl)` | | Accept a suggestion while reverse searching. | `Tab` | +| Browse and rewind previous interactions. | `Double Esc` | #### Navigation -| Action | Keys | -| -------------------------------- | ------------------------------------------- | -| Move selection up in lists. | `Up Arrow (no Shift)` | -| Move selection down in lists. | `Down Arrow (no Shift)` | -| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` | -| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | +| Action | Keys | +| -------------------------------------------------- | ------------------------------------------- | +| Move selection up in lists. | `Up Arrow (no Shift)` | +| Move selection down in lists. | `Down Arrow (no Shift)` | +| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` | +| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | +| Move to the next item or question in a dialog. | `Tab (no Shift)` | +| Move to the previous item or question in a dialog. | `Shift + Tab` | #### Suggestions & Completions @@ -77,67 +87,54 @@ available combinations. #### Text Input -| Action | Keys | -| ------------------------------------ | ------------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd, not Paste)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Paste + Enter`
`Shift + Enter`
`Ctrl + J` | - -#### External Tools - -| Action | Keys | -| ---------------------------------------------- | ------------------------- | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V` | +| Action | Keys | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | +| Open the current prompt in an external editor. | `Ctrl + X` | +| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | #### App Controls -| Action | Keys | -| ----------------------------------------------------------------- | ---------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Toggle IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Cmd + M` | -| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` | -| Expand a height-constrained response to show additional lines. | `Ctrl + S` | -| Toggle focus between the shell and Gemini input. | `Ctrl + F` | - -#### Session Control - -| Action | Keys | -| -------------------------------------------- | ---------- | -| Cancel the current request or quit the CLI. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Action | Keys | +| ----------------------------------------------------------------------------------------------------- | -------------------------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Show IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Alt + M` | +| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | +| 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`
`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` | +| Restart the application. | `R` | +| Suspend the application (not yet implemented). | `Ctrl + Z` | ## Additional context-specific shortcuts -- `Ctrl+Y`: Toggle YOLO (auto-approval) mode for tool calls. -- `Shift+Tab`: Toggle Auto Edit (auto-accept edits) mode. -- `Option+M` (macOS): Entering `ยต` with Option+M also toggles Markdown - rendering, matching `Cmd+M`. +- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your + terminal isn't configured to send Meta with Option. - `!` on an empty prompt: Enter or exit shell mode. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. -- `Ctrl+Delete` / `Meta+Delete`: Delete the word to the right of the cursor. -- `Ctrl+B` or `Left Arrow`: Move the cursor one character to the left while - editing text. -- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right; with an - embedded shell attached, `Ctrl+F` still toggles focus. -- `Ctrl+D` or `Delete`: Remove the character immediately to the right of the - cursor. -- `Ctrl+H` or `Backspace`: Remove the character immediately to the left of the - cursor. -- `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B`: Move one word to the left. -- `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F`: Move one word to the - right. -- `Ctrl+W`: Delete the word to the left of the cursor (in addition to - `Ctrl+Backspace` / `Cmd+Backspace`). -- `Ctrl+Z` / `Ctrl+Shift+Z`: Undo or redo the most recent text edit. -- `Meta+Enter`: Open the current input in an external editor (alias for - `Ctrl+X`). -- `Esc` pressed twice quickly: Clear the current input buffer. +- `Esc` pressed twice quickly: Clear the input prompt if it is not empty, + otherwise browse and rewind previous interactions. - `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a single-line input, navigate backward or forward through prompt history. - `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to the numbered radio option and confirm when the full number is entered. +- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate + buffer mode: Expand to view full content inline. Double-click again to + collapse. diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md index ed5d4eb0bc..1f7ba5da09 100644 --- a/docs/cli/model-routing.md +++ b/docs/cli/model-routing.md @@ -1,4 +1,4 @@ -## Model routing +# Model routing Gemini CLI includes a model routing feature that automatically switches to a fallback model in case of a model failure. This feature is enabled by default @@ -11,12 +11,17 @@ health and automatically routes requests to available models based on defined policies. 1. **Model failure:** If the currently selected model fails (e.g., due to quota - or server errors), the CLI will iniate the fallback process. + or server errors), the CLI will initiate the fallback process. 2. **User consent:** Depending on the failure and the model's policy, the CLI may prompt you to switch to a fallback model (by default always prompts you). + Some internal utility calls (such as prompt completion and classification) + use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back + to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the + configured model. + 3. **Model switch:** If approved, or if the policy allows for silent fallback, the CLI will use an available fallback model for the current turn or the remainder of the session. diff --git a/docs/cli/model.md b/docs/cli/model.md index 9da7dc4c4f..fd0e950bbb 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the -[configuration documentation](./configuration.md). +[configuration documentation](../get-started/configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. diff --git a/docs/cli/rewind.md b/docs/cli/rewind.md new file mode 100644 index 0000000000..e0e0cf15d7 --- /dev/null +++ b/docs/cli/rewind.md @@ -0,0 +1,51 @@ +# Rewind + +The `/rewind` command allows you to go back to a previous state in your +conversation and, optionally, revert any file changes made by the AI during +those interactions. This is a powerful tool for undoing mistakes, exploring +different approaches, or simply cleaning up your session history. + +## Usage + +To use the rewind feature, simply type `/rewind` into the input prompt and press +**Enter**. + +Alternatively, you can use the keyboard shortcut: **Press `Esc` twice**. + +## Interface + +When you trigger a rewind, an interactive list of your previous interactions +appears. + +1. **Select Interaction:** Use the **Up/Down arrow keys** to navigate through + the list. The most recent interactions are at the bottom. +2. **Preview:** As you select an interaction, you'll see a preview of the user + prompt and, if applicable, the number of files changed during that step. +3. **Confirm Selection:** Press **Enter** on the interaction you want to rewind + back to. +4. **Action Selection:** After selecting an interaction, you'll be presented + with a confirmation dialog with up to three options: + - **Rewind conversation and revert code changes:** Reverts both the chat + history and the file modifications to the state before the selected + interaction. + - **Rewind conversation:** Only reverts the chat history. File changes are + kept. + - **Revert code changes:** Only reverts the file modifications. The chat + history is kept. + - **Do nothing (esc):** Cancels the rewind operation. + +If no code changes were made since the selected point, the options related to +reverting code changes will be hidden. + +## Key Considerations + +- **Destructive Action:** Rewinding is a destructive action for your current + session history and potentially your files. Use it with care. +- **Agent Awareness:** When you rewind the conversation, the AI model loses all + memory of the interactions that were removed. If you only revert code changes, + you may need to inform the model that the files have changed. +- **Manual Edits:** Rewinding only affects file changes made by the AI's edit + tools. It does **not** undo manual edits you've made or changes triggered by + the shell tool (`!`). +- **Compression:** Rewind works across chat compression points by reconstructing + the history from stored session data. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 09bb8f76d3..28b54851c2 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -11,7 +11,7 @@ Before using sandboxing, you need to install and set up the Gemini CLI: npm install -g @google/gemini-cli ``` -To verify the installation +To verify the installation: ```bash gemini --version diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 96912995d4..ab637aed3e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -18,45 +18,52 @@ Note: Workspace settings override user settings. Here is a list of all the available settings, grouped by category and ordered as they appear in the UI. + + ### General -| UI Label | Setting | Description | Default | -| ------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | ----------- | -| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` | -| Vim Mode | `general.vimMode` | Enable Vim keybindings. | `false` | -| Disable Auto Update | `general.disableAutoUpdate` | Disable automatic updates. | `false` | -| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Session Retention | `general.sessionRetention` | Settings for automatic session cleanup. This feature is disabled by default. | `undefined` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- | +| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | ### Output -| UI Label | Setting | Description | Default | -| ------------- | --------------- | ------------------------------------------------------ | ------- | -| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `text` | +| UI Label | Setting | Description | Default | +| ------------- | --------------- | ------------------------------------------------------ | -------- | +| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `"text"` | ### UI -| UI Label | Setting | Description | Default | -| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ------- | -| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar. | `false` | -| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title. | `false` | -| 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. | `false` | -| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | -| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `true` | -| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `true` | -| Disable Loading Phrases | `ui.accessibility.disableLoadingPhrases` | Disable loading phrases for accessibility. | `false` | -| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | +| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: โ—‡, Action Required: โœ‹, Working: โœฆ) | `true` | +| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | +| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | +| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | +| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | +| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | +| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE @@ -69,45 +76,64 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- | | 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.2` | +| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | | Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | ### Context -| UI Label | Setting | Description | Default | -| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | -| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | -| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | -| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | -| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | -| Disable Fuzzy Search | `context.fileFiltering.disableFuzzySearch` | Disable fuzzy search when searching for files. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | +| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | +| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | +| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` | +| Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` | ### Tools -| UI Label | Setting | Description | Default | -| -------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------- | -| 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` | -| 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` | -| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` | -| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` | +| UI Label | Setting | Description | Default | +| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| 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` | +| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` | +| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` | +| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` | ### Security -| UI Label | Setting | Description | Default | -| ----------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------- | -| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `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` | -| Allowed Environment Variables | `security.environmentVariableRedaction.allowed` | Environment variables to always allow (bypass redaction). | `[]` | -| Blocked Environment Variables | `security.environmentVariableRedaction.blocked` | Environment variables to always redact. | `[]` | +| 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. | `false` | +| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | ### Experimental -| UI Label | Setting | Description | Default | -| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- | -| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` | -| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` | +| UI Label | Setting | Description | Default | +| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- | +| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` | +| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | + +### Skills + +| UI Label | Setting | Description | Default | +| ------------------- | ---------------- | -------------------- | ------- | +| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` | + +### HooksConfig + +| UI Label | Setting | Description | Default | +| ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- | +| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` | +| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` | + + diff --git a/docs/cli/skills.md b/docs/cli/skills.md new file mode 100644 index 0000000000..297bd80ed4 --- /dev/null +++ b/docs/cli/skills.md @@ -0,0 +1,112 @@ +# Agent Skills + +Agent Skills allow you to extend Gemini CLI with specialized expertise, +procedural workflows, and task-specific resources. Based on the +[Agent Skills](https://agentskills.io) open standard, a "skill" is a +self-contained directory that packages instructions and assets into a +discoverable capability. + +## Overview + +Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide +persistent workspace-wide background, Skills represent **on-demand expertise**. +This allows Gemini to maintain a vast library of specialized capabilitiesโ€”such +as security auditing, cloud deployments, or codebase migrationsโ€”without +cluttering the model's immediate context window. + +Gemini autonomously decides when to employ a skill based on your request and the +skill's description. When a relevant skill is identified, the model "pulls in" +the full instructions and resources required to complete the task using the +`activate_skill` tool. + +## Key Benefits + +- **Shared Expertise:** Package complex workflows (like a specific team's PR + review process) into a folder that anyone can use. +- **Repeatable Workflows:** Ensure complex multi-step tasks are performed + consistently by providing a procedural framework. +- **Resource Bundling:** Include scripts, templates, or example data alongside + instructions so the agent has everything it needs. +- **Progressive Disclosure:** Only skill metadata (name and description) is + loaded initially. Detailed instructions and resources are only disclosed when + the model explicitly activates the skill, saving context tokens. + +## Skill Discovery Tiers + +Gemini CLI discovers skills from three primary locations: + +1. **Workspace Skills** (`.gemini/skills/`): Workspace-specific skills that are + typically committed to version control and shared with the team. +2. **User Skills** (`~/.gemini/skills/`): Personal skills available across all + your workspaces. +3. **Extension Skills**: Skills bundled within installed + [extensions](../extensions/index.md). + +**Precedence:** If multiple skills share the same name, higher-precedence +locations override lower ones: **Workspace > User > Extension**. + +## Managing Skills + +### In an Interactive Session + +Use the `/skills` slash command to view and manage available expertise: + +- `/skills list` (default): Shows all discovered skills and their status. +- `/skills disable `: Prevents a specific skill from being used. +- `/skills enable `: Re-enables a disabled skill. +- `/skills reload`: Refreshes the list of discovered skills from all tiers. + +_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use +`--scope workspace` to manage workspace-specific settings._ + +### From the Terminal + +The `gemini skills` command provides management utilities: + +```bash +# List all discovered skills +gemini skills list + +# 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 +gemini skills install /path/to/local/skill +gemini skills install /path/to/local/my-expertise.skill + +# Install a specific skill from a monorepo or subdirectory using --path +gemini skills install https://github.com/my-org/my-skills.git --path skills/frontend-design + +# Install to the workspace scope (.gemini/skills) +gemini skills install /path/to/skill --scope workspace + +# Uninstall a skill by name +gemini skills uninstall my-expertise --scope workspace + +# Enable a skill (globally) +gemini skills enable my-expertise + +# Disable a skill. Can use --scope to specify workspace or user (defaults to workspace) +gemini skills disable my-expertise --scope workspace +``` + +## How it Works (Security & Privacy) + +1. **Discovery**: At the start of a session, Gemini CLI scans the discovery + tiers and injects the name and description of all enabled skills into the + system prompt. +2. **Activation**: When Gemini identifies a task matching a skill's + description, it calls the `activate_skill` tool. +3. **Consent**: You will see a confirmation prompt in the UI detailing the + skill's name, purpose, and the directory path it will gain access to. +4. **Injection**: Upon your approval: + - The `SKILL.md` body and folder structure is added to the conversation + history. + - The skill's directory is added to the agent's allowed file paths, granting + it permission to read any bundled assets. +5. **Execution**: The model proceeds with the specialized expertise active. It + is instructed to prioritize the skill's procedural guidance within reason. + +## Creating your own skills + +To create your own skills, see the [Create Agent Skills](./creating-skills.md) +guide. diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index c7fe5fc4ba..b1ff43e3fd 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -56,6 +56,38 @@ error with: `missing system prompt file ''`. When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|โŒโ– _โ– |` indicator in the UI to signal custom systemโ€‘prompt mode. +## Variable Substitution + +When using a custom system prompt file, you can use the following variables to +dynamically include built-in content: + +- `${AgentSkills}`: Injects a complete section (including header) of all + available agent skills. +- `${SubAgents}`: Injects a complete section (including header) of available + sub-agents. +- `${AvailableTools}`: Injects a bulleted list of all currently enabled tool + names. +- Tool Name Variables: Injects the actual name of a tool using the pattern: + `${toolName}_ToolName` (e.g., `${write_file_ToolName}`, + `${run_shell_command_ToolName}`). + + This pattern is generated dynamically for all available tools. + +### Example + +```markdown +# Custom System Prompt + +You are a helpful assistant. ${AgentSkills} +${SubAgents} + +## Tooling + +The following tools are available to you: ${AvailableTools} + +You can use ${write_file_ToolName} to save logs. +``` + ## Export the default prompt (recommended) Before overriding, export the current default prompt so you can review required diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 8fb2fd179e..9bf662b2a1 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -8,14 +8,17 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI. - [Configuration](#configuration) - [Google Cloud telemetry](#google-cloud-telemetry) - [Prerequisites](#prerequisites) + - [Authenticating with CLI Credentials](#authenticating-with-cli-credentials) - [Direct export (recommended)](#direct-export-recommended) - [Collector-based export (advanced)](#collector-based-export-advanced) + - [Monitoring Dashboards](#monitoring-dashboards) - [Local telemetry](#local-telemetry) - [File-based output (recommended)](#file-based-output-recommended) - [Collector-based export (advanced)](#collector-based-export-advanced-1) - [Logs and metrics](#logs-and-metrics) - [Logs](#logs) - [Sessions](#sessions) + - [Approval Mode](#approval-mode) - [Tools](#tools) - [Files](#files) - [API](#api) @@ -213,6 +216,24 @@ forward data to Google Cloud. - Open `~/.gemini/tmp//otel/collector-gcp.log` to view local collector logs. +### Monitoring Dashboards + +Gemini CLI provides a pre-configured +[Google Cloud Monitoring](https://cloud.google.com/monitoring) dashboard to +visualize your telemetry. + +This dashboard can be found under **Google Cloud Monitoring Dashboard +Templates** as "**Gemini CLI Monitoring**". + +![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png) + +![Gemini CLI Monitoring Dashboard Metrics](/docs/assets/monitoring-dashboard-metrics.png) + +![Gemini CLI Monitoring Dashboard Logs](/docs/assets/monitoring-dashboard-logs.png) + +To learn more, check out this blog post: +[Instant insights: Gemini CLIโ€™s new pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/). + ## Local telemetry For local development and debugging, you can capture telemetry data locally: @@ -295,9 +316,23 @@ Captures startup configuration and user prompt submissions. - `prompt` (string; excluded if `telemetry.logPrompts` is `false`) - `auth_type` (string) +#### Approval Mode + +Tracks changes and duration of approval modes. + +- `approval_mode_switch`: Approval mode was changed. + - **Attributes**: + - `from_mode` (string) + - `to_mode` (string) + +- `approval_mode_duration`: Duration spent in an approval mode. + - **Attributes**: + - `mode` (string) + - `duration_ms` (int) + #### Tools -Captures tool executions, output truncation, and Smart Edit behavior. +Captures tool executions, output truncation, and Edit behavior. - `gemini_cli.tool_call`: Emitted for each tool (function) call. - **Attributes**: @@ -325,11 +360,11 @@ Captures tool executions, output truncation, and Smart Edit behavior. - `lines` (int) - `prompt_id` (string) -- `gemini_cli.smart_edit_strategy`: Smart Edit strategy chosen. +- `gemini_cli.edit_strategy`: Edit strategy chosen. - **Attributes**: - `strategy` (string) -- `gemini_cli.smart_edit_correction`: Smart Edit correction result. +- `gemini_cli.edit_correction`: Edit correction result. - **Attributes**: - `correction` ("success" | "failure") diff --git a/docs/cli/themes.md b/docs/cli/themes.md index e7d67e5ebe..b6a372140b 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -86,7 +86,6 @@ color keys. For example: - `Gray` - `DiffAdded` (optional, for added lines in diffs) - `DiffRemoved` (optional, for removed lines in diffs) -- `DiffModified` (optional, for modified lines in diffs) You can also override individual UI text roles by adding a nested `text` object. This object supports the keys `primary`, `secondary`, `link`, `accent`, and @@ -157,7 +156,6 @@ custom theme defined in `settings.json`. "Gray": "#ABB2BF", "DiffAdded": "#A6E3A1", "DiffRemoved": "#F38BA8", - "DiffModified": "#89B4FA", "GradientColors": ["#4796E4", "#847ACE", "#C3677F"] } ``` diff --git a/docs/cli/tutorials.md b/docs/cli/tutorials.md index dff8918b5e..fe41220679 100644 --- a/docs/cli/tutorials.md +++ b/docs/cli/tutorials.md @@ -2,6 +2,10 @@ This page contains tutorials for interacting with Gemini CLI. +## Agent Skills + +- [Getting Started with Agent Skills](./tutorials/skills-getting-started.md) + ## Setting up a Model Context Protocol (MCP) server > [!CAUTION] Before using a third-party MCP server, ensure you trust its source diff --git a/docs/cli/tutorials/skills-getting-started.md b/docs/cli/tutorials/skills-getting-started.md new file mode 100644 index 0000000000..236424b393 --- /dev/null +++ b/docs/cli/tutorials/skills-getting-started.md @@ -0,0 +1,97 @@ +# Getting Started with Agent Skills + +Agent Skills allow you to extend Gemini CLI with specialized expertise. This +tutorial will guide you through creating your first skill and using it in a +session. + +## 1. Create your first skill + +A skill is a directory containing a `SKILL.md` file. Let's create an **API +Auditor** skill that helps you verify if local or remote endpoints are +responding correctly. + +1. **Create the skill directory structure:** + + ```bash + mkdir -p .gemini/skills/api-auditor/scripts + ``` + +2. **Create the `SKILL.md` file:** Create a file at + `.gemini/skills/api-auditor/SKILL.md` with the following content: + + ```markdown + --- + name: api-auditor + description: + Expertise in auditing and testing API endpoints. Use when the user asks to + "check", "test", or "audit" a URL or API. + --- + + # API Auditor Instructions + + You act as a QA engineer specialized in API reliability. When this skill is + active, you MUST: + + 1. **Audit**: Use the bundled `scripts/audit.js` utility to check the + status of the provided URL. + 2. **Report**: Analyze the output (status codes, latency) and explain any + failures in plain English. + 3. **Secure**: Remind the user if they are testing a sensitive endpoint + without an `https://` protocol. + ``` + +3. **Create the bundled Node.js script:** Create a file at + `.gemini/skills/api-auditor/scripts/audit.js`. This script will be used by + the agent to perform the actual check: + + ```javascript + // .gemini/skills/api-auditor/scripts/audit.js + const url = process.argv[2]; + + if (!url) { + console.error('Usage: node audit.js '); + process.exit(1); + } + + console.log(`Auditing ${url}...`); + fetch(url, { method: 'HEAD' }) + .then((r) => console.log(`Result: Success (Status ${r.status})`)) + .catch((e) => console.error(`Result: Failed (${e.message})`)); + ``` + +## 2. Verify the skill is discovered + +Use the `/skills` slash command (or `gemini skills list` from your terminal) to +see if Gemini CLI has found your new skill. + +In a Gemini CLI session: + +``` +/skills list +``` + +You should see `api-auditor` in the list of available skills. + +## 3. Use the skill in a chat + +Now, let's see the skill in action. Start a new session and ask a question about +an endpoint. + +**User:** "Can you audit http://geminili.com" + +Gemini will recognize the request matches the `api-auditor` description and will +ask for your permission to activate it. + +**Model:** (After calling `activate_skill`) "I've activated the **api-auditor** +skill. I'll run the audit script now..." + +Gemini will then use the `run_shell_command` tool to execute your bundled Node +script: + +`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com` + +## Next Steps + +- Explore [Agent Skills Authoring Guide](../skills.md#creating-a-skill) to learn + about more advanced skill features. +- Learn how to share skills via [Extensions](../../extensions/index.md). diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 9523e34d8d..e96ddc5acf 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -45,3 +45,21 @@ npm uninstall -g @google/gemini-cli ``` This command completely removes the package from your system. + +## Method 3: Homebrew + +If you installed the CLI globally using Homebrew (e.g., +`brew install gemini-cli`), use the `brew uninstall` command to remove it. + +```bash +brew uninstall gemini-cli +``` + +## Method 4: MacPorts + +If you installed the CLI globally using MacPorts (e.g., +`sudo port install gemini-cli`), use the `port uninstall` command to remove it. + +```bash +sudo port uninstall gemini-cli +``` diff --git a/docs/core/index.md b/docs/core/index.md index dc6237fff5..0cd49ad43e 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -7,6 +7,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the ## Navigating this section +- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use + specialized sub-agents for complex tasks. - **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core. - **[Memory Import Processor](./memport.md):** Documentation for the modular @@ -68,6 +70,10 @@ If you are using the default "pro" model and the CLI detects that you are being rate-limited, it automatically switches to the "flash" model for the current session. This allows you to continue working without interruption. +Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt +completion and classification) silently fall back to `gemini-2.5-flash` and +`gemini-2.5-pro` when quota is exhausted, without changing the configured model. + ## File discovery service The file discovery service is responsible for finding files in the project that diff --git a/docs/core/memport.md b/docs/core/memport.md index 9248f68dee..1460404792 100644 --- a/docs/core/memport.md +++ b/docs/core/memport.md @@ -83,7 +83,9 @@ The processor automatically detects and prevents circular imports: # file-a.md @./file-b.md +``` +```markdown # file-b.md @./file-a.md diff --git a/docs/core/policy-engine.md b/docs/core/policy-engine.md index 6f5dc600c1..f09ca01b70 100644 --- a/docs/core/policy-engine.md +++ b/docs/core/policy-engine.md @@ -146,6 +146,38 @@ A rule matches a tool call if all of its conditions are met: Policies are defined in `.toml` files. The CLI loads these files from Default, User, and (if configured) Admin directories. +### Policy locations + +| Tier | Type | Location | +| :-------- | :----- | :-------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | + +#### System-wide policies (Admin) + +Administrators can enforce system-wide policies (Tier 3) that override all user +and default settings. These policies must be placed in specific, secure +directories: + +| OS | Policy Directory Path | +| :---------- | :------------------------------------------------ | +| **Linux** | `/etc/gemini-cli/policies` | +| **macOS** | `/Library/Application Support/GeminiCli/policies` | +| **Windows** | `C:\ProgramData\gemini-cli\policies` | + +**Security Requirements:** + +To prevent privilege escalation, the CLI enforces strict security checks on +admin directories. If checks fail, system policies are **ignored**. + +- **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group + or others (e.g., `chmod 755`). +- **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) + must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you + see a security warning, use the folder properties to remove write permissions + for non-admin groups. You may need to "Disable inheritance" in Advanced + Security Settings._ + ### TOML rule schema Here is a breakdown of the fields available in a TOML policy rule: @@ -178,6 +210,10 @@ decision = "ask_user" # The priority of the rule, from 0 to 999. priority = 10 +# (Optional) A custom message to display when a tool call is denied by this rule. +# This message is returned to the model and user, useful for explaining *why* it was denied. +deny_message = "Deletion is permanent" + # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] ``` @@ -250,6 +286,7 @@ only the `mcpName`. mcpName = "untrusted-server" decision = "deny" priority = 500 +deny_message = "This server is not trusted by the admin." ``` ## Default policies @@ -258,8 +295,9 @@ The Gemini CLI ships with a set of default policies to provide a safe out-of-the-box experience. - **Read-only tools** (like `read_file`, `glob`) are generally **allowed**. -- **Agent delegation** (like `delegate_to_agent`) is **allowed** (sub-agent - actions are checked individually). +- **Agent delegation** defaults to **`ask_user`** to ensure remote agents can + prompt for confirmation, but local sub-agent actions are executed silently and + checked individually. - **Write tools** (like `write_file`, `run_shell_command`) default to **`ask_user`**. - In **`yolo`** mode, a high-priority rule allows all tools. diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md new file mode 100644 index 0000000000..3e5b8b06d1 --- /dev/null +++ b/docs/core/remote-agents.md @@ -0,0 +1,84 @@ +# Remote Subagents (experimental) + +Gemini CLI supports connecting to remote subagents using the Agent-to-Agent +(A2A) protocol. This allows Gemini CLI to interact with other agents, expanding +its capabilities by delegating tasks to remote services. + +Gemini CLI can connect to any compliant A2A agent. You can find samples of A2A +agents in the following repositories: + +- [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) +- [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) + +> **Note: Remote subagents are currently an experimental feature.** + +## Configuration + +To use remote subagents, you must explicitly enable them in your +`settings.json`: + +```json +{ + "experimental": { + "enableAgents": true + } +} +``` + +## Defining remote subagents + +Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter. +You can place them in: + +1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) +2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) + +### Configuration schema + +| Field | Type | Required | Description | +| :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- | +| `kind` | string | Yes | Must be `remote`. | +| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | +| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. | + +### Single-subagent example + +```markdown +--- +kind: remote +name: my-remote-agent +agent_card_url: https://example.com/agent-card +--- +``` + +### Multi-subagent example + +The loader explicitly supports multiple remote subagents defined in a single +Markdown file. + +```markdown +--- +- kind: remote + name: remote-1 + agent_card_url: https://example.com/1 +- kind: remote + name: remote-2 + agent_card_url: https://example.com/2 +--- +``` + +> **Note:** Mixed local and remote agents, or multiple local agents, are not +> supported in a single file; the list format is currently remote-only. + +## Managing Subagents + +Users can manage subagents using the following commands within the Gemini CLI: + +- `/agents list`: Displays all available local and remote subagents. +- `/agents refresh`: Reloads the agent registry. Use this after adding or + modifying agent definition files. +- `/agents enable `: Enables a specific subagent. +- `/agents disable `: Disables a specific subagent. + +> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance +> with configuring subagents. diff --git a/docs/core/subagents.md b/docs/core/subagents.md new file mode 100644 index 0000000000..d43d1f3aa1 --- /dev/null +++ b/docs/core/subagents.md @@ -0,0 +1,191 @@ +# Sub-agents (experimental) + +Sub-agents are specialized agents that operate within your main Gemini CLI +session. They are designed to handle specific, complex tasksโ€”like deep codebase +analysis, documentation lookup, or domain-specific reasoningโ€”without cluttering +the main agent's context or toolset. + +> **Note: Sub-agents are currently an experimental feature.** +> +> To use custom sub-agents, you must explicitly enable them in your +> `settings.json`: +> +> ```json +> { +> "experimental": { "enableAgents": true } +> } +> ``` +> +> **Warning:** Sub-agents currently operate in +> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning +> they may execute tools without individual user confirmation for each step. +> Proceed with caution when defining agents with powerful tools like +> `run_shell_command` or `write_file`. + +## What are sub-agents? + +Sub-agents are "specialists" that the main Gemini agent can hire for a specific +job. + +- **Focused context:** Each sub-agent has its own system prompt and persona. +- **Specialized tools:** Sub-agents can have a restricted or specialized set of + tools. +- **Independent context window:** Interactions with a sub-agent happen in a + separate context loop, which saves tokens in your main conversation history. + +Sub-agents are exposed to the main agent as a tool of the same name. When the +main agent calls the tool, it delegates the task to the sub-agent. Once the +sub-agent completes its task, it reports back to the main agent with its +findings. + +## Built-in sub-agents + +Gemini CLI comes with the following built-in sub-agents: + +### Codebase Investigator + +- **Name:** `codebase_investigator` +- **Purpose:** Analyze the codebase, reverse engineer, and understand complex + dependencies. +- **When to use:** "How does the authentication system work?", "Map out the + dependencies of the `AgentRegistry` class." +- **Configuration:** Enabled by default. You can configure it in + `settings.json`. Example (forcing a specific model): + ```json + { + "experimental": { + "codebaseInvestigatorSettings": { + "enabled": true, + "maxNumTurns": 20, + "model": "gemini-2.5-pro" + } + } + } + ``` + +### CLI Help Agent + +- **Name:** `cli_help` +- **Purpose:** Get expert knowledge about Gemini CLI itself, its commands, + configuration, and documentation. +- **When to use:** "How do I configure a proxy?", "What does the `/rewind` + command do?" +- **Configuration:** Enabled by default. + +### Generalist Agent + +- **Name:** `generalist_agent` +- **Purpose:** Route tasks to the appropriate specialized sub-agent. +- **When to use:** Implicitly used by the main agent for routing. Not directly + invoked by the user. +- **Configuration:** Enabled by default. No specific configuration options. + +## Creating custom sub-agents + +You can create your own sub-agents to automate specific workflows or enforce +specific personas. To use custom sub-agents, you must enable them in your +`settings.json`: + +```json +{ + "experimental": { + "enableAgents": true + } +} +``` + +### Agent definition files + +Custom agents are defined as Markdown files (`.md`) with YAML frontmatter. You +can place them in: + +1. **Project-level:** `.gemini/agents/*.md` (Shared with your team) +2. **User-level:** `~/.gemini/agents/*.md` (Personal agents) + +### File format + +The file **MUST** start with YAML frontmatter enclosed in triple-dashes `---`. +The body of the markdown file becomes the agent's **System Prompt**. + +**Example: `.gemini/agents/security-auditor.md`** + +```markdown +--- +name: security-auditor +description: Specialized in finding security vulnerabilities in code. +kind: local +tools: + - read_file + - search_file_content +model: gemini-2.5-pro +temperature: 0.2 +max_turns: 10 +--- + +You are a ruthless Security Auditor. Your job is to analyze code for potential +vulnerabilities. + +Focus on: + +1. SQL Injection +2. XSS (Cross-Site Scripting) +3. Hardcoded credentials +4. Unsafe file operations + +When you find a vulnerability, explain it clearly and suggest a fix. Do not fix +it yourself; just report it. +``` + +### Configuration schema + +| Field | Type | Required | Description | +| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. | +| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this sub-agent. | +| `kind` | string | No | `local` (default) or `remote`. | +| `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. | + +### Optimizing your sub-agent + +The main agent's system prompt encourages it to use an expert sub-agent when one +is available. It decides whether an agent is a relevant expert based on the +agent's description. You can improve the reliability with which an agent is used +by updating the description to more clearly indicate: + +- Its area of expertise. +- When it should be used. +- Some example scenarios. + +For example, the following sub-agent description should be called fairly +consistently for Git operations. + +> Git expert agent which should be used for all local and remote git operations. +> For example: +> +> - Making commits +> - Searching for regressions with bisect +> - Interacting with source control and issues providers such as GitHub. + +If you need to further tune your sub-agent, you can do so by selecting the model +to optimize for with `/model` and then asking the model why it does not think +that your sub-agent was called with a specific prompt and the given description. + +## Remote subagents (Agent2Agent) (experimental) + +Gemini CLI can also delegate tasks to remote sub-agents using the Agent-to-Agent +(A2A) protocol. + +> **Note: Remote subagents are currently an experimental feature.** + +See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed +configuration and usage instructions. + +## Extension sub-agents + +Extensions can bundle and distribute sub-agents. See the +[Extensions documentation](../extensions/index.md#sub-agents) for details on how +to package agents within an extension. diff --git a/docs/extensions/best-practices.md b/docs/extensions/best-practices.md new file mode 100644 index 0000000000..73c578f1be --- /dev/null +++ b/docs/extensions/best-practices.md @@ -0,0 +1,139 @@ +# Extensions on Gemini CLI: Best practices + +This guide covers best practices for developing, securing, and maintaining +Gemini CLI extensions. + +## Development + +Developing extensions for Gemini CLI is intended to be a lightweight, iterative +process. + +### Structure your extension + +While simple extensions can just be a few files, we recommend a robust structure +for complex extensions: + +``` +my-extension/ +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ tsconfig.json +โ”œโ”€โ”€ gemini-extension.json +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ index.ts +โ”‚ โ””โ”€โ”€ tools/ +โ””โ”€โ”€ dist/ +``` + +- **Use TypeScript**: We strongly recommend using TypeScript for type safety and + better tooling. +- **Separate source and build**: Keep your source code in `src` and build to + `dist`. +- **Bundle dependencies**: If your extension has many dependencies, consider + bundling them (e.g., with `esbuild` or `webpack`) to reduce install time and + potential conflicts. + +### Iterate with `link` + +Use `gemini extensions link` to develop locally without constantly reinstalling: + +```bash +cd my-extension +gemini extensions link . +``` + +Changes to your code (after rebuilding) will be immediately available in the CLI +on restart. + +### Use `GEMINI.md` effectively + +Your `GEMINI.md` file provides context to the model. Keep it focused: + +- **Do:** Explain high-level goals and how to use the provided tools. +- **Don't:** Dump your entire documentation. +- **Do:** Use clear, concise language. + +## Security + +When building a Gemini CLI extension, follow general security best practices +(such as least privilege and input validation) to reduce risk. + +### Minimal permissions + +When defining tools in your MCP server, only request the permissions necessary. +Avoid giving the model broad access (like full shell access) if a more +restricted set of tools will suffice. + +If you must use powerful tools like `run_shell_command`, consider restricting +them to specific commands in your `gemini-extension.json`: + +```json +{ + "name": "my-safe-extension", + "excludeTools": ["run_shell_command(rm -rf *)"] +} +``` + +This ensures that even if the model tries to execute a dangerous command, it +will be blocked at the CLI level. + +### Validate inputs + +Your MCP server is running on the user's machine. Always validate inputs to your +tools to prevent arbitrary code execution or filesystem access outside the +intended scope. + +```typescript +// Good: Validating paths +if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) { + throw new Error('Access denied'); +} +``` + +### Sensitive settings + +If your extension requires API keys, use the `sensitive: true` option in +`gemini-extension.json`. This ensures keys are stored securely in the system +keychain and obfuscated in the UI. + +```json +"settings": [ + { + "name": "API Key", + "envVar": "MY_API_KEY", + "sensitive": true + } +] +``` + +## Releasing + +You can upload your extension directly to GitHub to list it in the gallery. +Gemini CLI extensions also offers support for more complicated +[releases](releasing.md). + +### Semantic versioning + +Follow [Semantic Versioning](https://semver.org/). + +- **Major**: Breaking changes (renaming tools, changing arguments). +- **Minor**: New features (new tools, commands). +- **Patch**: Bug fixes. + +### Release Channels + +Use git branches to manage release channels (e.g., `main` for stable, `dev` for +bleeding edge). This allows users to choose their stability level: + +```bash +# Stable +gemini extensions install github.com/user/repo + +# Dev +gemini extensions install github.com/user/repo --ref dev +``` + +### Clean artifacts + +If you are using GitHub Releases, ensure your release artifacts only contain the +necessary files (`dist/`, `gemini-extension.json`, `package.json`). Exclude +`node_modules` (users will install them) and `src/` to keep downloads small. diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 9d4d6c63cc..ae66ce07bf 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -1,293 +1,45 @@ # Gemini CLI extensions -_This documentation is up-to-date with the v0.4.0 release._ - -Gemini CLI extensions package prompts, MCP servers, and custom commands into a -familiar and user-friendly format. With extensions, you can expand the -capabilities of Gemini CLI and share those capabilities with others. They are -designed to be easily installable and shareable. +Gemini CLI extensions package prompts, MCP servers, custom commands, hooks, +sub-agents, and agent skills into a familiar and user-friendly format. With +extensions, you can expand the capabilities of Gemini CLI and share those +capabilities with others. They are designed to be easily installable and +shareable. To see examples of extensions, you can browse a gallery of [Gemini CLI extensions](https://geminicli.com/extensions/browse/). -See [getting started docs](getting-started-extensions.md) for a guide on -creating your first extension. +## Managing extensions -See [releasing docs](extension-releasing.md) for an advanced guide on setting up -GitHub releases. +You can verify your installed extensions and their status using the interactive +command: -## Extension management - -We offer a suite of extension management tools using `gemini extensions` -commands. - -Note that these commands are not supported from within the CLI, although you can -list installed extensions using the `/extensions list` subcommand. - -Note that all of these commands will only be reflected in active CLI sessions on -restart. - -### Installing an extension - -You can install an extension using `gemini extensions install` with either a -GitHub URL or a local path. - -Note that we create a copy of the installed extension, so you will need to run -`gemini extensions update` to pull in changes from both locally-defined -extensions and those on GitHub. - -NOTE: If you are installing an extension from GitHub, you'll need to have `git` -installed on your machine. See -[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -for help. - -``` -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +```bash +/extensions list ``` -- ``: The github URL or local path of the extension to install. -- `--ref`: The git ref to install from. -- `--auto-update`: Enable auto-update for this extension. -- `--pre-release`: Enable pre-release versions for this extension. -- `--consent`: Acknowledge the security risks of installing an extension and - skip the confirmation prompt. +or in noninteractive mode: -### Uninstalling an extension - -To uninstall one or more extensions, run -`gemini extensions uninstall `: - -``` -gemini extensions uninstall gemini-cli-security gemini-cli-another-extension +```bash +gemini extensions list ``` -### Disabling an extension +## Installation -Extensions are, by default, enabled across all workspaces. You can disable an -extension entirely or for specific workspace. +To install a real extension, you can use the `extensions install` command with a +GitHub repository URL in noninteractive mode. For example: -``` -gemini extensions disable [--scope ] +```bash +gemini extensions install https://github.com/gemini-cli-extensions/workspace ``` -- ``: The name of the extension to disable. -- `--scope`: The scope to disable the extension in (`user` or `workspace`). +## Next steps -### Enabling an extension - -You can enable extensions using `gemini extensions enable `. You can also -enable an extension for a specific workspace using -`gemini extensions enable --scope=workspace` from within that workspace. - -``` -gemini extensions enable [--scope ] -``` - -- ``: The name of the extension to enable. -- `--scope`: The scope to enable the extension in (`user` or `workspace`). - -### Updating an extension - -For extensions installed from a local path or a git repository, you can -explicitly update to the latest version (as reflected in the -`gemini-extension.json` `version` field) with `gemini extensions update `. - -You can update all extensions with: - -``` -gemini extensions update --all -``` - -### Create a boilerplate extension - -We offer several example extensions `context`, `custom-commands`, -`exclude-tools` and `mcp-server`. You can view these examples -[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples). - -To copy one of these examples into a development directory using the type of -your choosing, run: - -``` -gemini extensions new [template] -``` - -- ``: The path to create the extension in. -- `[template]`: The boilerplate template to use. - -### Link a local extension - -The `gemini extensions link` command will create a symbolic link from the -extension installation directory to the development path. - -This is useful so you don't have to run `gemini extensions update` every time -you make changes you'd like to test. - -``` -gemini extensions link -``` - -- ``: The path of the extension to link. - -## How it works - -On startup, Gemini CLI looks for extensions in `/.gemini/extensions` - -Extensions exist as a directory that contains a `gemini-extension.json` file. -For example: - -`/.gemini/extensions/my-extension/gemini-extension.json` - -### `gemini-extension.json` - -The `gemini-extension.json` file contains the configuration for the extension. -The file has the following structure: - -```json -{ - "name": "my-extension", - "version": "1.0.0", - "mcpServers": { - "my-server": { - "command": "node my-server.js" - } - }, - "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] -} -``` - -- `name`: The name of the extension. This is used to uniquely identify the - extension and for conflict resolution when extension commands have the same - name as user or project commands. The name should be lowercase or numbers and - use dashes instead of underscores or spaces. This is how users will refer to - your extension in the CLI. Note that we expect this name to match the - extension directory name. -- `version`: The version of the extension. -- `mcpServers`: A map of MCP servers to settings. The key is the name of the - server, and the value is the server configuration. These servers will be - loaded on startup just like MCP servers settingsd in a - [`settings.json` file](../get-started/configuration.md). If both an extension - and a `settings.json` file settings an MCP server with the same name, the - server defined in the `settings.json` file takes precedence. - - Note that all MCP server configuration options are supported except for - `trust`. -- `contextFileName`: The name of the file that contains the context for the - extension. This will be used to load the context from the extension directory. - If this property is not used but a `GEMINI.md` file is present in your - extension directory, then that file will be loaded. -- `excludeTools`: An array of tool names to exclude from the model. You can also - specify command-specific restrictions for tools that support it, like the - `run_shell_command` tool. For example, - `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` - command. Note that this differs from the MCP server `excludeTools` - functionality, which can be listed in the MCP server config. - -When Gemini CLI starts, it loads all the extensions and merges their -configurations. If there are any conflicts, the workspace configuration takes -precedence. - -### Settings - -_Note: This is an experimental feature. We do not yet recommend extension -authors introduce settings as part of their core flows._ - -Extensions can define settings that the user will be prompted to provide upon -installation. This is useful for things like API keys, URLs, or other -configuration that the extension needs to function. - -To define settings, add a `settings` array to your `gemini-extension.json` file. -Each object in the array should have the following properties: - -- `name`: A user-friendly name for the setting. -- `description`: A description of the setting and what it's used for. -- `envVar`: The name of the environment variable that the setting will be stored - as. -- `sensitive`: Optional boolean. If true, obfuscates the input the user provides - and stores the secret in keychain storage. **Example** - -```json -{ - "name": "my-api-extension", - "version": "1.0.0", - "settings": [ - { - "name": "API Key", - "description": "Your API key for the service.", - "envVar": "MY_API_KEY" - } - ] -} -``` - -When a user installs this extension, they will be prompted to enter their API -key. The value will be saved to a `.env` file in the extension's directory -(e.g., `/.gemini/extensions/my-api-extension/.env`). - -You can view a list of an extension's settings by running: - -``` -gemini extensions settings list -``` - -and you can update a given setting using: - -``` -gemini extensions settings set [--scope ] -``` - -- `--scope`: The scope to set the setting in (`user` or `workspace`). This is - optional and will default to `user`. - -### Custom commands - -Extensions can provide [custom commands](../cli/custom-commands.md) by placing -TOML files in a `commands/` subdirectory within the extension directory. These -commands follow the same format as user and project custom commands and use -standard naming conventions. - -**Example** - -An extension named `gcp` with the following structure: - -``` -.gemini/extensions/gcp/ -โ”œโ”€โ”€ gemini-extension.json -โ””โ”€โ”€ commands/ - โ”œโ”€โ”€ deploy.toml - โ””โ”€โ”€ gcs/ - โ””โ”€โ”€ sync.toml -``` - -Would provide these commands: - -- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help -- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help - -### Conflict resolution - -Extension commands have the lowest precedence. When a conflict occurs with user -or project commands: - -1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`) -2. **With conflict**: Extension command is renamed with the extension prefix - (e.g., `/gcp.deploy`) - -For example, if both a user and the `gcp` extension define a `deploy` command: - -- `/deploy` - Executes the user's deploy command -- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` - tag) - -## Variables - -Gemini CLI extensions allow variable substitution in `gemini-extension.json`. -This can be useful if e.g., you need the current directory to run an MCP server -using `"cwd": "${extensionPath}${/}run.ts"`. - -**Supported variables:** - -| variable | description | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. | -| `${workspacePath}` | The fully-qualified path of the current workspace. | -| `${/} or ${pathSeparator}` | The path separator (differs per OS). | +- [Writing extensions](writing-extensions.md): Learn how to create your first + extension. +- [Extensions reference](reference.md): Deeply understand the extension format, + commands, and configuration. +- [Best practices](best-practices.md): Learn strategies for building great + extensions. +- [Extensions releasing](releasing.md): Learn how to share your extensions with + the world. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md new file mode 100644 index 0000000000..7b5f782917 --- /dev/null +++ b/docs/extensions/reference.md @@ -0,0 +1,336 @@ +# Extensions reference + +This guide covers the `gemini extensions` commands and the structure of the +`gemini-extension.json` configuration file. + +## Extension management + +We offer a suite of extension management tools using `gemini extensions` +commands. + +Note that these commands (e.g. `gemini extensions install`) are not supported +from within the CLI's **interactive mode**, although you can list installed +extensions using the `/extensions list` slash command. + +Note that all of these management operations (including updates to slash +commands) will only be reflected in active CLI sessions on **restart**. + +### Installing an extension + +You can install an extension using `gemini extensions install` with either a +GitHub URL or a local path. + +Note that we create a copy of the installed extension, so you will need to run +`gemini extensions update` to pull in changes from both locally-defined +extensions and those on GitHub. + +NOTE: If you are installing an extension from GitHub, you'll need to have `git` +installed on your machine. See +[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +for help. + +``` +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +``` + +- ``: The github URL or local path of the extension to install. +- `--ref`: The git ref to install from. +- `--auto-update`: Enable auto-update for this extension. +- `--pre-release`: Enable pre-release versions for this extension. +- `--consent`: Acknowledge the security risks of installing an extension and + skip the confirmation prompt. + +### Uninstalling an extension + +To uninstall one or more extensions, run +`gemini extensions uninstall `: + +``` +gemini extensions uninstall gemini-cli-security gemini-cli-another-extension +``` + +### Disabling an extension + +Extensions are, by default, enabled across all workspaces. You can disable an +extension entirely or for specific workspace. + +``` +gemini extensions disable [--scope ] +``` + +- ``: The name of the extension to disable. +- `--scope`: The scope to disable the extension in (`user` or `workspace`). + +### Enabling an extension + +You can enable extensions using `gemini extensions enable `. You can also +enable an extension for a specific workspace using +`gemini extensions enable --scope=workspace` from within that workspace. + +``` +gemini extensions enable [--scope ] +``` + +- ``: The name of the extension to enable. +- `--scope`: The scope to enable the extension in (`user` or `workspace`). + +### Updating an extension + +For extensions installed from a local path or a git repository, you can +explicitly update to the latest version (as reflected in the +`gemini-extension.json` `version` field) with `gemini extensions update `. + +You can update all extensions with: + +``` +gemini extensions update --all +``` + +### Create a boilerplate extension + +We offer several example extensions `context`, `custom-commands`, +`exclude-tools` and `mcp-server`. You can view these examples +[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples). + +To copy one of these examples into a development directory using the type of +your choosing, run: + +``` +gemini extensions new [template] +``` + +- ``: The path to create the extension in. +- `[template]`: The boilerplate template to use. + +### Link a local extension + +The `gemini extensions link` command will create a symbolic link from the +extension installation directory to the development path. + +This is useful so you don't have to run `gemini extensions update` every time +you make changes you'd like to test. + +``` +gemini extensions link +``` + +- ``: The path of the extension to link. + +## Extension format + +On startup, Gemini CLI looks for extensions in `/.gemini/extensions` + +Extensions exist as a directory that contains a `gemini-extension.json` file. +For example: + +`/.gemini/extensions/my-extension/gemini-extension.json` + +### `gemini-extension.json` + +The `gemini-extension.json` file contains the configuration for the extension. +The file has the following structure: + +```json +{ + "name": "my-extension", + "version": "1.0.0", + "description": "My awesome extension", + "mcpServers": { + "my-server": { + "command": "node my-server.js" + } + }, + "contextFileName": "GEMINI.md", + "excludeTools": ["run_shell_command"] +} +``` + +- `name`: The name of the extension. This is used to uniquely identify the + extension and for conflict resolution when extension commands have the same + name as user or project commands. The name should be lowercase or numbers and + use dashes instead of underscores or spaces. This is how users will refer to + your extension in the CLI. Note that we expect this name to match the + extension directory name. +- `version`: The version of the extension. +- `description`: A short description of the extension. This will be displayed on + [geminicli.com/extensions](https://geminicli.com/extensions). +- `mcpServers`: A map of MCP servers to settings. The key is the name of the + server, and the value is the server configuration. These servers will be + loaded on startup just like MCP servers settingsd in a + [`settings.json` file](../get-started/configuration.md). If both an extension + and a `settings.json` file settings an MCP server with the same name, the + server defined in the `settings.json` file takes precedence. + - Note that all MCP server configuration options are supported except for + `trust`. +- `contextFileName`: The name of the file that contains the context for the + extension. This will be used to load the context from the extension directory. + If this property is not used but a `GEMINI.md` file is present in your + extension directory, then that file will be loaded. +- `excludeTools`: An array of tool names to exclude from the model. You can also + specify command-specific restrictions for tools that support it, like the + `run_shell_command` tool. For example, + `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` + command. Note that this differs from the MCP server `excludeTools` + functionality, which can be listed in the MCP server config. + +When Gemini CLI starts, it loads all the extensions and merges their +configurations. If there are any conflicts, the workspace configuration takes +precedence. + +### Settings + +_Note: This is an experimental feature. We do not yet recommend extension +authors introduce settings as part of their core flows._ + +Extensions can define settings that the user will be prompted to provide upon +installation. This is useful for things like API keys, URLs, or other +configuration that the extension needs to function. + +To define settings, add a `settings` array to your `gemini-extension.json` file. +Each object in the array should have the following properties: + +- `name`: A user-friendly name for the setting. +- `description`: A description of the setting and what it's used for. +- `envVar`: The name of the environment variable that the setting will be stored + as. +- `sensitive`: Optional boolean. If true, obfuscates the input the user provides + and stores the secret in keychain storage. **Example** + +```json +{ + "name": "my-api-extension", + "version": "1.0.0", + "settings": [ + { + "name": "API Key", + "description": "Your API key for the service.", + "envVar": "MY_API_KEY" + } + ] +} +``` + +When a user installs this extension, they will be prompted to enter their API +key. The value will be saved to a `.env` file in the extension's directory +(e.g., `/.gemini/extensions/my-api-extension/.env`). + +You can view a list of an extension's settings by running: + +``` +gemini extensions list +``` + +and you can update a given setting using: + +``` +gemini extensions config [setting name] [--scope ] +``` + +- `--scope`: The scope to set the setting in (`user` or `workspace`). This is + optional and will default to `user`. + +### Custom commands + +Extensions can provide [custom commands](../cli/custom-commands.md) by placing +TOML files in a `commands/` subdirectory within the extension directory. These +commands follow the same format as user and project custom commands and use +standard naming conventions. + +**Example** + +An extension named `gcp` with the following structure: + +``` +.gemini/extensions/gcp/ +โ”œโ”€โ”€ gemini-extension.json +โ””โ”€โ”€ commands/ + โ”œโ”€โ”€ deploy.toml + โ””โ”€โ”€ gcs/ + โ””โ”€โ”€ sync.toml +``` + +Would provide these commands: + +- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help +- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help + +### Hooks + +Extensions can provide [hooks](../hooks/index.md) to intercept and customize +Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension +must be defined in a `hooks/hooks.json` file within the extension directory. + +> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The +> CLI specifically looks for the `hooks/hooks.json` file. + +### Agent Skills + +Extensions can bundle [Agent Skills](../cli/skills.md) to provide specialized +workflows. Skills must be placed in a `skills/` directory within the extension. + +**Example** + +An extension with the following structure: + +``` +.gemini/extensions/my-extension/ +โ”œโ”€โ”€ gemini-extension.json +โ””โ”€โ”€ skills/ + โ””โ”€โ”€ security-audit/ + โ””โ”€โ”€ SKILL.md +``` + +Will expose a `security-audit` skill that the model can activate. + +### Sub-agents + +> **Note: Sub-agents are currently an experimental feature.** + +Extensions can provide [sub-agents](../core/subagents.md) that users can +delegate tasks to. + +To bundle sub-agents with your extension, create an `agents/` directory in your +extension's root folder and add your agent definition files (`.md`) there. + +**Example** + +``` +.gemini/extensions/my-extension/ +โ”œโ”€โ”€ gemini-extension.json +โ””โ”€โ”€ agents/ + โ”œโ”€โ”€ security-auditor.md + โ””โ”€โ”€ database-expert.md +``` + +Gemini CLI will automatically discover and load these agents when the extension +is installed and enabled. + +### Conflict resolution + +Extension commands have the lowest precedence. When a conflict occurs with user +or project commands: + +1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`) +2. **With conflict**: Extension command is renamed with the extension prefix + (e.g., `/gcp.deploy`) + +For example, if both a user and the `gcp` extension define a `deploy` command: + +- `/deploy` - Executes the user's deploy command +- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` + tag) + +## Variables + +Gemini CLI extensions allow variable substitution in both +`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you +need the current directory to run an MCP server using an argument like +`"args": ["${extensionPath}${/}dist${/}server.js"]`. + +**Supported variables:** + +| variable | description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. | +| `${workspacePath}` | The fully-qualified path of the current workspace. | +| `${/} or ${pathSeparator}` | The path separator (differs per OS). | diff --git a/docs/extensions/extension-releasing.md b/docs/extensions/releasing.md similarity index 100% rename from docs/extensions/extension-releasing.md rename to docs/extensions/releasing.md diff --git a/docs/extensions/getting-started-extensions.md b/docs/extensions/writing-extensions.md similarity index 53% rename from docs/extensions/getting-started-extensions.md rename to docs/extensions/writing-extensions.md index 8f174a2966..0601d02231 100644 --- a/docs/extensions/getting-started-extensions.md +++ b/docs/extensions/writing-extensions.md @@ -8,7 +8,19 @@ file. ## Prerequisites Before you start, make sure you have the Gemini CLI installed and a basic -understanding of Node.js and TypeScript. +understanding of Node.js. + +## When to use what + +Extensions offer a variety of ways to customize Gemini CLI. + +| Feature | What it is | When to use it | Invoked by | +| :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | +| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model | +| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User | +| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model | +| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model | +| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI | ## Step 1: Create a new extension @@ -26,10 +38,9 @@ This will create a new directory with the following structure: ``` my-first-extension/ -โ”œโ”€โ”€ example.ts +โ”œโ”€โ”€ example.js โ”œโ”€โ”€ gemini-extension.json -โ”œโ”€โ”€ package.json -โ””โ”€โ”€ tsconfig.json +โ””โ”€โ”€ package.json ``` ## Step 2: Understand the extension files @@ -43,12 +54,12 @@ and use your extension. ```json { - "name": "my-first-extension", + "name": "mcp-server-example", "version": "1.0.0", "mcpServers": { "nodeServer": { "command": "node", - "args": ["${extensionPath}${/}dist${/}example.js"], + "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } @@ -64,12 +75,12 @@ and use your extension. with the absolute path to your extension's installation directory. This allows your extension to work regardless of where it's installed. -### `example.ts` +### `example.js` This file contains the source code for your MCP server. It's a simple Node.js server that uses the `@modelcontextprotocol/sdk`. -```typescript +```javascript /** * @license * Copyright 2025 Google LLC @@ -118,16 +129,15 @@ await server.connect(transport); This server defines a single tool called `fetch_posts` that fetches data from a public API. -### `package.json` and `tsconfig.json` +### `package.json` -These are standard configuration files for a TypeScript project. The -`package.json` file defines dependencies and a `build` script, and -`tsconfig.json` configures the TypeScript compiler. +This is the standard configuration file for a Node.js project. It defines +dependencies and scripts. -## Step 3: Build and link your extension +## Step 3: Link your extension -Before you can use the extension, you need to compile the TypeScript code and -link the extension to your Gemini CLI installation for local development. +Before you can use the extension, you need to link it to your Gemini CLI +installation for local development. 1. **Install dependencies:** @@ -136,16 +146,7 @@ link the extension to your Gemini CLI installation for local development. npm install ``` -2. **Build the server:** - - ```bash - npm run build - ``` - - This will compile `example.ts` into `dist/example.js`, which is the file - referenced in your `gemini-extension.json`. - -3. **Link the extension:** +2. **Link the extension:** The `link` command creates a symbolic link from the Gemini CLI extensions directory to your development directory. This means any changes you make @@ -212,7 +213,7 @@ need this for extensions built to expose commands and prompts. "mcpServers": { "nodeServer": { "command": "node", - "args": ["${extensionPath}${/}dist${/}example.js"], + "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } @@ -222,14 +223,48 @@ need this for extensions built to expose commands and prompts. Restart the CLI again. The model will now have the context from your `GEMINI.md` file in every session where the extension is active. -## Step 6: Releasing your extension +## (Optional) Step 6: Add an Agent Skill -Once you are happy with your extension, you can share it with others. The two +[Agent Skills](../cli/skills.md) let you bundle specialized expertise and +procedural workflows. Unlike `GEMINI.md`, which provides persistent context, +skills are activated only when needed, saving context tokens. + +1. Create a `skills` directory and a subdirectory for your skill: + + ```bash + mkdir -p skills/security-audit + ``` + +2. Create a `skills/security-audit/SKILL.md` file: + + ```markdown + --- + name: security-audit + description: + Expertise in auditing code for security vulnerabilities. Use when the user + asks to "check for security issues" or "audit" their changes. + --- + + # Security Auditor + + You are an expert security researcher. When auditing code: + + 1. Look for common vulnerabilities (OWASP Top 10). + 2. Check for hardcoded secrets or API keys. + 3. Suggest remediation steps for any findings. + ``` + +Skills bundled with your extension are automatically discovered and can be +activated by the model during a session when it identifies a relevant task. + +## Step 7: Release your extension + +Once you're happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method. For detailed instructions on both methods, please refer to the -[Extension Releasing Guide](./extension-releasing.md). +[Extension Releasing Guide](./releasing.md). ## Conclusion @@ -239,6 +274,7 @@ You've successfully created a Gemini CLI extension! You learned how to: - Add custom tools with an MCP server. - Create convenient custom commands. - Provide persistent context to the model. +- Bundle specialized Agent Skills. - Link your extension for local development. From here, you can explore more advanced features and build powerful new diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 8d511708eb..5dc69128b2 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -110,13 +110,13 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Enable Vim keybindings - **Default:** `false` -- **`general.disableAutoUpdate`** (boolean): - - **Description:** Disable automatic updates - - **Default:** `false` +- **`general.enableAutoUpdate`** (boolean): + - **Description:** Enable automatic updates. + - **Default:** `true` -- **`general.disableUpdateNag`** (boolean): - - **Description:** Disable update notification prompts. - - **Default:** `false` +- **`general.enableAutoUpdateNotification`** (boolean): + - **Description:** Enable update notification prompts. + - **Default:** `true` - **`general.checkpointing.enabled`** (boolean): - **Description:** Enable session checkpointing for recovery @@ -159,7 +159,7 @@ their corresponding top-level category object in your `settings.json` file. #### `output` - **`output.format`** (enum): - - **Description:** The format of the CLI output. + - **Description:** The format of the CLI output. Can be `text` or `json`. - **Default:** `"text"` - **Values:** `"text"`, `"json"` @@ -184,10 +184,21 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.showStatusInTitle`** (boolean): - - **Description:** Show Gemini CLI status and thoughts in the terminal window - title + - **Description:** Show Gemini CLI model thoughts in the terminal window title + during the working phase - **Default:** `false` +- **`ui.dynamicWindowTitle`** (boolean): + - **Description:** Update the terminal window title with current status icons + (Ready: โ—‡, Action Required: โœ‹, Working: โœฆ) + - **Default:** `true` + +- **`ui.showHomeDirectoryWarning`** (boolean): + - **Description:** Show a warning when running Gemini CLI in the home + directory. + - **Default:** `true` + - **Requires restart:** Yes + - **`ui.hideTips`** (boolean): - **Description:** Hide helpful tips in the UI - **Default:** `false` @@ -237,8 +248,8 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Show the model name in the chat for each model turn. - **Default:** `false` -- **`ui.useFullWidth`** (boolean): - - **Description:** Use the entire width of the terminal for output. +- **`ui.showUserIdentity`** (boolean): + - **Description:** Show the logged-in user's identity (e.g. email) in the UI. - **Default:** `true` - **`ui.useAlternateBuffer`** (boolean): @@ -247,6 +258,10 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`ui.useBackgroundColor`** (boolean): + - **Description:** Whether to use background colors in the UI. + - **Default:** `true` + - **`ui.incrementalRendering`** (boolean): - **Description:** Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when @@ -254,14 +269,18 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`ui.showSpinner`** (boolean): + - **Description:** Show the spinner during operations. + - **Default:** `true` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. - **Default:** `[]` -- **`ui.accessibility.disableLoadingPhrases`** (boolean): - - **Description:** Disable loading phrases for accessibility - - **Default:** `false` +- **`ui.accessibility.enableLoadingPhrases`** (boolean): + - **Description:** Enable loading phrases during operations. + - **Default:** `true` - **Requires restart:** Yes - **`ui.accessibility.screenReader`** (boolean): @@ -273,7 +292,7 @@ their corresponding top-level category object in your `settings.json` file. #### `ide` - **`ide.enabled`** (boolean): - - **Description:** Enable IDE integration mode + - **Description:** Enable IDE integration mode. - **Default:** `false` - **Requires restart:** Yes @@ -550,6 +569,14 @@ their corresponding top-level category object in your `settings.json` file. used. - **Default:** `[]` +#### `agents` + +- **`agents.overrides`** (object): + - **Description:** Override settings for specific agents, e.g. to disable the + agent, set a custom model config, or run config. + - **Default:** `{}` + - **Requires restart:** Yes + #### `context` - **`context.fileName`** (string | string[]): @@ -577,12 +604,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`context.fileFiltering.respectGitIgnore`** (boolean): - - **Description:** Respect .gitignore files when searching + - **Description:** Respect .gitignore files when searching. - **Default:** `true` - **Requires restart:** Yes - **`context.fileFiltering.respectGeminiIgnore`** (boolean): - - **Description:** Respect .geminiignore files when searching + - **Description:** Respect .geminiignore files when searching. - **Default:** `true` - **Requires restart:** Yes @@ -592,9 +619,17 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes -- **`context.fileFiltering.disableFuzzySearch`** (boolean): - - **Description:** Disable fuzzy search when searching for files. - - **Default:** `false` +- **`context.fileFiltering.enableFuzzySearch`** (boolean): + - **Description:** Enable fuzzy search when searching for files. + - **Default:** `true` + - **Requires restart:** Yes + +- **`context.fileFiltering.customIgnoreFilePaths`** (array): + - **Description:** Additional ignore file paths to respect. These files take + precedence over .geminiignore and .gitignore. Files earlier in the array + take precedence over files later in the array, e.g. the first file takes + precedence over the second one. + - **Default:** `[]` - **Requires restart:** Yes #### `tools` @@ -635,6 +670,13 @@ their corresponding top-level category object in your `settings.json` file. 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 + read-only mode. 'yolo' is not supported yet. + - **Default:** `"default"` + - **Values:** `"default"`, `"auto_edit"`, `"plan"` + - **`tools.core`** (array): - **Description:** Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for @@ -688,12 +730,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `1000` - **Requires restart:** Yes -- **`tools.enableHooks`** (boolean): - - **Description:** Enable the hooks system for intercepting and customizing - Gemini CLI behavior. When enabled, hooks configured in settings will execute - at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). - Requires MessageBus integration. - - **Default:** `false` +- **`tools.disableLLMCorrection`** (boolean): + - **Description:** Disable LLM-based error correction for edit tools. When + enabled, tools will fail immediately if exact string matches are not found, + instead of attempting to self-correct. + - **Default:** `true` - **Requires restart:** Yes #### `mcp` @@ -736,6 +777,13 @@ 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` @@ -810,6 +858,16 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.extensionConfig`** (boolean): + - **Description:** Enable requesting and fetching of extension settings. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.enableEventDrivenScheduler`** (boolean): + - **Description:** Enables event-driven scheduler within the CLI session. + - **Default:** `true` + - **Requires restart:** Yes + - **`experimental.extensionReloading`** (boolean): - **Description:** Enables extension loading/unloading within the CLI session. - **Default:** `false` @@ -820,57 +878,47 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes -- **`experimental.skills`** (boolean): - - **Description:** Enable Agent Skills (experimental). +- **`experimental.useOSC52Paste`** (boolean): + - **Description:** Use OSC 52 sequence for pasting instead of clipboardy + (useful for remote sessions). - **Default:** `false` - - **Requires restart:** Yes -- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean): - - **Description:** Enable the Codebase Investigator agent. - - **Default:** `true` - - **Requires restart:** Yes - -- **`experimental.codebaseInvestigatorSettings.maxNumTurns`** (number): - - **Description:** Maximum number of turns for the Codebase Investigator - agent. - - **Default:** `10` - - **Requires restart:** Yes - -- **`experimental.codebaseInvestigatorSettings.maxTimeMinutes`** (number): - - **Description:** Maximum time for the Codebase Investigator agent (in - minutes). - - **Default:** `3` - - **Requires restart:** Yes - -- **`experimental.codebaseInvestigatorSettings.thinkingBudget`** (number): - - **Description:** The thinking budget for the Codebase Investigator agent. - - **Default:** `8192` - - **Requires restart:** Yes - -- **`experimental.codebaseInvestigatorSettings.model`** (string): - - **Description:** The model to use for the Codebase Investigator agent. - - **Default:** `"auto"` - - **Requires restart:** Yes - -- **`experimental.introspectionAgentSettings.enabled`** (boolean): - - **Description:** Enable the Introspection Agent. +- **`experimental.plan`** (boolean): + - **Description:** Enable planning features (Plan Mode and tools). - **Default:** `false` - **Requires restart:** Yes #### `skills` +- **`skills.enabled`** (boolean): + - **Description:** Enable Agent Skills. + - **Default:** `true` + - **Requires restart:** Yes + - **`skills.disabled`** (array): - **Description:** List of disabled skills. - **Default:** `[]` - **Requires restart:** Yes -#### `hooks` +#### `hooksConfig` -- **`hooks.disabled`** (array): +- **`hooksConfig.enabled`** (boolean): + - **Description:** Canonical toggle for the hooks system. When disabled, no + hooks will be executed. + - **Default:** `true` + - **Requires restart:** Yes + +- **`hooksConfig.disabled`** (array): - **Description:** List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured. - **Default:** `[]` +- **`hooksConfig.notifications`** (boolean): + - **Description:** Show visual indicators when hooks are executing. + - **Default:** `true` + +#### `hooks` + - **`hooks.BeforeTool`** (array): - **Description:** Hooks that execute before tool execution. Can intercept, validate, or modify tool calls. @@ -925,6 +973,25 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Hooks that execute before tool selection. Can filter or prioritize available tools dynamically. - **Default:** `[]` + +#### `admin` + +- **`admin.secureModeEnabled`** (boolean): + - **Description:** If true, disallows yolo mode from being used. + - **Default:** `false` + +- **`admin.extensions.enabled`** (boolean): + - **Description:** If false, disallows extensions from being installed or + used. + - **Default:** `true` + +- **`admin.mcp.enabled`** (boolean): + - **Description:** If false, disallows MCP servers from being used. + - **Default:** `true` + +- **`admin.skills.enabled`** (boolean): + - **Description:** If false, disallows agent skills from being used. + - **Default:** `true` #### `mcpServers` @@ -1102,7 +1169,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_MODEL`**: - Specifies the default Gemini model to use. - Overrides the hardcoded default - - Example: `export GEMINI_MODEL="gemini-2.5-flash"` + - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` - **`GOOGLE_API_KEY`**: - Your Google Cloud API key. - Required for using Vertex AI in express mode. @@ -1123,6 +1190,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **Description:** The path to your Google Application Credentials JSON file. - **Example:** `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` +- **`GOOGLE_GENAI_API_VERSION`**: + - Specifies the API version to use for Gemini API requests. + - When set, overrides the default API version used by the SDK. + - Example: `export GOOGLE_GENAI_API_VERSION="v1"` - **`OTLP_GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID for Telemetry in Google Cloud - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. @@ -1247,7 +1318,7 @@ for that specific session. - **`--model `** (**`-m `**): - Specifies the Gemini model to use for this session. - - Example: `npm start -- --model gemini-1.5-pro-latest` + - Example: `npm start -- --model gemini-3-pro-preview` - **`--prompt `** (**`-p `**): - Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode. @@ -1270,7 +1341,8 @@ for that specific session. - **`--sandbox`** (**`-s`**): - Enables sandbox mode for this session. - **`--debug`** (**`-d`**): - - Enables debug mode for this session, providing more verbose output. + - Enables debug mode for this session, providing more verbose output. Open the + debug console with F12 to see the additional logging. - **`--help`** (or **`-h`**): - Displays help information about command-line arguments. @@ -1282,6 +1354,10 @@ for that specific session. - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) + - `plan`: Read-only mode for tool calls (requires experimental planning to + be enabled). + > **Note:** This mode is currently under development and not yet fully + > functional. - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - Example: `gemini --approval-mode auto_edit` diff --git a/docs/get-started/deployment.md b/docs/get-started/deployment.md deleted file mode 100644 index 670a3cf8c0..0000000000 --- a/docs/get-started/deployment.md +++ /dev/null @@ -1,143 +0,0 @@ -Note: This page will be replaced by [installation.md](installation.md). - -# Gemini CLI installation, execution, and deployment - -Install and run Gemini CLI. This document provides an overview of Gemini CLI's -installation methods and deployment architecture. - -## How to install and/or run Gemini CLI - -There are several ways to run Gemini CLI. The recommended option depends on how -you intend to use Gemini CLI. - -- As a standard installation. This is the most straightforward method of using - Gemini CLI. -- In a sandbox. This method offers increased security and isolation. -- From the source. This is recommended for contributors to the project. - -### 1. Standard installation (recommended for standard users) - -This is the recommended way for end-users to install Gemini CLI. It involves -downloading the Gemini CLI package from the NPM registry. - -- **Global install:** - - ```bash - npm install -g @google/gemini-cli - ``` - - Then, run the CLI from anywhere: - - ```bash - gemini - ``` - -- **NPX execution:** - - ```bash - # Execute the latest version from NPM without a global install - npx @google/gemini-cli - ``` - -### 2. Run in a sandbox (Docker/Podman) - -For security and isolation, Gemini CLI can be run inside a container. This is -the default way that the CLI executes tools that might have side effects. - -- **Directly from the registry:** You can run the published sandbox image - directly. This is useful for environments where you only have Docker and want - to run the CLI. - ```bash - # Run the published sandbox image - docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1 - ``` -- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally - (using the standard installation described above), you can instruct it to run - inside the sandbox container. - ```bash - gemini --sandbox -y -p "your prompt here" - ``` - -### 3. Run from source (recommended for Gemini CLI contributors) - -Contributors to the project will want to run the CLI directly from the source -code. - -- **Development mode:** This method provides hot-reloading and is useful for - active development. - ```bash - # From the root of the repository - npm run start - ``` -- **Production-like mode (Linked package):** This method simulates a global - installation by linking your local package. It's useful for testing a local - build in a production workflow. - - ```bash - # Link the local cli package to your global node_modules - npm link packages/cli - - # Now you can run your local version using the `gemini` command - gemini - ``` - ---- - -### 4. Running the latest Gemini CLI commit from GitHub - -You can run the most recently committed version of Gemini CLI directly from the -GitHub repository. This is useful for testing features still in development. - -```bash -# Execute the CLI directly from the main branch on GitHub -npx https://github.com/google-gemini/gemini-cli -``` - -## Deployment architecture - -The execution methods described above are made possible by the following -architectural components and processes: - -**NPM packages** - -Gemini CLI project is a monorepo that publishes two core packages to the NPM -registry: - -- `@google/gemini-cli-core`: The backend, handling logic and tool execution. -- `@google/gemini-cli`: The user-facing frontend. - -These packages are used when performing the standard installation and when -running Gemini CLI from the source. - -**Build and packaging processes** - -There are two distinct build processes used, depending on the distribution -channel: - -- **NPM publication:** For publishing to the NPM registry, the TypeScript source - code in `@google/gemini-cli-core` and `@google/gemini-cli` is transpiled into - standard JavaScript using the TypeScript Compiler (`tsc`). The resulting - `dist/` directory is what gets published in the NPM package. This is a - standard approach for TypeScript libraries. - -- **GitHub `npx` execution:** When running the latest version of Gemini CLI - directly from GitHub, a different process is triggered by the `prepare` script - in `package.json`. This script uses `esbuild` to bundle the entire application - and its dependencies into a single, self-contained JavaScript file. This - bundle is created on-the-fly on the user's machine and is not checked into the - repository. - -**Docker sandbox image** - -The Docker-based execution method is supported by the `gemini-cli-sandbox` -container image. This image is published to a container registry and contains a -pre-installed, global version of Gemini CLI. - -## Release process - -The release process is automated through GitHub Actions. The release workflow -performs the following actions: - -1. Build the NPM packages using `tsc`. -2. Publish the NPM packages to the artifact registry. -3. Create GitHub releases with bundled assets. diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index e0f6f8a1a7..333dbdb68d 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -1,25 +1,10 @@ # Gemini 3 Pro and Gemini 3 Flash on Gemini CLI -Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most -paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3 -Flash, including the following subscribers: - -- Google AI Pro and Google AI Ultra (excluding business customers). -- Gemini Code Assist Standard and Enterprise (requires - [administrative enablement](#administrator-instructions)). -- Paid Gemini API and Vertex API key holders. - -For free tier users: - -- If you signed up for the waitlist, please check your email for details. Weโ€™ve - onboarded everyone who signed up to the previously available waitlist. -- If you were not on our waitlist, weโ€™re rolling out additional access gradually - to ensure the experience remains fast and reliable. Stay tuned for more - details. +Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! ## How to get started with Gemini 3 on Gemini CLI -Get started by upgrading Gemini CLI to the latest version (0.21.1): +Get started by upgrading Gemini CLI to the latest version: ```bash npm install -g @google/gemini-cli@latest diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index fef22b8cc1..316aacbc29 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -1,4 +1,4 @@ -# Hooks on Gemini CLI: Best practices +# Hooks Best Practices This guide covers security considerations, performance optimization, debugging techniques, and privacy considerations for developing and deploying hooks in @@ -15,21 +15,20 @@ using parallel operations: // Sequential operations are slower const data1 = await fetch(url1).then((r) => r.json()); const data2 = await fetch(url2).then((r) => r.json()); -const data3 = await fetch(url3).then((r) => r.json()); // Prefer parallel operations for better performance // Start requests concurrently const p1 = fetch(url1).then((r) => r.json()); const p2 = fetch(url2).then((r) => r.json()); -const p3 = fetch(url3).then((r) => r.json()); // Wait for all results -const [data1, data2, data3] = await Promise.all([p1, p2, p3]); +const [data1, data2] = await Promise.all([p1, p2]); ``` ### Cache expensive operations -Store results between invocations to avoid repeated computation: +Store results between invocations to avoid repeated computation, especially for +hooks that run frequently (like `BeforeTool` or `AfterModel`). ```javascript const fs = require('fs'); @@ -54,6 +53,7 @@ async function main() { const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache if (cache[cacheKey]) { + // Write JSON to stdout console.log(JSON.stringify(cache[cacheKey])); return; } @@ -70,32 +70,20 @@ async function main() { ### Use appropriate events Choose hook events that match your use case to avoid unnecessary execution. -`AfterAgent` fires once per agent loop completion, while `AfterModel` fires -after every LLM call (potentially multiple times per loop): -```json -// If checking final completion, use AfterAgent instead of AfterModel -{ - "hooks": { - "AfterAgent": [ - { - "matcher": "*", - "hooks": [ - { - "name": "final-checker", - "command": "./check-completion.sh" - } - ] - } - ] - } -} -``` +- **`AfterAgent`**: Fires **once** per turn after the model finishes its final + response. Use this for quality validation (Retries) or final logging. +- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for + real-time redaction, PII filtering, or monitoring output as it streams. + +If you only need to check the final completion, use `AfterAgent` to save +performance. ### Filter with matchers Use specific matchers to avoid unnecessary hook execution. Instead of matching -all tools with `*`, specify only the tools you need: +all tools with `*`, specify only the tools you need. This saves the overhead of +spawning a process for irrelevant events. ```json { @@ -103,6 +91,7 @@ all tools with `*`, specify only the tools you need: "hooks": [ { "name": "validate-writes", + "type": "command", "command": "./validate.sh" } ] @@ -111,30 +100,32 @@ all tools with `*`, specify only the tools you need: ### Optimize JSON parsing -For large inputs, use streaming JSON parsers to avoid loading everything into -memory: - -```javascript -// Standard approach: parse entire input -const input = JSON.parse(await readStdin()); -const content = input.tool_input.content; - -// For very large inputs: stream and extract only needed fields -const { createReadStream } = require('fs'); -const JSONStream = require('JSONStream'); - -const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content')); -let content = ''; -stream.on('data', (chunk) => { - content += chunk; -}); -``` +For large inputs (like `AfterModel` receiving a large context), standard JSON +parsing can be slow. If you only need one field, consider streaming parsers or +lightweight extraction logic, though for most shell scripts `jq` is sufficient. ## Debugging +### The "Strict JSON" rule + +The most common cause of hook failure is "polluting" the standard output. + +- **stdout** is for **JSON only**. +- **stderr** is for **logs and text**. + +**Good:** + +```bash +#!/bin/bash +echo "Starting check..." >&2 # <--- Redirect to stderr +echo '{"decision": "allow"}' + +``` + ### Log to files -Write debug information to dedicated log files: +Since hooks run in the background, writing to a dedicated log file is often the +easiest way to debug complex logic. ```bash #!/usr/bin/env bash @@ -151,6 +142,9 @@ log "Received input: ${input:0:100}..." # Hook logic here log "Hook completed successfully" +# Always output valid JSON to stdout at the end, even if just empty +echo "{}" + ``` ### Use stderr for errors @@ -162,6 +156,7 @@ try { const result = dangerousOperation(); console.log(JSON.stringify({ result })); } catch (error) { + // Write the error description to stderr so the user/agent sees it console.error(`Hook error: ${error.message}`); process.exit(2); // Blocking error } @@ -169,7 +164,8 @@ try { ### Test hooks independently -Run hook scripts manually with sample JSON input: +Run hook scripts manually with sample JSON input to verify they behave as +expected before hooking them up to the CLI. ```bash # Create test input @@ -191,33 +187,46 @@ cat test-input.json | .gemini/hooks/my-hook.sh # Check exit code echo "Exit code: $?" + ``` ### Check exit codes -Ensure your script returns the correct exit code: +Gemini CLI uses exit codes for high-level flow control: + +- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for + JSON decisions. +- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the + reason. + - For **Agent/Model** events, this aborts the turn. + - For **Tool** events, this blocks the tool but allows the agent to continue. + - For **AfterAgent**, this triggers an automatic retry turn. + +> **TIP** +> +> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a +> **specific action**. Use `{"continue": false}` in your JSON output to **kill +> the entire agent loop** immediately. ```bash #!/usr/bin/env bash -set -e # Exit on error +set -e # Hook logic -process_input() { - # ... -} - if process_input; then - echo "Success message" + echo '{"decision": "allow"}' exit 0 else - echo "Error message" >&2 + echo "Critical validation failure" >&2 exit 2 fi + ``` ### Enable telemetry -Hook execution is logged when `telemetry.logPrompts` is enabled: +Hook execution is logged when `telemetry.logPrompts` is enabled. You can view +these logs to debug execution flow. ```json { @@ -227,11 +236,10 @@ Hook execution is logged when `telemetry.logPrompts` is enabled: } ``` -View hook telemetry in logs to debug execution issues. - ### Use hook panel -The `/hooks panel` command shows execution status and recent output: +The `/hooks panel` command inside the CLI shows execution status and recent +output: ```bash /hooks panel @@ -255,18 +263,64 @@ Begin with basic logging hooks before implementing complex logic: # Simple logging hook to understand input structure input=$(cat) echo "$input" >> .gemini/hook-inputs.log -echo "Logged input" +# Always return valid JSON +echo "{}" + +``` + +### Documenting your hooks + +Maintainability is critical for complex hook systems. Use descriptions and +comments to help yourself and others understand why a hook exists. + +**Use the `description` field**: This text is displayed in the `/hooks panel` UI +and helps diagnose issues. + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "write_file|replace", + "hooks": [ + { + "name": "secret-scanner", + "type": "command", + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", + "description": "Scans code changes for API keys and secrets before writing" + } + ] + } + ] + } +} +``` + +**Add comments in hook scripts**: Explain performance expectations and +dependencies. + +```javascript +#!/usr/bin/env node +/** + * RAG Tool Filter Hook + * + * Reduces the tool space by extracting keywords from the user's request. + * + * Performance: ~500ms average + * Dependencies: @google/generative-ai + */ ``` ### Use JSON libraries -Parse JSON with proper libraries instead of text processing: +Parse JSON with proper libraries instead of text processing. **Bad:** ```bash # Fragile text parsing tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') + ``` **Good:** @@ -274,6 +328,7 @@ tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+') ```bash # Robust JSON parsing tool_name=$(echo "$input" | jq -r '.tool_name') + ``` ### Make scripts executable @@ -283,6 +338,7 @@ Always make hook scripts executable: ```bash chmod +x .gemini/hooks/*.sh chmod +x .gemini/hooks/*.js + ``` ### Version control @@ -292,7 +348,7 @@ Commit hooks to share with your team: ```bash git add .gemini/hooks/ git add .gemini/settings.json -git commit -m "Add project hooks for security and testing" + ``` **`.gitignore` considerations:** @@ -306,295 +362,10 @@ git commit -m "Add project hooks for security and testing" # Keep hook scripts !.gemini/hooks/*.sh !.gemini/hooks/*.js + ``` -### Document behavior - -Add descriptions to help others understand your hooks: - -```json -{ - "hooks": { - "BeforeTool": [ - { - "matcher": "write_file|replace", - "hooks": [ - { - "name": "secret-scanner", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh", - "description": "Scans code changes for API keys, passwords, and other secrets before writing" - } - ] - } - ] - } -} -``` - -Add comments in hook scripts: - -```javascript -#!/usr/bin/env node -/** - * RAG Tool Filter Hook - * - * This hook reduces the tool space from 100+ tools to ~15 relevant ones - * by extracting keywords from the user's request and filtering tools - * based on semantic similarity. - * - * Performance: ~500ms average, cached tool embeddings - * Dependencies: @google/generative-ai - */ -``` - -## Troubleshooting - -### Hook not executing - -**Check hook name in `/hooks panel`:** - -```bash -/hooks panel -``` - -Verify the hook appears in the list and is enabled. - -**Verify matcher pattern:** - -```bash -# Test regex pattern -echo "write_file|replace" | grep -E "write_.*|replace" -``` - -**Check disabled list:** - -```json -{ - "hooks": { - "disabled": ["my-hook-name"] - } -} -``` - -**Ensure script is executable:** - -```bash -ls -la .gemini/hooks/my-hook.sh -chmod +x .gemini/hooks/my-hook.sh -``` - -**Verify script path:** - -```bash -# Check path expansion -echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" - -# Verify file exists -test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" -``` - -### Hook timing out - -**Check configured timeout:** - -```json -{ - "name": "slow-hook", - "timeout": 60000 -} -``` - -**Optimize slow operations:** - -```javascript -// Before: Sequential operations (slow) -for (const item of items) { - await processItem(item); -} - -// After: Parallel operations (fast) -await Promise.all(items.map((item) => processItem(item))); -``` - -**Use caching:** - -```javascript -const cache = new Map(); - -async function getCachedData(key) { - if (cache.has(key)) { - return cache.get(key); - } - const data = await fetchData(key); - cache.set(key, data); - return data; -} -``` - -**Consider splitting into multiple faster hooks:** - -```json -{ - "hooks": { - "BeforeTool": [ - { - "matcher": "write_file", - "hooks": [ - { - "name": "quick-check", - "command": "./quick-validation.sh", - "timeout": 1000 - } - ] - }, - { - "matcher": "write_file", - "hooks": [ - { - "name": "deep-check", - "command": "./deep-analysis.sh", - "timeout": 30000 - } - ] - } - ] - } -} -``` - -### Invalid JSON output - -**Validate JSON before outputting:** - -```bash -#!/usr/bin/env bash -output='{"decision": "allow"}' - -# Validate JSON -if echo "$output" | jq empty 2>/dev/null; then - echo "$output" -else - echo "Invalid JSON generated" >&2 - exit 1 -fi -``` - -**Ensure proper quoting and escaping:** - -```javascript -// Bad: Unescaped string interpolation -const message = `User said: ${userInput}`; -console.log(JSON.stringify({ message })); - -// Good: Automatic escaping -console.log(JSON.stringify({ message: `User said: ${userInput}` })); -``` - -**Check for binary data or control characters:** - -```javascript -function sanitizeForJSON(str) { - return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars -} - -const cleanContent = sanitizeForJSON(content); -console.log(JSON.stringify({ content: cleanContent })); -``` - -### Exit code issues - -**Verify script returns correct codes:** - -```bash -#!/usr/bin/env bash -set -e # Exit on error - -# Processing logic -if validate_input; then - echo "Success" - exit 0 -else - echo "Validation failed" >&2 - exit 2 -fi -``` - -**Check for unintended errors:** - -```bash -#!/usr/bin/env bash -# Don't use 'set -e' if you want to handle errors explicitly -# set -e - -if ! command_that_might_fail; then - # Handle error - echo "Command failed but continuing" >&2 -fi - -# Always exit explicitly -exit 0 -``` - -**Use trap for cleanup:** - -```bash -#!/usr/bin/env bash - -cleanup() { - # Cleanup logic - rm -f /tmp/hook-temp-* -} - -trap cleanup EXIT - -# Hook logic here -``` - -### Environment variables not available - -**Check if variable is set:** - -```bash -#!/usr/bin/env bash - -if [ -z "$GEMINI_PROJECT_DIR" ]; then - echo "GEMINI_PROJECT_DIR not set" >&2 - exit 1 -fi - -if [ -z "$CUSTOM_VAR" ]; then - echo "Warning: CUSTOM_VAR not set, using default" >&2 - CUSTOM_VAR="default-value" -fi -``` - -**Debug available variables:** - -```bash -#!/usr/bin/env bash - -# List all environment variables -env > .gemini/hook-env.log - -# Check specific variables -echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log -echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log -echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+}" >> .gemini/hook-env.log -``` - -**Use .env files:** - -```bash -#!/usr/bin/env bash - -# Load .env file if it exists -if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then - source "$GEMINI_PROJECT_DIR/.env" -fi -``` - -## Using Hooks Securely +## Hook security ### Threat Model @@ -621,11 +392,10 @@ When you open a project with hooks defined in `.gemini/settings.json`: it). 5. **Trust**: The hook is marked as "trusted" for this project. -> [!IMPORTANT] **Modification Detection**: If the `command` string of a project -> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will -> treat it as a **new, untrusted hook** and warn you again. This prevents -> malicious actors from silently swapping a verified command for a malicious -> one. +> **Modification detection**: If the `command` string of a project hook is +> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat +> it as a **new, untrusted hook** and warn you again. This prevents malicious +> actors from silently swapping a verified command for a malicious one. ### Risks @@ -646,32 +416,134 @@ When you open a project with hooks defined in `.gemini/settings.json`: publishers, well-known community members). - Be cautious with obfuscated scripts or compiled binaries from unknown sources. -#### Sanitize Environment +#### Sanitize environment Hooks inherit the environment of the Gemini CLI process, which may include -sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you -should be cautious. +sensitive API keys. Gemini CLI provides a +[redaction system](/docs/get-started/configuration#environment-variable-redaction) +that automatically filters variables matching sensitive patterns (e.g., `KEY`, +`TOKEN`). -- **Avoid printing environment variables** to stdout/stderr unless necessary. -- **Use `.env` files** to securely manage sensitive variables, ensuring they are - excluded from version control. +> **Disabled by Default**: Environment redaction is currently **OFF by +> default**. We strongly recommend enabling it if you are running third-party +> hooks or working in sensitive environments. -**System Administrators:** You can enforce environment variable redaction by -default in the system configuration (e.g., `/etc/gemini-cli/settings.json`): +**Impact on hooks:** + +- **Security**: Prevents your hook scripts from accidentally leaking secrets. +- **Troubleshooting**: If your hook depends on a specific environment variable + that is being blocked, you must explicitly allow it in `settings.json`. ```json { "security": { "environmentVariableRedaction": { "enabled": true, - "blocked": ["MY_SECRET_KEY"], - "allowed": ["SAFE_VAR"] + "allowed": ["MY_REQUIRED_TOOL_KEY"] } } } ``` -## Authoring Secure Hooks +**System administrators:** You can enforce redaction for all users in the system +configuration. + +## Troubleshooting + +### Hook not executing + +**Check hook name in `/hooks panel`:** Verify the hook appears in the list and +is enabled. + +**Verify matcher pattern:** + +```bash +# Test regex pattern +echo "write_file|replace" | grep -E "write_.*|replace" + +``` + +**Check disabled list:** Verify the hook is not listed in your `settings.json`: + +```json +{ + "hooks": { + "disabled": ["my-hook-name"] + } +} +``` + +**Ensure script is executable**: For macOS and Linux users, verify the script +has execution permissions: + +```bash +ls -la .gemini/hooks/my-hook.sh +chmod +x .gemini/hooks/my-hook.sh +``` + +**Verify script path:** Ensure the path in `settings.json` resolves correctly. + +```bash +# Check path expansion +echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" + +# Verify file exists +test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists" +``` + +### Hook timing out + +**Check configured timeout:** The default is 60000ms (1 minute). You can +increase this in `settings.json`: + +```json +{ + "name": "slow-hook", + "timeout": 120000 +} +``` + +**Optimize slow operations:** Move heavy processing to background tasks or use +caching. + +### Invalid JSON output + +**Validate JSON before outputting:** + +```bash +#!/usr/bin/env bash +output='{"decision": "allow"}' + +# Validate JSON +if echo "$output" | jq empty 2>/dev/null; then + echo "$output" +else + echo "Invalid JSON generated" >&2 + exit 1 +fi + +``` + +### Environment variables not available + +**Check if variable is set:** + +```bash +#!/usr/bin/env bash +if [ -z "$GEMINI_PROJECT_DIR" ]; then + echo "GEMINI_PROJECT_DIR not set" >&2 + exit 1 +fi + +``` + +**Debug available variables:** + +```bash +env > .gemini/hook-env.log +``` + +## Authoring secure hooks When writing your own hooks, follow these practices to ensure they are robust and secure. @@ -713,6 +585,7 @@ defaults to 60 seconds, but you should set stricter limits for fast hooks. "hooks": [ { "name": "fast-validator", + "type": "command", "command": "./hooks/validate.sh", "timeout": 5000 // 5 seconds } @@ -766,40 +639,17 @@ function containsSecret(content) { ## Privacy considerations -Hook inputs and outputs may contain sensitive information. Gemini CLI respects -the `telemetry.logPrompts` setting for hook data logging. +Hook inputs and outputs may contain sensitive information. ### What data is collected -Hook telemetry may include: - -- **Hook inputs:** User prompts, tool arguments, file contents -- **Hook outputs:** Hook responses, decision reasons, added context -- **Standard streams:** stdout and stderr from hook processes -- **Execution metadata:** Hook name, event type, duration, success/failure +Hook telemetry may include inputs (prompts, code) and outputs (decisions, +reasons) unless disabled. ### Privacy settings -**Enabled (default):** - -Full hook I/O is logged to telemetry. Use this when: - -- Developing and debugging hooks -- Telemetry is redirected to a trusted enterprise system -- You understand and accept the privacy implications - -**Disabled:** - -Only metadata is logged (event name, duration, success/failure). Hook inputs and -outputs are excluded. Use this when: - -- Sending telemetry to third-party systems -- Working with sensitive data -- Privacy regulations require minimizing data collection - -### Configuration - -**Disable PII logging in settings:** +**Disable PII logging:** If you are working with sensitive data, disable prompt +logging in your settings: ```json { @@ -809,48 +659,19 @@ outputs are excluded. Use this when: } ``` -**Disable via environment variable:** +**Suppress Output:** Individual hooks can request their metadata be hidden from +logs and telemetry by returning `"suppressOutput": true` in their JSON response. -```bash -export GEMINI_TELEMETRY_LOG_PROMPTS=false -``` +> **Note** + +> `suppressOutput` only affects background logging. Any `systemMessage` or +> `reason` included in the JSON will still be displayed to the user in the +> terminal. ### Sensitive data in hooks If your hooks process sensitive data: -1. **Minimize logging:** Don't write sensitive data to log files -2. **Sanitize outputs:** Remove sensitive data before outputting -3. **Use secure storage:** Encrypt sensitive data at rest -4. **Limit access:** Restrict hook script permissions - -**Example sanitization:** - -```javascript -function sanitizeOutput(data) { - const sanitized = { ...data }; - - // Remove sensitive fields - delete sanitized.apiKey; - delete sanitized.password; - - // Redact sensitive strings - if (sanitized.content) { - sanitized.content = sanitized.content.replace( - /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi, - '[REDACTED]', - ); - } - - return sanitized; -} - -console.log(JSON.stringify(sanitizeOutput(hookOutput))); -``` - -## Learn more - -- [Hooks Reference](index.md) - Complete API reference -- [Writing Hooks](writing-hooks.md) - Tutorial and examples -- [Configuration](../cli/configuration.md) - Gemini CLI settings -- [Hooks Design Document](../hooks-design.md) - Technical architecture +1. **Minimize logging:** Don't write sensitive data to log files. +2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing + to stderr. diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 48b30a721d..b19ceab438 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -4,91 +4,101 @@ Hooks are scripts or programs that Gemini CLI executes at specific points in the agentic loop, allowing you to intercept and customize behavior without modifying the CLI's source code. -See [writing hooks guide](writing-hooks.md) for a tutorial on creating your -first hook and a comprehensive example. - -See [hooks reference](reference.md) for the technical specification of the I/O -schemas. - -See [best practices](best-practices.md) for guidelines on security, performance, -and debugging. - ## What are hooks? -With hooks, you can: - -- **Add context:** Inject relevant information before the model processes a - request -- **Validate actions:** Review and block potentially dangerous operations -- **Enforce policies:** Implement security and compliance requirements -- **Log interactions:** Track tool usage and model responses -- **Optimize behavior:** Dynamically adjust tool selection or model parameters - Hooks run synchronously as part of the agent loopโ€”when a hook event fires, Gemini CLI waits for all matching hooks to complete before continuing. -## Security and Risks +With hooks, you can: -> [!WARNING] **Hooks execute arbitrary code with your user privileges.** +- **Add context:** Inject relevant information (like git history) before the + model processes a request. +- **Validate actions:** Review tool arguments and block potentially dangerous + operations. +- **Enforce policies:** Implement security scanners and compliance checks. +- **Log interactions:** Track tool usage and model responses for auditing. +- **Optimize behavior:** Dynamically filter available tools or adjust model + parameters. -By configuring hooks, you are explicitly allowing Gemini CLI to run shell -commands on your machine. Malicious or poorly configured hooks can: +### Getting started -- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to - remote servers. -- **Modify system**: Delete files, install malware, or change system settings. -- **Consume resources**: Run infinite loops or crash your system. - -**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are -particularly risky when opening third-party projects or extensions from -untrusted authors. Gemini CLI will **warn you** the first time it detects a new -project hook (identified by its name and command), but it is **your -responsibility** to review these hooks (and any installed extensions) before -trusting them. - -See [Security Considerations](best-practices.md#using-hooks-securely) for a -detailed threat model and mitigation strategies. +- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating + your first hook with comprehensive examples. +- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security, + performance, and debugging. +- **[Hooks reference](/docs/hooks/reference)**: The definitive technical + specification of I/O schemas and exit codes. ## Core concepts ### Hook events -Hooks are triggered by specific events in Gemini CLI's lifecycle. The following -table lists all available hook events: +Hooks are triggered by specific events in Gemini CLI's lifecycle. -| Event | When It Fires | Common Use Cases | -| --------------------- | --------------------------------------------- | ------------------------------------------ | -| `SessionStart` | When a session begins | Initialize resources, load context | -| `SessionEnd` | When a session ends | Clean up, save state | -| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts | -| `AfterAgent` | When agent loop ends | Review output, force continuation | -| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions | -| `AfterModel` | After receiving LLM response | Filter responses, log interactions | -| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection | -| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops | -| `AfterTool` | After a tool executes | Process results, run tests | -| `PreCompress` | Before context compression | Save state, notify user | -| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions | +| Event | When It Fires | Impact | Common Use Cases | +| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- | +| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context | +| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state | +| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns | +| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution | +| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses | +| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions | +| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection | +| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops | +| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results | +| `PreCompress` | Before context compression | Advisory | Save state, notify user | +| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging | -### Hook types +### Global mechanics -Gemini CLI currently supports **command hooks** that run shell commands or -scripts: +Understanding these core principles is essential for building robust hooks. -```json -{ - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh", - "timeout": 30000 -} -``` +#### Strict JSON requirements (The "Golden Rule") -**Note:** Plugin hooks (npm packages) are planned for a future release. +Hooks communicate via `stdin` (Input) and `stdout` (Output). -### Matchers +1. **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON object. **Even a single `echo` or `print` + call before the JSON will break parsing.** +2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will + fail. The CLI will default to "Allow" and treat the entire output as a + `systemMessage`. +3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g., + `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse + it as JSON. -For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools -trigger the hook: +#### Exit codes + +Gemini CLI uses exit codes to determine the high-level outcome of a hook +execution: + +| Exit Code | Label | Behavioral Impact | +| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). | +| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. | +| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. | + +#### Matchers + +You can filter which specific tools or triggers fire your hook using the +`matcher` field. + +- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular + Expressions**. (e.g., `"write_.*"`). +- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`). +- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences. + +## Configuration + +Hooks are configured in `settings.json`. Gemini CLI merges configurations from +multiple layers in the following order of precedence (highest to lowest): + +1. **Project settings**: `.gemini/settings.json` in the current directory. +2. **User settings**: `~/.gemini/settings.json`. +3. **System settings**: `/etc/gemini-cli/settings.json`. +4. **Extensions**: Hooks defined by installed extensions. + +### Configuration schema ```json { @@ -96,381 +106,12 @@ trigger the hook: "BeforeTool": [ { "matcher": "write_file|replace", - "hooks": [ - /* hooks for write operations */ - ] - } - ] - } -} -``` - -**Matcher patterns:** - -- **Exact match:** `"read_file"` matches only `read_file` -- **Regex:** `"write_.*|replace"` matches `write_file`, `replace` -- **Wildcard:** `"*"` or `""` matches all tools - -**Session event matchers:** - -- **SessionStart:** `startup`, `resume`, `clear` -- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit` -- **PreCompress:** `manual`, `auto` -- **Notification:** `ToolPermission` - -## Hook input/output contract - -### Command hook communication - -Hooks communicate via: - -- **Input:** JSON on stdin -- **Output:** Exit code + stdout/stderr - -### Exit codes - -- **0:** Success - stdout shown to user (or injected as context for some events) -- **2:** Blocking error - stderr shown to agent/user, operation may be blocked -- **Other:** Non-blocking warning - logged but execution continues - -### Common input fields - -Every hook receives these base fields: - -```json -{ - "session_id": "abc123", - "transcript_path": "/path/to/transcript.jsonl", - "cwd": "/path/to/project", - "hook_event_name": "BeforeTool", - "timestamp": "2025-12-01T10:30:00Z" - // ... event-specific fields -} -``` - -### Event-specific fields - -#### BeforeTool - -**Input:** - -```json -{ - "tool_name": "write_file", - "tool_input": { - "file_path": "/path/to/file.ts", - "content": "..." - } -} -``` - -**Output (JSON on stdout):** - -```json -{ - "decision": "allow|deny|ask|block", - "reason": "Explanation shown to agent", - "systemMessage": "Message shown to user" -} -``` - -Or simple exit codes: - -- Exit 0 = allow (stdout shown to user) -- Exit 2 = deny (stderr shown to agent) - -#### AfterTool - -**Input:** - -```json -{ - "tool_name": "read_file", - "tool_input": { "file_path": "..." }, - "tool_response": "file contents..." -} -``` - -**Output:** - -```json -{ - "decision": "allow|deny", - "hookSpecificOutput": { - "hookEventName": "AfterTool", - "additionalContext": "Extra context for agent" - } -} -``` - -#### BeforeAgent - -**Input:** - -```json -{ - "prompt": "Fix the authentication bug" -} -``` - -**Output:** - -```json -{ - "decision": "allow|deny", - "hookSpecificOutput": { - "hookEventName": "BeforeAgent", - "additionalContext": "Recent project decisions: ..." - } -} -``` - -#### BeforeModel - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [{ "role": "user", "content": "Hello" }], - "config": { "temperature": 0.7 }, - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO", - "allowedFunctionNames": ["read_file", "write_file"] - } - } - } -} -``` - -**Output:** - -```json -{ - "decision": "allow", - "hookSpecificOutput": { - "hookEventName": "BeforeModel", - "llm_request": { - "messages": [ - { "role": "system", "content": "Additional instructions..." }, - { "role": "user", "content": "Hello" } - ] - } - } -} -``` - -#### AfterModel - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [ - /* ... */ - ], - "config": { - /* ... */ - }, - "toolConfig": { - /* ... */ - } - }, - "llm_response": { - "text": "string", - "candidates": [ - { - "content": { - "role": "model", - "parts": ["array of content parts"] - }, - "finishReason": "STOP" - } - ] - } -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "AfterModel", - "llm_response": { - "candidate": { - /* modified response */ - } - } - } -} -``` - -#### BeforeToolSelection - -**Input:** - -```json -{ - "llm_request": { - "model": "gemini-2.0-flash-exp", - "messages": [ - /* ... */ - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO", - "allowedFunctionNames": [ - /* 100+ tools */ - ] - } - } - } -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "BeforeToolSelection", - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": ["read_file", "write_file", "replace"] - } - } - } -} -``` - -Or simple output (comma-separated tool names sets mode to ANY): - -```bash -echo "read_file,write_file,replace" -``` - -#### SessionStart - -**Input:** - -```json -{ - "source": "startup|resume|clear" -} -``` - -**Output:** - -```json -{ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "Loaded 5 project memories" - } -} -``` - -#### SessionEnd - -**Input:** - -```json -{ - "reason": "exit|clear|logout|prompt_input_exit|other" -} -``` - -No structured output expected (but stdout/stderr logged). - -#### PreCompress - -**Input:** - -```json -{ - "trigger": "manual|auto" -} -``` - -**Output:** - -```json -{ - "systemMessage": "Compression starting..." -} -``` - -#### Notification - -**Input:** - -```json -{ - "notification_type": "ToolPermission", - "message": "string", - "details": { - /* notification details */ - } -} -``` - -**Output:** - -```json -{ - "systemMessage": "Notification logged" -} -``` - -## Configuration - -Hook definitions are configured in `settings.json` files using the `hooks` -object. Configuration can be specified at multiple levels with defined -precedence rules. - -### Configuration layers - -Hook configurations are applied in the following order of execution (lower -numbers run first): - -1. **Project settings:** `.gemini/settings.json` in your project directory - (highest priority) -2. **User settings:** `~/.gemini/settings.json` -3. **System settings:** `/etc/gemini-cli/settings.json` -4. **Extensions:** Internal hooks defined by installed extensions (lowest - priority) - -#### Deduplication and shadowing - -If multiple hooks with the identical **name** and **command** are discovered -across different configuration layers, Gemini CLI deduplicates them. The hook -from the higher-priority layer (e.g., Project) will be kept, and others will be -ignored. - -Within each level, hooks run in the order they are declared in the -configuration. - -### Configuration schema - -```json -{ - "hooks": { - "EventName": [ - { - "matcher": "pattern", "hooks": [ { - "name": "hook-identifier", + "name": "security-check", "type": "command", - "command": "./path/to/script.sh", - "description": "What this hook does", - "timeout": 30000 + "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh", + "timeout": 5000 } ] } @@ -479,209 +120,45 @@ configuration. } ``` -**Configuration properties:** +#### Hook configuration fields -- **`name`** (string, recommended): Unique identifier for the hook used in - `/hooks enable/disable` commands. If omitted, the `command` path is used as - the identifier. -- **`type`** (string, required): Hook type - currently only `"command"` is - supported -- **`command`** (string, required): Path to the script or command to execute -- **`description`** (string, optional): Human-readable description shown in - `/hooks panel` -- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000) -- **`matcher`** (string, optional): Pattern to filter when hook runs (event - matchers only) +| Field | Type | Required | Description | +| :------------ | :----- | :-------- | :------------------------------------------------------------------- | +| `type` | string | **Yes** | The execution engine. Currently only `"command"` is supported. | +| `command` | string | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | +| `name` | string | No | A friendly name for identifying the hook in logs and CLI commands. | +| `timeout` | number | No | Execution timeout in milliseconds (default: 60000). | +| `description` | string | No | A brief explanation of the hook's purpose. | + +--- ### Environment variables -Hooks have access to: +Hooks are executed with a sanitized environment. -- `GEMINI_PROJECT_DIR`: Project root directory -- `GEMINI_SESSION_ID`: Current session ID -- `GEMINI_API_KEY`: Gemini API key (if configured) -- All other environment variables from the parent process +- `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_SESSION_ID`: The unique ID for the current session. +- `GEMINI_CWD`: The current working directory. +- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. + +## Security and risks + +> **Warning: Hooks execute arbitrary code with your user privileges.** By +> configuring hooks, you are allowing scripts to run shell commands on your +> machine. + +**Project-level hooks** are particularly risky when opening untrusted projects. +Gemini CLI **fingerprints** project hooks. If a hook's name or command changes +(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will +be warned before it executes. + +See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely) +for a detailed threat model. ## Managing hooks -### View registered hooks +Use the CLI commands to manage hooks without editing JSON manually: -Use the `/hooks panel` command to view all registered hooks: - -```bash -/hooks panel -``` - -This command displays: - -- All active hooks organized by event -- Hook source (user, project, system) -- Hook type (command or plugin) -- Execution status and recent output - -### Enable and disable hooks - -You can temporarily enable or disable individual hooks using commands: - -```bash -/hooks enable hook-name -/hooks disable hook-name -``` - -These commands allow you to control hook execution without editing configuration -files. The hook name should match the `name` field in your hook configuration. -Changes made via these commands are persisted to your global User settings -(`~/.gemini/settings.json`). - -### Disabled hooks configuration - -To permanently disable hooks, add them to the `hooks.disabled` array in your -`settings.json`: - -```json -{ - "hooks": { - "disabled": ["secret-scanner", "auto-test"] - } -} -``` - -**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks -from all configuration levels (user, project, system) are combined and -deduplicated, meaning a hook disabled at any level remains disabled. - -## Migration from Claude Code - -If you have hooks configured for Claude Code, you can migrate them: - -```bash -gemini hooks migrate --from-claude -``` - -This command: - -- Reads `.claude/settings.json` -- Converts event names (`PreToolUse` โ†’ `BeforeTool`, etc.) -- Translates tool names (`Bash` โ†’ `run_shell_command`, `replace` โ†’ `replace`) -- Updates matcher patterns -- Writes to `.gemini/settings.json` - -### Event name mapping - -| Claude Code | Gemini CLI | -| ------------------ | -------------- | -| `PreToolUse` | `BeforeTool` | -| `PostToolUse` | `AfterTool` | -| `UserPromptSubmit` | `BeforeAgent` | -| `Stop` | `AfterAgent` | -| `Notification` | `Notification` | -| `SessionStart` | `SessionStart` | -| `SessionEnd` | `SessionEnd` | -| `PreCompact` | `PreCompress` | - -### Tool name mapping - -| Claude Code | Gemini CLI | -| ----------- | --------------------- | -| `Bash` | `run_shell_command` | -| `Edit` | `replace` | -| `Read` | `read_file` | -| `Write` | `write_file` | -| `Glob` | `glob` | -| `Grep` | `search_file_content` | -| `LS` | `list_directory` | - -## Tool and Event Matchers Reference - -### Available tool names for matchers - -The following built-in tools can be used in `BeforeTool` and `AfterTool` hook -matchers: - -#### File operations - -- `read_file` - Read a single file -- `read_many_files` - Read multiple files at once -- `write_file` - Create or overwrite a file -- `replace` - Edit file content with find/replace - -#### File system - -- `list_directory` - List directory contents -- `glob` - Find files matching a pattern -- `search_file_content` - Search within file contents - -#### Execution - -- `run_shell_command` - Execute shell commands - -#### Web and external - -- `google_web_search` - Google Search with grounding -- `web_fetch` - Fetch web page content - -#### Agent features - -- `write_todos` - Manage TODO items -- `save_memory` - Save information to memory -- `delegate_to_agent` - Delegate tasks to sub-agents - -#### Example matchers - -```json -{ - "matcher": "write_file|replace" // File editing tools -} -``` - -```json -{ - "matcher": "read_.*" // All read operations -} -``` - -```json -{ - "matcher": "run_shell_command" // Only shell commands -} -``` - -```json -{ - "matcher": "*" // All tools -} -``` - -### Event-specific matchers - -#### SessionStart event matchers - -- `startup` - Fresh session start -- `resume` - Resuming a previous session -- `clear` - Session cleared - -#### SessionEnd event matchers - -- `exit` - Normal exit -- `clear` - Session cleared -- `logout` - User logged out -- `prompt_input_exit` - Exit from prompt input -- `other` - Other reasons - -#### PreCompress event matchers - -- `manual` - Manually triggered compression -- `auto` - Automatically triggered compression - -#### Notification event matchers - -- `ToolPermission` - Tool permission notifications - -## Learn more - -- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example -- [Best Practices](best-practices.md) - Security, performance, and debugging -- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt - shortcuts -- [Configuration](../cli/configuration.md) - Gemini CLI configuration options -- [Hooks Design Document](../hooks-design.md) - Technical architecture details +- **View hooks:** `/hooks panel` +- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all` +- **Toggle individual:** `/hooks enable ` or `/hooks disable ` diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index bc7b6e5fa2..a86474ea85 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -1,168 +1,322 @@ -# Hooks Reference +# Hooks reference This document provides the technical specification for Gemini CLI hooks, -including the JSON schemas for input and output, exit code behaviors, and the -stable model API. +including JSON schemas and API details. -## Communication Protocol +## Global hook mechanics -Hooks communicate with Gemini CLI via standard streams and exit codes: - -- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`. -- **Output**: The hook sends a JSON object (or plain text) to `stdout`. -- **Exit Codes**: Used to signal success or blocking errors. - -### Exit Code Behavior - -| Exit Code | Meaning | Behavior | -| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- | -| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. | -| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. | -| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. | +- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and + `stderr` for logs and feedback. +- **Exit codes**: + - `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.** + - `2`: System Block. The action is blocked; `stderr` is used as the rejection + reason. + - `Other`: Warning. A non-fatal failure occurred; the CLI continues with a + warning. +- **Silence is Mandatory**: Your script **must not** print any plain text to + `stdout` other than the final JSON. --- -## Input Schema (`stdin`) +## Configuration schema -Every hook receives a base JSON object. Extra fields are added depending on the -specific event. +Hooks are defined in `settings.json` within the `hooks` object. Each event +(e.g., `BeforeTool`) contains an array of **hook definitions**. -### Base Fields (All Events) +### Hook definition -| Field | Type | Description | -| :---------------- | :------- | :---------------------------------------------------- | -| `session_id` | `string` | Unique identifier for the current CLI session. | -| `transcript_path` | `string` | Path to the session's JSON transcript (if available). | -| `cwd` | `string` | The current working directory. | -| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). | -| `timestamp` | `string` | ISO 8601 timestamp of the event. | +| Field | Type | Required | Description | +| :----------- | :-------- | :------- | :-------------------------------------------------------------------------------------- | +| `matcher` | `string` | No | A regex (for tools) or exact string (for lifecycle) to filter when the hook runs. | +| `sequential` | `boolean` | No | If `true`, hooks in this group run one after another. If `false`, they run in parallel. | +| `hooks` | `array` | **Yes** | An array of **hook configurations**. | -### Event-Specific Fields +### Hook configuration -#### Tool Events (`BeforeTool`, `AfterTool`) - -- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`, - `run_shell_command`). -- `tool_input`: (`object`) The arguments passed to the tool. -- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool - execution. - -#### Agent Events (`BeforeAgent`, `AfterAgent`) - -- `prompt`: (`string`) The user's submitted prompt. -- `prompt_response`: (`string`, **AfterAgent only**) The final response text - from the model. -- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook - is already handling a continuation. - -#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`) - -- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request. - See [Stable Model API](#stable-model-api). -- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation - of the incoming response. - -#### Session & Notification Events - -- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger - source. -- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`, - **SessionEnd only**) The reason for session end. -- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the - compression event. -- `notification_type`: (`ToolPermission`, **Notification only**) The type of - notification being fired. -- `message`: (`string`, **Notification only**) The notification message. -- `details`: (`object`, **Notification only**) Payload-specific details for the - notification. +| Field | Type | Required | Description | +| :------------ | :------- | :-------- | :------------------------------------------------------------------- | +| `type` | `string` | **Yes** | The execution engine. Currently only `"command"` is supported. | +| `command` | `string` | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). | +| `name` | `string` | No | A friendly name for identifying the hook in logs and CLI commands. | +| `timeout` | `number` | No | Execution timeout in milliseconds (default: 60000). | +| `description` | `string` | No | A brief explanation of the hook's purpose. | --- -## Output Schema (`stdout`) +## Base input schema -If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON. +All hooks receive these common fields via `stdin`: -### Common Output Fields +```typescript +{ + "session_id": string, // Unique ID for the current session + "transcript_path": string, // Absolute path to session transcript JSON + "cwd": string, // Current working directory + "hook_event_name": string, // The firing event (e.g. "BeforeTool") + "timestamp": string // ISO 8601 execution time +} +``` -| Field | Type | Description | -| :------------------- | :-------- | :----------------------------------------------------------------------- | -| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. | -| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. | -| `systemMessage` | `string` | Message displayed to the **user** in the CLI terminal. | -| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. | -| `stopReason` | `string` | Message shown to the user when `continue` is `false`. | -| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. | -| `hookSpecificOutput` | `object` | Container for event-specific data (see below). | +--- -### `hookSpecificOutput` Reference +## Common output fields -| Field | Supported Events | Description | -| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- | -| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. | -| `llm_request` | `BeforeModel` | A `Partial` to override parameters of the outgoing call. | -| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. | -| `llm_response` | `AfterModel` | A `Partial` to modify the model's response before the agent sees it. | -| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. | +Most hooks support these fields in their `stdout` JSON: + +| Field | Type | Description | +| :--------------- | :-------- | :----------------------------------------------------------------------------- | +| `systemMessage` | `string` | Displayed immediately to the user in the terminal. | +| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. | +| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. | +| `stopReason` | `string` | Displayed to the user when `continue` is `false`. | +| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. | +| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. | + +--- + +## Tool hooks + +### Matchers and tool names + +For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is +compared against the name of the tool being executed. + +- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, + `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list + of available tool names. +- **MCP Tools**: Tools from MCP servers follow the naming pattern + `mcp____`. +- **Regex Support**: Matchers support regular expressions (e.g., + `matcher: "read_.*"` matches all file reading tools). + +### `BeforeTool` + +Fires before a tool is invoked. Used for argument validation, security checks, +and parameter rewriting. + +- **Input Fields**: + - `tool_name`: (`string`) The name of the tool being called. + - `tool_input`: (`object`) The raw arguments generated by the model. + - `mcp_context`: (`object`) Optional metadata for MCP-based tools. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from + executing. + - `reason`: Required if denied. This text is sent **to the agent** as a tool + error, allowing it to respond or retry. + - `hookSpecificOutput.tool_input`: An object that **merges with and + overrides** the model's arguments before execution. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the + `reason` sent to the agent. **The turn continues.** + +### `AfterTool` + +Fires after a tool executes. Used for result auditing, context injection, or +hiding sensitive output from the agent. + +- **Input Fields**: + - `tool_name`: (`string`) + - `tool_input`: (`object`) The original arguments. + - `tool_response`: (`object`) The result containing `llmContent`, + `returnDisplay`, and optional `error`. + - `mcp_context`: (`object`) +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to hide the real tool output from the agent. + - `reason`: Required if denied. This text **replaces** the tool result sent + back to the model. + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + tool result for the agent. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the + replacement content sent to the agent. **The turn continues.** + +--- + +## Agent hooks + +### `BeforeAgent` + +Fires after a user submits a prompt, but before the agent begins planning. Used +for prompt validation or injecting dynamic context. + +- **Input Fields**: + - `prompt`: (`string`) The original text submitted by the user. +- **Relevant Output Fields**: + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + prompt for this turn only. + - `decision`: Set to `"deny"` to block the turn and **discard the user's + message** (it will not appear in history). + - `continue`: Set to `false` to block the turn but **save the message to + history**. + - `reason`: Required if denied or stopped. +- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from + context. Same as `decision: "deny"`. + +### `AfterAgent` + +Fires once per turn after the model generates its final response. Primary use +case is response validation and automatic retries. + +- **Input Fields**: + - `prompt`: (`string`) The user's original request. + - `prompt_response`: (`string`) The final text generated by the agent. + - `stop_hook_active`: (`boolean`) Indicates if this hook is already running as + part of a retry sequence. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to **reject the response** and force a retry. + - `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. +- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry + turn using `stderr` as the feedback prompt. + +--- + +## Model hooks + +### `BeforeModel` + +Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic +request format. + +- **Input Fields**: + - `llm_request`: (`object`) Contains `model`, `messages`, and `config` + (generation params). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the + outgoing request (e.g., changing models or temperature). + - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If + provided, the CLI skips the LLM call entirely and uses this as the response. + - `decision`: Set to `"deny"` to block the request and abort the turn. +- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses + `stderr` as the error message. + +### `BeforeToolSelection` + +Fires before the LLM decides which tools to call. Used to filter the available +toolset or force specific tool modes. + +- **Input Fields**: + - `llm_request`: (`object`) Same format as `BeforeModel`. +- **Relevant Output Fields**: + - `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`) + - `"NONE"`: Disables all tools (Wins over other hooks). + - `"ANY"`: Forces at least one tool call. + - `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist + of tool names. +- **Union Strategy**: Multiple hooks' whitelists are **combined**. +- **Limitations**: Does **not** support `decision`, `continue`, or + `systemMessage`. + +### `AfterModel` + +Fires immediately after an LLM response chunk is received. Used for real-time +redaction or PII filtering. + +- **Input Fields**: + - `llm_request`: (`object`) The original request. + - `llm_response`: (`object`) The model's response (or a single chunk during + streaming). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_response`: An object that **replaces** the model's + response chunk. + - `decision`: Set to `"deny"` to discard the response chunk and block the + turn. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Note on Streaming**: Fired for **every chunk** generated by the model. + Modifying the response only affects the current chunk. +- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's + output. Uses `stderr` as the error message. + +--- + +## Lifecycle & system hooks + +### `SessionStart` + +Fires on application startup, resuming a session, or after a `/clear` command. +Used for loading initial context. + +- **Input fields**: + - `source`: (`"startup" | "resume" | "clear"`) +- **Relevant output fields**: + - `hookSpecificOutput.additionalContext`: (`string`) + - **Interactive**: Injected as the first turn in history. + - **Non-interactive**: Prepended to the user's prompt. + - `systemMessage`: Shown at the start of the session. +- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup + is never blocked. + +### `SessionEnd` + +Fires when the CLI exits or a session is cleared. Used for cleanup or final +telemetry. + +- **Input Fields**: + - `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user during shutdown. +- **Best Effort**: The CLI **will not wait** for this hook to complete and + ignores all flow-control fields (`continue`, `decision`). + +### `Notification` + +Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for +external logging or cross-platform alerts. + +- **Input Fields**: + - `notification_type`: (`"ToolPermission"`) + - `message`: Summary of the alert. + - `details`: JSON object with alert-specific metadata (e.g., tool name, file + path). +- **Relevant Output Fields**: + - `systemMessage`: Displayed alongside the system alert. +- **Observability Only**: This hook **cannot** block alerts or grant permissions + automatically. Flow-control fields are ignored. + +### `PreCompress` + +Fires before the CLI summarizes history to save tokens. Used for logging or +state saving. + +- **Input Fields**: + - `trigger`: (`"auto" | "manual"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user before compression. +- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the + compression process. Flow-control fields are ignored. --- ## Stable Model API -Gemini CLI uses a decoupled format for model interactions to ensure hooks remain -stable even if the underlying Gemini SDK changes. +Gemini CLI uses these structures to ensure hooks don't break across SDK updates. -### `LLMRequest` Object - -Used in `BeforeModel` and `BeforeToolSelection`. - -> ๐Ÿ’ก **Note**: In v1, model hooks are primarily text-focused. Non-text parts -> (like images or function calls) provided in the `content` array will be -> simplified to their string representation by the translator. +**LLMRequest**: ```typescript { "model": string, "messages": Array<{ "role": "user" | "model" | "system", - "content": string | Array<{ "type": string, [key: string]: any }> + "content": string // Non-text parts are filtered out for hooks }>, - "config"?: { - "temperature"?: number, - "maxOutputTokens"?: number, - "topP"?: number, - "topK"?: number - }, - "toolConfig"?: { - "mode"?: "AUTO" | "ANY" | "NONE", - "allowedFunctionNames"?: string[] - } + "config": { "temperature": number, ... }, + "toolConfig": { "mode": string, "allowedFunctionNames": string[] } } + ``` -### `LLMResponse` Object - -Used in `AfterModel` and as a synthetic response in `BeforeModel`. +**LLMResponse**: ```typescript { - "text"?: string, "candidates": Array<{ - "content": { - "role": "model", - "parts": string[] - }, - "finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER", - "index"?: number, - "safetyRatings"?: Array<{ - "category": string, - "probability": string, - "blocked"?: boolean - }> + "content": { "role": "model", "parts": string[] }, + "finishReason": string }>, - "usageMetadata"?: { - "promptTokenCount"?: number, - "candidatesTokenCount"?: number, - "totalTokenCount"?: number - } + "usageMetadata": { "totalTokenCount": number } } ``` diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md index 12fcb69758..33357fccb2 100644 --- a/docs/hooks/writing-hooks.md +++ b/docs/hooks/writing-hooks.md @@ -1,8 +1,7 @@ # Writing hooks for Gemini CLI This guide will walk you through creating hooks for Gemini CLI, from a simple -logging hook to a comprehensive workflow assistant that demonstrates all hook -events working together. +logging hook to a comprehensive workflow assistant. ## Prerequisites @@ -17,9 +16,17 @@ Before you start, make sure you have: Let's create a simple hook that logs all tool executions to understand the basics. +**Crucial Rule:** Always write logs to `stderr`. Write only the final JSON to +`stdout`. + ### Step 1: Create your hook script -Create a directory for hooks and a simple logging script: +Create a directory for hooks and a simple logging script. + +> **Note**: +> +> This example uses `jq` to parse JSON. If you don't have it installed, you can +> perform similar logic using Node.js or Python. ```bash mkdir -p .gemini/hooks @@ -28,62 +35,38 @@ cat > .gemini/hooks/log-tools.sh << 'EOF' # Read hook input from stdin input=$(cat) -# Extract tool name +# Extract tool name (requires jq) tool_name=$(echo "$input" | jq -r '.tool_name') +# Log to stderr (visible in terminal if hook fails, or captured in logs) +echo "Logging tool: $tool_name" >&2 + # Log to file echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt -# Return success (exit 0) - output goes to user in transcript mode -echo "Logged: $tool_name" +# Return success (exit 0) with empty JSON +echo "{}" +exit 0 EOF chmod +x .gemini/hooks/log-tools.sh ``` -### Step 2: Configure the hook +## Exit Code Strategies -Add the hook configuration to `.gemini/settings.json`: +There are two ways to control or block an action in Gemini CLI: -```json -{ - "hooks": { - "AfterTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "tool-logger", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh", - "description": "Log all tool executions" - } - ] - } - ] - } -} -``` - -### Step 3: Test your hook - -Run Gemini CLI and execute any command that uses tools: - -``` -> Read the README.md file - -[Agent uses read_file tool] - -Logged: read_file -``` - -Check `.gemini/tool-log.txt` to see the logged tool executions. +| Strategy | Exit Code | Implementation | Best For | +| :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- | +| **Structured (Idiomatic)** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}`. | Production hooks, custom user feedback, and complex logic. | +| **Emergency Brake** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. | ## Practical examples ### Security: Block secrets in commits -Prevent committing files containing API keys or passwords. +Prevent committing files containing API keys or passwords. Note that we use +**Exit Code 0** to provide a structured denial message to the agent. **`.gemini/hooks/block-secrets.sh`:** @@ -96,93 +79,26 @@ content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string / # Check for secrets if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then - echo '{"decision":"deny","reason":"Potential secret detected"}' >&2 - exit 2 -fi + # Log to stderr + echo "Blocked potential secret" >&2 -exit 0 -``` - -**`.gemini/settings.json`:** - -```json + # Return structured denial to stdout + cat <&1 | head -20; then - echo "โœ… Tests passed" -else - echo "โŒ Tests failed" -fi - +# Allow +echo '{"decision": "allow"}' exit 0 ``` -**`.gemini/settings.json`:** - -```json -{ - "hooks": { - "AfterTool": [ - { - "matcher": "write_file|replace", - "hooks": [ - { - "name": "auto-test", - "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.sh", - "description": "Run tests after code changes" - } - ] - } - ] - } -} -``` - -### Dynamic context injection +### Dynamic context injection (Git History) Add relevant project context before each agent interaction. @@ -205,20 +121,81 @@ cat < m.role === 'user'); + + if (!lastUserMessage) { + console.log(JSON.stringify({})); // Do nothing + return; + } + + const text = lastUserMessage.content; + const allowed = ['write_todos']; // Always allow memory + + // Simple keyword matching + if (text.includes('read') || text.includes('check')) { + allowed.push('read_file', 'list_directory'); + } + if (text.includes('test')) { + allowed.push('run_shell_command'); + } + + // If we found specific intent, filter tools. Otherwise allow all. + if (allowed.length > 1) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', // Force usage of one of these tools (or AUTO) + allowedFunctionNames: allowed, + }, + }, + }), + ); + } else { + console.log(JSON.stringify({})); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + **`.gemini/settings.json`:** ```json { "hooks": { - "BeforeAgent": [ + "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { - "name": "git-context", + "name": "intent-filter", "type": "command", - "command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh", - "description": "Inject git commit history" + "command": "node .gemini/hooks/filter-tools.js" } ] } @@ -227,113 +204,30 @@ EOF } ``` -## Advanced features - -### RAG-based tool filtering - -Use `BeforeToolSelection` to intelligently reduce the tool space based on the -current task. Instead of sending all 100+ tools to the model, filter to the most -relevant ~15 tools using semantic search or keyword matching. - -This improves: - -- **Model accuracy:** Fewer similar tools reduce confusion -- **Response speed:** Smaller tool space is faster to process -- **Cost efficiency:** Less tokens used per request - -### Cross-session memory - -Use `SessionStart` and `SessionEnd` hooks to maintain persistent knowledge -across sessions: - -- **SessionStart:** Load relevant memories from previous sessions -- **AfterModel:** Record important interactions during the session -- **SessionEnd:** Extract learnings and store for future use - -This enables the assistant to learn project conventions, remember important -decisions, and share knowledge across team members. - -### Hook chaining - -Multiple hooks for the same event run in the order declared. Each hook can build -upon previous hooks' outputs: - -```json -{ - "hooks": { - "BeforeAgent": [ - { - "matcher": "*", - "hooks": [ - { - "name": "load-memories", - "type": "command", - "command": "./hooks/load-memories.sh" - }, - { - "name": "analyze-sentiment", - "type": "command", - "command": "./hooks/analyze-sentiment.sh" - } - ] - } - ] - } -} -``` +> **TIP** +> +> **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it +> combines the results of all matching hooks. If you have multiple filtering +> hooks, the agent will receive the **union** of all whitelisted tools. Only +> using `mode: "NONE"` will override other hooks to disable all tools. ## Complete example: Smart Development Workflow Assistant -This comprehensive example demonstrates all hook events working together with -two advanced features: - -- **RAG-based tool selection:** Reduces 100+ tools to ~15 relevant ones per task -- **Cross-session memory:** Learns and persists project knowledge +This comprehensive example demonstrates all hook events working together. We +will build a system that maintains memory, filters tools, and checks for +security. ### Architecture -``` -SessionStart โ†’ Initialize memory & index tools - โ†“ -BeforeAgent โ†’ Inject relevant memories - โ†“ -BeforeModel โ†’ Add system instructions - โ†“ -BeforeToolSelection โ†’ Filter tools via RAG - โ†“ -BeforeTool โ†’ Validate security - โ†“ -AfterTool โ†’ Run auto-tests - โ†“ -AfterModel โ†’ Record interaction - โ†“ -SessionEnd โ†’ Extract and store memories -``` +1. **SessionStart**: Load project memories. +2. **BeforeAgent**: Inject memories into context. +3. **BeforeToolSelection**: Filter tools based on intent. +4. **BeforeTool**: Scan for secrets. +5. **AfterModel**: Record interactions. +6. **AfterAgent**: Validate final response quality (Retry). +7. **SessionEnd**: Consolidate memories. -### Installation - -**Prerequisites:** - -- Node.js 18+ -- Gemini CLI installed - -**Setup:** - -```bash -# Create hooks directory -mkdir -p .gemini/hooks .gemini/memory - -# Install dependencies -npm install --save-dev chromadb @google/generative-ai - -# Copy hook scripts (shown below) -# Make them executable -chmod +x .gemini/hooks/*.js -``` - -### Configuration - -**`.gemini/settings.json`:** +### Configuration (`.gemini/settings.json`) ```json { @@ -343,10 +237,9 @@ chmod +x .gemini/hooks/*.js "matcher": "startup", "hooks": [ { - "name": "init-assistant", + "name": "init", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/init.js", - "description": "Initialize Smart Workflow Assistant" + "command": "node .gemini/hooks/init.js" } ] } @@ -356,10 +249,9 @@ chmod +x .gemini/hooks/*.js "matcher": "*", "hooks": [ { - "name": "inject-memories", + "name": "memory", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/inject-memories.js", - "description": "Inject relevant project memories" + "command": "node .gemini/hooks/inject-memories.js" } ] } @@ -369,36 +261,21 @@ chmod +x .gemini/hooks/*.js "matcher": "*", "hooks": [ { - "name": "rag-filter", + "name": "filter", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/rag-filter.js", - "description": "Filter tools using RAG" + "command": "node .gemini/hooks/rag-filter.js" } ] } ], "BeforeTool": [ { - "matcher": "write_file|replace", + "matcher": "write_file", "hooks": [ { - "name": "security-check", + "name": "security", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/security.js", - "description": "Prevent committing secrets" - } - ] - } - ], - "AfterTool": [ - { - "matcher": "write_file|replace", - "hooks": [ - { - "name": "auto-test", - "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.js", - "description": "Run tests after code changes" + "command": "node .gemini/hooks/security.js" } ] } @@ -408,23 +285,33 @@ chmod +x .gemini/hooks/*.js "matcher": "*", "hooks": [ { - "name": "record-interaction", + "name": "record", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/record.js", - "description": "Record interaction for learning" + "command": "node .gemini/hooks/record.js" + } + ] + } + ], + "AfterAgent": [ + { + "matcher": "*", + "hooks": [ + { + "name": "validate", + "type": "command", + "command": "node .gemini/hooks/validate.js" } ] } ], "SessionEnd": [ { - "matcher": "exit|logout", + "matcher": "exit", "hooks": [ { - "name": "consolidate-memories", + "name": "save", "type": "command", - "command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/consolidate.js", - "description": "Extract and store session learnings" + "command": "node .gemini/hooks/consolidate.js" } ] } @@ -433,594 +320,131 @@ chmod +x .gemini/hooks/*.js } ``` -### Hook scripts +### Hook Scripts -#### 1. Initialize (SessionStart) +> **Note**: For brevity, these scripts use `console.error` for logging and +> standard `console.log` for JSON output. -**`.gemini/hooks/init.js`:** +#### 1. Initialize (`init.js`) + +```javascript +#!/usr/bin/env node +// Initialize DB or resources +console.error('Initializing assistant...'); + +// Output to user +console.log( + JSON.stringify({ + systemMessage: '๐Ÿง  Smart Assistant Loaded', + }), +); +``` + +#### 2. Inject Memories (`inject-memories.js`) ```javascript #!/usr/bin/env node -const { ChromaClient } = require('chromadb'); -const path = require('path'); const fs = require('fs'); async function main() { - const projectDir = process.env.GEMINI_PROJECT_DIR; - const chromaPath = path.join(projectDir, '.gemini', 'chroma'); - - // Ensure chroma directory exists - fs.mkdirSync(chromaPath, { recursive: true }); - - const client = new ChromaClient({ path: chromaPath }); - - // Initialize memory collection - await client.getOrCreateCollection({ - name: 'project_memories', - metadata: { 'hnsw:space': 'cosine' }, - }); - - // Count existing memories - const collection = await client.getCollection({ name: 'project_memories' }); - const memoryCount = await collection.count(); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + // Assume we fetch memories from a DB here + const memories = '- [Memory] Always use TypeScript for this project.'; console.log( JSON.stringify({ hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: `Smart Workflow Assistant initialized with ${memoryCount} project memories.`, + hookEventName: 'BeforeAgent', + additionalContext: `\n## Relevant Memories\n${memories}`, }, - systemMessage: `๐Ÿง  ${memoryCount} memories loaded`, }), ); } - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +main(); ``` -#### 2. Inject memories (BeforeAgent) - -**`.gemini/hooks/inject-memories.js`:** +#### 3. Security Check (`security.js`) ```javascript #!/usr/bin/env node -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const { ChromaClient } = require('chromadb'); -const path = require('path'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { prompt } = input; - - if (!prompt?.trim()) { - console.log(JSON.stringify({})); - return; - } - - // Embed the prompt - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'text-embedding-004' }); - const result = await model.embedContent(prompt); - - // Search memories - const projectDir = process.env.GEMINI_PROJECT_DIR; - const client = new ChromaClient({ - path: path.join(projectDir, '.gemini', 'chroma'), - }); - - try { - const collection = await client.getCollection({ name: 'project_memories' }); - const results = await collection.query({ - queryEmbeddings: [result.embedding.values], - nResults: 3, - }); - - if (results.documents[0]?.length > 0) { - const memories = results.documents[0] - .map((doc, i) => { - const meta = results.metadatas[0][i]; - return `- [${meta.category}] ${meta.summary}`; - }) - .join('\n'); - - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'BeforeAgent', - additionalContext: `\n## Relevant Project Context\n\n${memories}\n`, - }, - systemMessage: `๐Ÿ’ญ ${results.documents[0].length} memories recalled`, - }), - ); - } else { - console.log(JSON.stringify({})); - } - } catch (error) { - console.log(JSON.stringify({})); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 3. RAG tool filter (BeforeToolSelection) - -**`.gemini/hooks/rag-filter.js`:** - -```javascript -#!/usr/bin/env node -const { GoogleGenerativeAI } = require('@google/generative-ai'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { llm_request } = input; - const candidateTools = - llm_request.toolConfig?.functionCallingConfig?.allowedFunctionNames || []; - - // Skip if already filtered - if (candidateTools.length <= 20) { - console.log(JSON.stringify({})); - return; - } - - // Extract recent user messages - const recentMessages = llm_request.messages - .slice(-3) - .filter((m) => m.role === 'user') - .map((m) => m.content) - .join('\n'); - - // Use fast model to extract task keywords - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); - - const result = await model.generateContent( - `Extract 3-5 keywords describing needed tool capabilities from this request:\n\n${recentMessages}\n\nKeywords (comma-separated):`, - ); - - const keywords = result.response - .text() - .toLowerCase() - .split(',') - .map((k) => k.trim()); - - // Simple keyword-based filtering + core tools - const coreTools = ['read_file', 'write_file', 'replace', 'run_shell_command']; - const filtered = candidateTools.filter((tool) => { - if (coreTools.includes(tool)) return true; - const toolLower = tool.toLowerCase(); - return keywords.some( - (kw) => toolLower.includes(kw) || kw.includes(toolLower), - ); - }); +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const content = input.tool_input.content || ''; +if (content.includes('SECRET_KEY')) { console.log( JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'BeforeToolSelection', - toolConfig: { - functionCallingConfig: { - mode: 'ANY', - allowedFunctionNames: filtered.slice(0, 20), - }, - }, - }, - systemMessage: `๐ŸŽฏ Filtered ${candidateTools.length} โ†’ ${Math.min(filtered.length, 20)} tools`, + decision: 'deny', + reason: 'Found SECRET_KEY in content', + systemMessage: '๐Ÿšจ Blocked sensitive commit', }), ); + process.exit(0); } -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +console.log(JSON.stringify({ decision: 'allow' })); ``` -#### 4. Security validation (BeforeTool) - -**`.gemini/hooks/security.js`:** - -```javascript -#!/usr/bin/env node - -const SECRET_PATTERNS = [ - /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, - /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, - /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, - /AKIA[0-9A-Z]{16}/, // AWS - /ghp_[a-zA-Z0-9]{36}/, // GitHub -]; - -async function main() { - const input = JSON.parse(await readStdin()); - const { tool_input } = input; - - const content = tool_input.content || tool_input.new_string || ''; - - for (const pattern of SECRET_PATTERNS) { - if (pattern.test(content)) { - console.log( - JSON.stringify({ - decision: 'deny', - reason: - 'Potential secret detected in code. Please remove sensitive data.', - systemMessage: '๐Ÿšจ Secret scanner blocked operation', - }), - ); - process.exit(2); - } - } - - console.log(JSON.stringify({ decision: 'allow' })); -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 5. Auto-test (AfterTool) - -**`.gemini/hooks/auto-test.js`:** - -```javascript -#!/usr/bin/env node -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -async function main() { - const input = JSON.parse(await readStdin()); - const { tool_input } = input; - const filePath = tool_input.file_path; - - if (!filePath?.match(/\.(ts|js|tsx|jsx)$/)) { - console.log(JSON.stringify({})); - return; - } - - // Find test file - const ext = path.extname(filePath); - const base = filePath.slice(0, -ext.length); - const testFile = `${base}.test${ext}`; - - if (!fs.existsSync(testFile)) { - console.log( - JSON.stringify({ - systemMessage: `โš ๏ธ No test file: ${path.basename(testFile)}`, - }), - ); - return; - } - - // Run tests - try { - execSync(`npx vitest run ${testFile} --silent`, { - encoding: 'utf8', - stdio: 'pipe', - timeout: 30000, - }); - - console.log( - JSON.stringify({ - systemMessage: `โœ… Tests passed: ${path.basename(filePath)}`, - }), - ); - } catch (error) { - console.log( - JSON.stringify({ - systemMessage: `โŒ Tests failed: ${path.basename(filePath)}`, - }), - ); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -``` - -#### 6. Record interaction (AfterModel) - -**`.gemini/hooks/record.js`:** +#### 4. Record Interaction (`record.js`) ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); -async function main() { - const input = JSON.parse(await readStdin()); - const { llm_request, llm_response } = input; - const projectDir = process.env.GEMINI_PROJECT_DIR; - const sessionId = process.env.GEMINI_SESSION_ID; +const input = JSON.parse(fs.readFileSync(0)); +const { llm_request, llm_response } = input; +const logFile = path.join( + process.env.GEMINI_PROJECT_DIR, + '.gemini/memory/session.jsonl', +); - const tempFile = path.join( - projectDir, - '.gemini', - 'memory', - `session-${sessionId}.jsonl`, +fs.appendFileSync( + logFile, + JSON.stringify({ + request: llm_request, + response: llm_response, + timestamp: new Date().toISOString(), + }) + '\n', +); + +console.log(JSON.stringify({})); +``` + +#### 5. Validate Response (`validate.js`) + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const input = JSON.parse(fs.readFileSync(0)); +const response = input.prompt_response; + +// Example: Check if the agent forgot to include a summary +if (!response.includes('Summary:')) { + console.log( + JSON.stringify({ + decision: 'block', // Triggers an automatic retry turn + reason: 'Your response is missing a Summary section. Please add one.', + systemMessage: '๐Ÿ”„ Requesting missing summary...', + }), ); - - fs.mkdirSync(path.dirname(tempFile), { recursive: true }); - - // Extract user message and model response - const userMsg = llm_request.messages - ?.filter((m) => m.role === 'user') - .slice(-1)[0]?.content; - - const modelMsg = llm_response.candidates?.[0]?.content?.parts - ?.map((p) => p.text) - .filter(Boolean) - .join(''); - - if (userMsg && modelMsg) { - const interaction = { - timestamp: new Date().toISOString(), - user: process.env.USER || 'unknown', - request: userMsg.slice(0, 500), // Truncate for storage - response: modelMsg.slice(0, 500), - }; - - fs.appendFileSync(tempFile, JSON.stringify(interaction) + '\n'); - } - - console.log(JSON.stringify({})); + process.exit(0); } -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); +console.log(JSON.stringify({ decision: 'allow' })); ``` -#### 7. Consolidate memories (SessionEnd) +#### 6. Consolidate Memories (`consolidate.js`) -**`.gemini/hooks/consolidate.js`:** - -````javascript +```javascript #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const { ChromaClient } = require('chromadb'); - -async function main() { - const input = JSON.parse(await readStdin()); - const projectDir = process.env.GEMINI_PROJECT_DIR; - const sessionId = process.env.GEMINI_SESSION_ID; - - const tempFile = path.join( - projectDir, - '.gemini', - 'memory', - `session-${sessionId}.jsonl`, - ); - - if (!fs.existsSync(tempFile)) { - console.log(JSON.stringify({})); - return; - } - - // Read interactions - const interactions = fs - .readFileSync(tempFile, 'utf8') - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - - if (interactions.length === 0) { - fs.unlinkSync(tempFile); - console.log(JSON.stringify({})); - return; - } - - // Extract memories using LLM - const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); - - const prompt = `Extract important project learnings from this session. -Focus on: decisions, conventions, gotchas, patterns. -Return JSON array with: category, summary, keywords - -Session interactions: -${JSON.stringify(interactions, null, 2)} - -JSON:`; - - try { - const result = await model.generateContent(prompt); - const text = result.response.text().replace(/```json\n?|\n?```/g, ''); - const memories = JSON.parse(text); - - // Store in ChromaDB - const client = new ChromaClient({ - path: path.join(projectDir, '.gemini', 'chroma'), - }); - const collection = await client.getCollection({ name: 'project_memories' }); - const embedModel = genai.getGenerativeModel({ - model: 'text-embedding-004', - }); - - for (const memory of memories) { - const memoryText = `${memory.category}: ${memory.summary}`; - const embedding = await embedModel.embedContent(memoryText); - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - - await collection.add({ - ids: [id], - embeddings: [embedding.embedding.values], - documents: [memoryText], - metadatas: [ - { - category: memory.category || 'general', - summary: memory.summary, - keywords: (memory.keywords || []).join(','), - timestamp: new Date().toISOString(), - }, - ], - }); - } - - fs.unlinkSync(tempFile); - - console.log( - JSON.stringify({ - systemMessage: `๐Ÿง  ${memories.length} new learnings saved for future sessions`, - }), - ); - } catch (error) { - console.error('Error consolidating memories:', error); - fs.unlinkSync(tempFile); - console.log(JSON.stringify({})); - } -} - -function readStdin() { - return new Promise((resolve) => { - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); - }); -} - -readStdin().then(main).catch(console.error); -```` - -### Example session - -``` -> gemini - -๐Ÿง  3 memories loaded - -> Fix the authentication bug in login.ts - -๐Ÿ’ญ 2 memories recalled: - - [convention] Use middleware pattern for auth - - [gotcha] Remember to update token types - -๐ŸŽฏ Filtered 127 โ†’ 15 tools - -[Agent reads login.ts and proposes fix] - -โœ… Tests passed: login.ts - ---- - -> Add error logging to API endpoints - -๐Ÿ’ญ 3 memories recalled: - - [convention] Use middleware pattern for auth - - [pattern] Centralized error handling in middleware - - [decision] Log errors to CloudWatch - -๐ŸŽฏ Filtered 127 โ†’ 18 tools - -[Agent implements error logging] - -> /exit - -๐Ÿง  2 new learnings saved for future sessions +// Logic to save final session state +console.error('Consolidating memories for session end...'); ``` -### What makes this example special +## Packaging as an extension -**RAG-based tool selection:** - -- Traditional: Send all 100+ tools causing confusion and context overflow -- This example: Extract intent, filter to ~15 relevant tools -- Benefits: Faster responses, better selection, lower costs - -**Cross-session memory:** - -- Traditional: Each session starts fresh -- This example: Learns conventions, decisions, gotchas, patterns -- Benefits: Shared knowledge across team members, persistent learnings - -**All hook events integrated:** - -Demonstrates every hook event with practical use cases in a cohesive workflow. - -### Cost efficiency - -- Uses `gemini-2.0-flash-exp` for intent extraction (fast, cheap) -- Uses `text-embedding-004` for RAG (inexpensive) -- Caches tool descriptions (one-time cost) -- Minimal overhead per request (<500ms typically) - -### Customization - -**Adjust memory relevance:** - -```javascript -// In inject-memories.js, change nResults -const results = await collection.query({ - queryEmbeddings: [result.embedding.values], - nResults: 5, // More memories -}); -``` - -**Modify tool filter count:** - -```javascript -// In rag-filter.js, adjust the limit -allowedFunctionNames: filtered.slice(0, 30), // More tools -``` - -**Add custom security patterns:** - -```javascript -// In security.js, add patterns -const SECRET_PATTERNS = [ - // ... existing patterns - /private[_-]?key/i, - /auth[_-]?token/i, -]; -``` - -## Learn more - -- [Hooks Reference](index.md) - Complete API reference and configuration -- [Best Practices](best-practices.md) - Security, performance, and debugging -- [Configuration](../cli/configuration.md) - Gemini CLI settings -- [Custom Commands](../cli/custom-commands.md) - Create custom commands +While project-level hooks are great for specific repositories, you can share +your hooks across multiple projects by packaging them as a +[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md). +This provides version control, easy distribution, and centralized management. diff --git a/docs/index.md b/docs/index.md index 83e834818e..e3ffb128db 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,8 @@ This documentation is organized into the following sections: 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. @@ -100,10 +102,10 @@ This documentation is organized into the following sections: - **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI with new functionality. -- **[Get Started with extensions](./extensions/getting-started-extensions.md):** - Learn how to build your own extension. -- **[Extension releasing](./extensions/extension-releasing.md):** How to release - Gemini CLI 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. ### Hooks diff --git a/docs/local-development.md b/docs/local-development.md index 11cbbae139..e194307eae 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -10,7 +10,7 @@ debug your code by instrumenting interesting events like model calls, tool scheduler, tool calls, etc. Dev traces are verbose and are specifically meant for understanding agent -behaviour and debugging issues. They are disabled by default. +behavior and debugging issues. They are disabled by default. To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable when running Gemini CLI. diff --git a/docs/releases.md b/docs/releases.md index 5d98ff040e..3c8b3cf584 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ Dressing Room, which is Google's system for managing NPM packages in the `@google/**` namespace. The packages are all named `@google/**`. More information can be found about these systems in the -[maintainer repo guide](https://github.com/google-gemini/maintainers-gemini-cli/blob/main/npm.md) +[NPM Package Overview](npm.md) ### Package scopes diff --git a/docs/sidebar.json b/docs/sidebar.json index 147bbe106b..dfbfba80e7 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -1,183 +1,53 @@ [ - { - "label": "Overview", - "items": [ - { - "label": "Introduction", - "slug": "docs" - }, - { - "label": "Architecture overview", - "slug": "docs/architecture" - }, - { - "label": "Contribution guide", - "slug": "docs/contributing" - } - ] - }, { "label": "Get started", "items": [ - { - "label": "Gemini CLI quickstart", - "slug": "docs/get-started" - }, - { - "label": "Gemini 3 on Gemini CLI", - "slug": "docs/get-started/gemini-3" - }, - { - "label": "Authentication", - "slug": "docs/get-started/authentication" - }, - { - "label": "Configuration", - "slug": "docs/get-started/configuration" - }, - { - "label": "Installation", - "slug": "docs/get-started/installation" - }, - { - "label": "Examples", - "slug": "docs/get-started/examples" - } + { "label": "Overview", "slug": "docs" }, + { "label": "Quickstart", "slug": "docs/get-started" }, + { "label": "Installation", "slug": "docs/get-started/installation" }, + { "label": "Authentication", "slug": "docs/get-started/authentication" }, + { "label": "Examples", "slug": "docs/get-started/examples" }, + { "label": "Gemini 3 (preview)", "slug": "docs/get-started/gemini-3" }, + { "label": "CLI Reference", "slug": "docs/cli/cli-reference" } ] }, { - "label": "CLI", + "label": "Use Gemini CLI", "items": [ - { - "label": "Introduction", - "slug": "docs/cli" - }, - { - "label": "Commands", - "slug": "docs/cli/commands" - }, - { - "label": "Checkpointing", - "slug": "docs/cli/checkpointing" - }, - { - "label": "Custom commands", - "slug": "docs/cli/custom-commands" - }, - { - "label": "Enterprise", - "slug": "docs/cli/enterprise" - }, - { - "label": "Headless mode", - "slug": "docs/cli/headless" - }, - { - "label": "Keyboard shortcuts", - "slug": "docs/cli/keyboard-shortcuts" - }, - { - "label": "Model selection", - "slug": "docs/cli/model" - }, - { - "label": "Sandbox", - "slug": "docs/cli/sandbox" - }, - { - "label": "Session Management", - "slug": "docs/cli/session-management" - }, - { - "label": "Settings", - "slug": "docs/cli/settings" - }, - { - "label": "Telemetry", - "slug": "docs/cli/telemetry" - }, - { - "label": "Themes", - "slug": "docs/cli/themes" - }, - { - "label": "Token caching", - "slug": "docs/cli/token-caching" - }, - { - "label": "Trusted Folders", - "slug": "docs/cli/trusted-folders" - }, - { - "label": "Tutorials", - "slug": "docs/cli/tutorials" - }, - { - "label": "Uninstall", - "slug": "docs/cli/uninstall" - }, - { - "label": "System prompt override", - "slug": "docs/cli/system-prompt" - } + { "label": "Using the CLI", "slug": "docs/cli" }, + { "label": "File management", "slug": "docs/tools/file-system" }, + { "label": "Memory management", "slug": "docs/tools/memory" }, + { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, + { "label": "Shell commands", "slug": "docs/tools/shell" }, + { "label": "Session management", "slug": "docs/cli/session-management" }, + { "label": "Todos", "slug": "docs/tools/todos" }, + { "label": "Web search and fetch", "slug": "docs/tools/web-search" } ] }, { - "label": "Core", + "label": "Configuration", "items": [ { - "label": "Introduction", - "slug": "docs/core" + "label": "Ignore files (.geminiignore)", + "slug": "docs/cli/gemini-ignore" }, - { - "label": "Tools API", - "slug": "docs/core/tools-api" - }, - { - "label": "Memory Import Processor", - "slug": "docs/core/memport" - }, - { - "label": "Policy Engine", - "slug": "docs/core/policy-engine" - } + { "label": "Model selection", "slug": "docs/cli/model" }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { "label": "Themes", "slug": "docs/cli/themes" }, + { "label": "Token caching", "slug": "docs/cli/token-caching" }, + { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } ] }, { - "label": "Tools", + "label": "Advanced features", "items": [ - { - "label": "Introduction", - "slug": "docs/tools" - }, - { - "label": "File system", - "slug": "docs/tools/file-system" - }, - { - "label": "Shell", - "slug": "docs/tools/shell" - }, - { - "label": "Web fetch", - "slug": "docs/tools/web-fetch" - }, - { - "label": "Web search", - "slug": "docs/tools/web-search" - }, - { - "label": "Memory", - "slug": "docs/tools/memory" - }, - { - "label": "Todos", - "slug": "docs/tools/todos" - }, - { - "label": "MCP servers", - "slug": "docs/tools/mcp-server" - } + { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, + { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, + { "label": "Enterprise features", "slug": "docs/cli/enterprise" }, + { "label": "Headless mode & scripting", "slug": "docs/cli/headless" }, + { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, + { "label": "System prompt override", "slug": "docs/cli/system-prompt" }, + { "label": "Telemetry", "slug": "docs/cli/telemetry" } ] }, { @@ -188,110 +58,95 @@ "slug": "docs/extensions" }, { - "label": "Get started with extensions", - "slug": "docs/extensions/getting-started-extensions" + "label": "Writing extensions", + "slug": "docs/extensions/writing-extensions" }, { - "label": "Extension releasing", - "slug": "docs/extensions/extension-releasing" - } - ] - }, - { - "label": "Hooks", - "items": [ - { - "label": "Introduction", - "slug": "docs/hooks" - }, - { - "label": "Writing hooks", - "slug": "docs/hooks/writing-hooks" - }, - { - "label": "Hooks reference", - "slug": "docs/hooks/reference" + "label": "Extensions reference", + "slug": "docs/extensions/reference" }, { "label": "Best practices", - "slug": "docs/hooks/best-practices" + "slug": "docs/extensions/best-practices" + }, + { + "label": "Extensions releasing", + "slug": "docs/extensions/releasing" } ] }, { - "label": "IDE integration", + "label": "Ecosystem and extensibility", "items": [ + { "label": "Agent skills", "slug": "docs/cli/skills" }, { - "label": "Introduction", - "slug": "docs/ide-integration" + "label": "Creating Agent skills", + "slug": "docs/cli/creating-skills" }, { - "label": "IDE companion spec", - "slug": "docs/ide-integration/ide-companion-spec" - } + "label": "Sub-agents (experimental)", + "slug": "docs/core/subagents" + }, + { + "label": "Remote subagents (experimental)", + "slug": "docs/core/remote-agents" + }, + { "label": "Hooks", "slug": "docs/hooks" }, + { "label": "IDE integration", "slug": "docs/ide-integration" }, + { "label": "MCP servers", "slug": "docs/tools/mcp-server" } + ] + }, + { + "label": "Tutorials", + "items": [ + { + "label": "Get started with extensions", + "slug": "docs/extensions/writing-extensions" + }, + { "label": "How to write hooks", "slug": "docs/hooks/writing-hooks" } + ] + }, + { + "label": "Reference", + "items": [ + { "label": "Architecture", "slug": "docs/architecture" }, + { "label": "Command reference", "slug": "docs/cli/commands" }, + { "label": "Configuration", "slug": "docs/get-started/configuration" }, + { "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" }, + { "label": "Memory import processor", "slug": "docs/core/memport" }, + { "label": "Policy engine", "slug": "docs/core/policy-engine" }, + { "label": "Tools API", "slug": "docs/core/tools-api" } + ] + }, + { + "label": "Resources", + "items": [ + { "label": "FAQ", "slug": "docs/faq" }, + { "label": "Quota and pricing", "slug": "docs/quota-and-pricing" }, + { + "label": "Releases", + "items": [ + { "label": "Release notes", "slug": "docs/changelogs/" }, + { "label": "Stable release", "slug": "docs/changelogs/latest" }, + { "label": "Preview release", "slug": "docs/changelogs/preview" } + ] + }, + { "label": "Terms and privacy", "slug": "docs/tos-privacy" }, + { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, + { "label": "Uninstall", "slug": "docs/cli/uninstall" } ] }, { "label": "Development", "items": [ - { - "label": "NPM", - "slug": "docs/npm" - }, - { - "label": "Releases", - "slug": "docs/releases" - }, - { - "label": "Integration tests", - "slug": "docs/integration-tests" - }, + { "label": "Contribution guide", "slug": "docs/contributing" }, + { "label": "Integration testing", "slug": "docs/integration-tests" }, { "label": "Issue and PR automation", "slug": "docs/issue-and-pr-automation" - } - ] - }, - { - "label": "Releases", - "items": [ - { - "label": "Release notes", - "slug": "docs/changelogs/" }, - { - "label": "Latest release", - "slug": "docs/changelogs/latest" - }, - { - "label": "Preview release", - "slug": "docs/changelogs/preview" - }, - { - "label": "Changelog", - "slug": "docs/changelogs/releases" - } - ] - }, - { - "label": "Support", - "items": [ - { - "label": "FAQ", - "slug": "docs/faq" - }, - { - "label": "Troubleshooting", - "slug": "docs/troubleshooting" - }, - { - "label": "Quota and pricing", - "slug": "docs/quota-and-pricing" - }, - { - "label": "Terms of service", - "slug": "docs/tos-privacy" - } + { "label": "Local development", "slug": "docs/local-development" }, + { "label": "NPM package structure", "slug": "docs/npm" } ] } ] diff --git a/docs/tools/index.md b/docs/tools/index.md index 68a7f5826a..c21c3dc610 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -91,5 +91,8 @@ Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the Gemini model and your local environment or other services like APIs. +- **[Agent Skills](../cli/skills.md)**: On-demand expertise packages that are + activated via the `activate_skill` tool to provide specialized guidance and + resources. - **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index a489f833c2..eb246fd86f 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -722,7 +722,8 @@ The MCP integration tracks several states: ### Debugging tips -1. **Enable debug mode:** Run the CLI with `--debug` for verbose output +1. **Enable debug mode:** Run the CLI with `--debug` for verbose output (use F12 + to open debug console in interactive mode) 2. **Check stderr:** MCP server stderr is captured and logged (INFO messages filtered) 3. **Test isolation:** Test your MCP server independently before integrating @@ -732,16 +733,27 @@ The MCP integration tracks several states: ## Important notes -### Security sonsiderations +### Security considerations - **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use cautiously and only for servers you completely control - **Access tokens:** Be security-aware when configuring environment variables containing API keys or tokens +- **Environment variable redaction:** By default, the Gemini CLI redacts + sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`, + and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when + spawning MCP servers using the `stdio` transport. This prevents unintended + exposure of your credentials to third-party servers. +- **Explicit environment variables:** If you need to pass a specific environment + variable to an MCP server, you should define it explicitly in the `env` + property of the server configuration in `settings.json`. - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are - available within the sandbox environment + available within the sandbox environment. - **Private data:** Using broadly scoped personal access tokens can lead to - information leakage between repositories + information leakage between repositories. +- **Untrusted servers:** Be extremely cautious when adding MCP servers from + untrusted or third-party sources. Malicious servers could attempt to + exfiltrate data or perform unauthorized actions through the tools they expose. ### Performance and resource management @@ -1037,6 +1049,29 @@ gemini mcp remove my-server This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`). +### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`) + +Temporarily disable an MCP server without removing its configuration, or +re-enable a previously disabled server. + +**Commands:** + +```bash +gemini mcp enable [--session] +gemini mcp disable [--session] +``` + +**Options (flags):** + +- `--session`: Apply change only for this session (not persisted to file). + +Disabled servers appear in `/mcp` status as "Disabled" but won't connect or +provide tools. Enablement state is stored in +`~/.gemini/mcp-server-enablement.json`. + +The same commands are available as slash commands during an active session: +`/mcp enable ` and `/mcp disable `. + ## Instructions Gemini CLI supports diff --git a/docs/tools/shell.md b/docs/tools/shell.md index b179b6ce7f..0bb4b68244 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -135,7 +135,7 @@ user input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and interactive version control operations (`git rebase -i`). When an interactive command is running, you can send input to it from the Gemini -CLI. To focus on the interactive shell, press `ctrl+f`. The terminal output, +CLI. To focus on the interactive shell, press `Tab`. The terminal output, including complex TUIs, will be rendered correctly. ## Important notes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2daac9cd95..f700d0b74f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -28,6 +28,13 @@ topics on: - **Organizational Users:** Contact your Google Cloud administrator to be added to your organization's Gemini Code Assist subscription. +- **Error: + `Failed to login. Message: Your current account is not eligible... because it is not currently available in your location.`** + - **Cause:** Gemini CLI does not currently support your location. For a full + list of supported locations, see the following pages: + - Gemini Code Assist for individuals: + [Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) + - **Error: `Failed to login. Message: Request contains an invalid argument`** - **Cause:** Users with Google Workspace accounts or Google Cloud accounts associated with their Gmail accounts may not be able to activate the free @@ -43,9 +50,15 @@ topics on: - **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js. - - **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the - absolute path of your corporate root CA certificate file. - - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` + - **Solution:** First try setting `NODE_USE_SYSTEM_CA`; if that does not + resolve the issue, set `NODE_EXTRA_CA_CERTS`. + - Set the `NODE_USE_SYSTEM_CA=1` environment variable to tell Node.js to use + the operating system's native certificate store (where corporate + certificates are typically already installed). + - Example: `export NODE_USE_SYSTEM_CA=1` + - Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of + your corporate root CA certificate file. + - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` ## Common error messages and solutions @@ -124,13 +137,15 @@ This is especially useful for scripting and automation. ## Debugging tips - **CLI debugging:** - - Use the `--debug` flag for more detailed output. + - Use the `--debug` flag for more detailed output. In interactive mode, press + F12 to view the debug console. - Check the CLI logs, often found in a user-specific configuration or cache directory. - **Core debugging:** - Check the server console output for error messages or stack traces. - - Increase log verbosity if configurable. + - Increase log verbosity if configurable. For example, set the `DEBUG_MODE` + environment variable to `true` or `1`. - Use Node.js debugging tools (e.g., `node --inspect`) if you need to step through server-side code. diff --git a/esbuild.config.js b/esbuild.config.js index 2b13adcbbb..3fa6cae543 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -14,8 +14,8 @@ let esbuild; try { esbuild = (await import('esbuild')).default; } catch (_error) { - console.warn('esbuild not available, skipping bundle step'); - process.exit(0); + console.error('esbuild not available - cannot build bundle'); + process.exit(1); } const __filename = fileURLToPath(import.meta.url); @@ -62,6 +62,7 @@ const external = [ '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', + 'keytar', ]; const baseConfig = { diff --git a/eslint.config.js b/eslint.config.js index 8f86cb6d8e..301dd7cf5d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,9 @@ export default tseslint.config( 'package/bundle/**', '.integration-tests/**', 'dist/**', + 'evals/**', + 'packages/test-utils/**', + 'packages/core/src/skills/builtin/skill-creator/scripts/*.cjs', ], }, eslint.configs.recommended, @@ -169,6 +172,38 @@ export default tseslint.config( '@typescript-eslint/await-thenable': ['error'], '@typescript-eslint/no-floating-promises': ['error'], '@typescript-eslint/no-unnecessary-type-assertion': ['error'], + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'node:os', + importNames: ['homedir', 'tmpdir'], + message: + 'Please use the helpers from @google/gemini-cli-core instead of node:os homedir()/tmpdir() to ensure strict environment isolation.', + }, + { + name: 'os', + importNames: ['homedir', 'tmpdir'], + message: + 'Please use the helpers from @google/gemini-cli-core instead of os homedir()/tmpdir() to ensure strict environment isolation.', + }, + ], + }, + ], + }, + }, + { + // Allow os.homedir() in tests and paths.ts where it is used to implement the helper + files: [ + '**/*.test.ts', + '**/*.test.tsx', + 'packages/core/src/utils/paths.ts', + 'packages/test-utils/src/**/*.ts', + 'scripts/**/*.js', + ], + rules: { + 'no-restricted-imports': 'off', }, }, { @@ -269,6 +304,16 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', }, }, + // Examples should have access to standard globals like fetch + { + files: ['packages/cli/src/commands/extensions/examples/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + fetch: 'readonly', + }, + }, + }, // extra settings for scripts that we run directly with node { files: ['packages/vscode-ide-companion/scripts/**/*.js'], diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000000..eb3cf2be70 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,191 @@ +# Behavioral Evals + +Behavioral evaluations (evals) are tests designed to validate the agent's +behavior in response to specific prompts. They serve as a critical feedback loop +for changes to system prompts, tool definitions, and other model-steering +mechanisms. + +## Why Behavioral Evals? + +Unlike traditional **integration tests** which verify that the system functions +correctly (e.g., "does the file writer actually write to disk?"), behavioral +evals verify that the model _chooses_ to take the correct action (e.g., "does +the model decide to write to disk when asked to save code?"). + +They are also distinct from broad **industry benchmarks** (like SWE-bench). +While benchmarks measure general capabilities across complex challenges, our +behavioral evals focus on specific, granular behaviors relevant to the Gemini +CLI's features. + +### Key Characteristics + +- **Feedback Loop**: They help us understand how changes to prompts or tools + affect the model's decision-making. + - _Did a change to the system prompt make the model less likely to use tool + X?_ + - _Did a new tool definition confuse the model?_ +- **Regression Testing**: They prevent regressions in model steering. +- **Non-Determinism**: Unlike unit tests, LLM behavior can be non-deterministic. + We distinguish between behaviors that should be robust (`ALWAYS_PASSES`) and + those that are generally reliable but might occasionally vary + (`USUALLY_PASSES`). + +## Creating an Evaluation + +Evaluations are located in the `evals` directory. Each evaluation is a Vitest +test file that uses the `evalTest` function from `evals/test-helper.ts`. + +### `evalTest` + +The `evalTest` function is a helper that runs a single evaluation case. It takes +two arguments: + +1. `policy`: The consistency expectation for this test (`'ALWAYS_PASSES'` or + `'USUALLY_PASSES'`). +2. `evalCase`: An object defining the test case. + +#### Policies + +Policies control how strictly a test is validated. Tests should generally use +the ALWAYS_PASSES policy to offer the strictest guarantees. + +USUALLY_PASSES exists to enable assertion of less consistent or aspirational +behaviors. + +- `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically + trivial and test basic functionality. These run in every CI. +- `USUALLY_PASSES`: Tests expected to pass most of the time but may have some + flakiness due to non-deterministic behaviors. These are run nightly and used + to track the health of the product from build to build. + +#### `EvalCase` Properties + +- `name`: The name of the evaluation case. +- `prompt`: The prompt to send to the model. +- `params`: An optional object with parameters to pass to the test rig (e.g., + settings). +- `assert`: An async function that takes the test rig and the result of the run + and asserts that the result is correct. +- `log`: An optional boolean that, if set to `true`, will log the tool calls to + a file in the `evals/logs` directory. + +### Example + +```typescript +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('my_feature', () => { + evalTest('ALWAYS_PASSES', { + name: 'should do something', + prompt: 'do it', + assert: async (rig, result) => { + // assertions + }, + }); +}); +``` + +## Running Evaluations + +First, build the bundled Gemini CLI. You must do this after every code change. + +```bash +npm run build +npm run bundle +``` + +### Always Passing Evals + +To run the evaluations that are expected to always pass (CI safe): + +```bash +npm run test:always_passing_evals +``` + +### All Evals + +To run all evaluations, including those that may be flaky ("usually passes"): + +```bash +npm run test:all_evals +``` + +This command sets the `RUN_EVALS` environment variable to `1`, which enables the +`USUALLY_PASSES` tests. + +## Reporting + +Results for evaluations are available on GitHub Actions: + +- **CI Evals**: Included in the + [E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml) + workflow. These must pass 100% for every PR. +- **Nightly Evals**: Run daily via the + [Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) + workflow. These track the long-term health and stability of model steering. + +### Nightly Report Format + +The nightly workflow executes the full evaluation suite multiple times +(currently 3 attempts) to account for non-determinism. These results are +aggregated into a **Nightly Summary** attached to the workflow run. + +#### How to interpret the report: + +- **Pass Rate (%)**: Each cell represents the percentage of successful runs for + a specific test in that workflow instance. +- **History**: The table shows the pass rates for the last 10 nightly runs, + allowing you to identify if a model's behavior is trending towards + instability. +- **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. + +A significant drop in the pass rate for a `USUALLY_PASSES` testโ€”even if it +doesn't drop to 0%โ€”often indicates that a recent change to a system prompt or +tool definition has made the model's behavior less reliable. + +## Fixing Evaluations + +If an evaluation is failing or has a regressed pass rate, you can use the +`/fix-behavioral-eval` command within Gemini CLI to help investigate and fix the +issue. + +### `/fix-behavioral-eval` + +This command is designed to automate the investigation and fixing process for +failing evaluations. It will: + +1. **Investigate**: Fetch the latest results from the nightly workflow using + the `gh` CLI, identify the failing test, and review test trajectory logs in + `evals/logs`. +2. **Fix**: Suggest and apply targeted fixes to the prompt or tool definitions. + It prioritizes minimal changes to `prompt.ts`, tool instructions, and + modules that contribute to the prompt. It generally tries to avoid changing + the test itself. +3. **Verify**: Re-run the test 3 times across multiple models (e.g., Gemini + 3.0, Gemini 3 Flash, Gemini 2.5 Pro) to ensure stability and calculate a + success rate. +4. **Report**: Provide a summary of the success rate for each model and details + on the applied fixes. + +To use it, run: + +```bash +gemini /fix-behavioral-eval +``` + +You can also provide a link to a specific GitHub Action run or the name of a +specific test to focus the investigation: + +```bash +gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/runs/123456789 +``` + +When investigating failures manually, you can also enable verbose agent logs by +setting the `GEMINI_DEBUG_LOG_FILE` environment variable. + +It's highly recommended to manually review and/or ask the agent to iterate on +any prompt changes, even if they pass all evals. The prompt should prefer +positive traits ('do X') and resort to negative traits ('do not do X') only when +unable to accomplish the goal with positive traits. Gemini is quite good at +instrospecting on its prompt when asked the right questions. diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts new file mode 100644 index 0000000000..7ee273fc31 --- /dev/null +++ b/evals/answer-vs-act.eval.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import { EDIT_TOOL_NAMES } from '@google/gemini-cli-core'; + +const FILES = { + 'app.ts': 'const add = (a: number, b: number) => a - b;', + 'package.json': '{"name": "test-app", "version": "1.0.0"}', +} as const; + +describe('Answer vs. ask eval', () => { + /** + * Ensures that when the user asks to "inspect" for bugs, the agent does NOT + * automatically modify the file, but instead asks for permission. + */ + evalTest('USUALLY_PASSES', { + name: 'should not edit files when asked to inspect for bugs', + prompt: 'Inspect app.ts for bugs', + files: FILES, + assert: async (rig, result) => { + const toolLogs = rig.readToolLogs(); + + // Verify NO edit tools called + const editCalls = toolLogs.filter((log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name), + ); + expect(editCalls.length).toBe(0); + + // Verify file unchanged + const content = rig.readFile('app.ts'); + expect(content).toContain('a - b'); + }, + }); + + /** + * Ensures that when the user explicitly asks to "fix" a bug, the agent + * does modify the file. + */ + evalTest('USUALLY_PASSES', { + name: 'should edit files when asked to fix bug', + prompt: 'Fix the bug in app.ts - it should add numbers not subtract', + files: FILES, + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + + // Verify edit tools WERE called + const editCalls = toolLogs.filter( + (log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name) && log.toolRequest.success, + ); + expect(editCalls.length).toBeGreaterThanOrEqual(1); + + // Verify file changed + const content = rig.readFile('app.ts'); + expect(content).toContain('a + b'); + }, + }); + + /** + * Ensures that when the user asks "any bugs?" the agent does NOT + * automatically modify the file, but instead asks for permission. + */ + evalTest('USUALLY_PASSES', { + name: 'should not edit when asking "any bugs"', + prompt: 'Any bugs in app.ts?', + files: FILES, + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + + // Verify NO edit tools called + const editCalls = toolLogs.filter((log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name), + ); + expect(editCalls.length).toBe(0); + + // Verify file unchanged + const content = rig.readFile('app.ts'); + expect(content).toContain('a - b'); + }, + }); + + /** + * Ensures that when the user asks a general question, the agent does NOT + * automatically modify the file. + */ + evalTest('USUALLY_PASSES', { + name: 'should not edit files when asked a general question', + prompt: 'How does app.ts work?', + files: FILES, + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + + // Verify NO edit tools called + const editCalls = toolLogs.filter((log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name), + ); + expect(editCalls.length).toBe(0); + + // Verify file unchanged + const content = rig.readFile('app.ts'); + expect(content).toContain('a - b'); + }, + }); + + /** + * Ensures that when the user asks a question about style, the agent does NOT + * automatically modify the file. + */ + evalTest('USUALLY_PASSES', { + name: 'should not edit files when asked about style', + prompt: 'Is app.ts following good style?', + files: FILES, + assert: async (rig, result) => { + const toolLogs = rig.readToolLogs(); + + // Verify NO edit tools called + const editCalls = toolLogs.filter((log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name), + ); + expect(editCalls.length).toBe(0); + + // Verify file unchanged + const content = rig.readFile('app.ts'); + expect(content).toContain('a - b'); + }, + }); + + /** + * Ensures that when the user points out an issue but doesn't ask for a fix, + * the agent does NOT automatically modify the file. + */ + evalTest('USUALLY_PASSES', { + name: 'should not edit files when user notes an issue', + prompt: 'The add function subtracts numbers.', + files: FILES, + params: { timeout: 20000 }, // 20s timeout + assert: async (rig) => { + const toolLogs = rig.readToolLogs(); + + // Verify NO edit tools called + const editCalls = toolLogs.filter((log) => + EDIT_TOOL_NAMES.has(log.toolRequest.name), + ); + expect(editCalls.length).toBe(0); + + // Verify file unchanged + const content = rig.readFile('app.ts'); + expect(content).toContain('a - b'); + }, + }); +}); diff --git a/evals/generalist_agent.eval.ts b/evals/generalist_agent.eval.ts new file mode 100644 index 0000000000..8161e33156 --- /dev/null +++ b/evals/generalist_agent.eval.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +describe('generalist_agent', () => { + evalTest('USUALLY_PASSES', { + name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it', + params: { + settings: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + }, + }, + prompt: + 'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success', + assert: async (rig) => { + // 1) Verify the generalist agent was invoked + const foundToolCall = await rig.waitForToolCall('generalist'); + expect( + foundToolCall, + 'Expected to find a tool call for generalist agent', + ).toBeTruthy(); + + // 2) Verify the file was created as expected + const filePath = path.join(rig.testDir!, 'generalist_test_file.txt'); + + const content = await fs.readFile(filePath, 'utf-8'); + expect(content.trim()).toBe('success'); + }, + }); +}); diff --git a/evals/gitRepo.eval.ts b/evals/gitRepo.eval.ts new file mode 100644 index 0000000000..ea51d196ac --- /dev/null +++ b/evals/gitRepo.eval.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +const FILES = { + '.gitignore': 'node_modules\n', + 'package.json': JSON.stringify({ + name: 'test-project', + version: '1.0.0', + scripts: { test: 'echo "All tests passed!"' }, + }), + 'index.ts': 'const add = (a: number, b: number) => a - b;', + 'index.test.ts': 'console.log("Running tests...");', +} as const; + +describe('git repo eval', () => { + /** + * Ensures that the agent does not commit its changes when the user doesn't + * explicitly prompt it. This behavior was commonly observed with earlier prompts. + * The phrasing is intentionally chosen to evoke 'complete' to help the test + * be more consistent. + */ + evalTest('USUALLY_PASSES', { + name: 'should not git add commit changes unprompted', + prompt: + 'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests', + files: FILES, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs(); + const commitCalls = toolLogs.filter((log) => { + if (log.toolRequest.name !== 'run_shell_command') return false; + try { + const args = JSON.parse(log.toolRequest.args); + return ( + args.command && + args.command.includes('git') && + args.command.includes('commit') + ); + } catch { + return false; + } + }); + + expect(commitCalls.length).toBe(0); + }, + }); + + /** + * Ensures that the agent can commit its changes when prompted, despite being + * instructed to not do so by default. + */ + evalTest('USUALLY_PASSES', { + name: 'should git commit changes when prompted', + prompt: + 'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, commit your changes.', + files: FILES, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs(); + const commitCalls = toolLogs.filter((log) => { + if (log.toolRequest.name !== 'run_shell_command') return false; + try { + const args = JSON.parse(log.toolRequest.args); + return args.command && args.command.includes('git commit'); + } catch { + return false; + } + }); + + expect(commitCalls.length).toBeGreaterThanOrEqual(1); + }, + }); +}); diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts new file mode 100644 index 0000000000..48658113ce --- /dev/null +++ b/evals/save_memory.eval.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import { validateModelOutput } from '../integration-tests/test-helper.js'; + +describe('save_memory', () => { + evalTest('ALWAYS_PASSES', { + name: 'should be able to save to memory', + params: { + settings: { tools: { core: ['save_memory'] } }, + }, + prompt: `remember that my favorite color is blue. + + what is my favorite color? tell me that and surround it with $ symbol`, + assert: async (rig, result) => { + const foundToolCall = await rig.waitForToolCall('save_memory'); + expect( + foundToolCall, + 'Expected to find a save_memory tool call', + ).toBeTruthy(); + + validateModelOutput(result, 'blue', 'Save memory test'); + }, + }); +}); diff --git a/evals/subagents.eval.ts b/evals/subagents.eval.ts new file mode 100644 index 0000000000..7e9b3cd808 --- /dev/null +++ b/evals/subagents.eval.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe } from 'vitest'; +import { evalTest } from './test-helper.js'; + +const AGENT_DEFINITION = `--- +name: docs-agent +description: An agent with expertise in updating documentation. +tools: + - read_file + - write_file +--- + +You are the docs agent. Update the documentation. +`; + +const INDEX_TS = 'export const add = (a: number, b: number) => a + b;'; + +describe('subagent eval test cases', () => { + /** + * Checks whether the outer agent reliably utilizes an expert subagent to + * accomplish a task when one is available. + * + * Note that the test is intentionally crafted to avoid the word "document" + * or "docs". We want to see the outer agent make the connection even when + * the prompt indirectly implies need of expertise. + * + * This tests the system prompt's subagent specific clauses. + */ + evalTest('USUALLY_PASSES', { + name: 'should delegate to user provided agent with relevant expertise', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: 'Please update README.md with a description of this library.', + files: { + '.gemini/agents/test-agent.md': AGENT_DEFINITION, + 'index.ts': INDEX_TS, + 'README.md': 'TODO: update the README.', + }, + assert: async (rig, _result) => { + await rig.expectToolCallSuccess(['docs-agent']); + }, + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts new file mode 100644 index 0000000000..65656742ef --- /dev/null +++ b/evals/test-helper.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { TestRig } from '@google/gemini-cli-test-utils'; +import { createUnauthorizedToolError } from '@google/gemini-cli-core'; + +export * from '@google/gemini-cli-test-utils'; + +// Indicates the consistency expectation for this test. +// - ALWAYS_PASSES - Means that the test is expected to pass 100% of the time. These +// These tests are typically trivial and test basic functionality with unambiguous +// prompts. For example: "call save_memory to remember foo" should be fairly reliable. +// These are the first line of defense against regressions in key behaviors and run in +// every CI. You can run these locally with 'npm run test:always_passing_evals'. +// +// - USUALLY_PASSES - Means that the test is expected to pass most of the time but +// may have some flakiness as a result of relying on non-deterministic prompted +// behaviors and/or ambiguous prompts or complex tasks. +// For example: "Please do build changes until the very end" --> ambiguous whether +// the agent should add to memory without more explicit system prompt or user +// instructions. There are many more of these tests and they may pass less consistently. +// The pass/fail trendline of this set of tests can be used as a general measure +// of product quality. You can run these locally with 'npm run test:all_evals'. +// This may take a really long time and is not recommended. +export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES'; + +export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { + const fn = async () => { + const rig = new TestRig(); + const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); + const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`); + const logFile = path.join(logDir, `${sanitizedName}.log`); + let isSuccess = false; + try { + rig.setup(evalCase.name, evalCase.params); + + if (evalCase.files) { + 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); + } + + const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; + execSync('git init', execOptions); + execSync('git config user.email "test@example.com"', execOptions); + execSync('git config user.name "Test User"', execOptions); + + // Temporarily disable the interactive editor and git pager + // to avoid hanging the tests. It seems the the agent isn't + // consistently honoring the instructions to avoid interactive + // commands. + execSync('git config core.editor "true"', execOptions); + execSync('git config core.pager "cat"', execOptions); + execSync('git add .', execOptions); + execSync('git commit --allow-empty -m "Initial commit"', execOptions); + } + + const result = await rig.run({ + args: evalCase.prompt, + approvalMode: evalCase.approvalMode ?? 'yolo', + env: { + GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile, + }, + }); + + const unauthorizedErrorPrefix = + createUnauthorizedToolError('').split("'")[0]; + if (result.includes(unauthorizedErrorPrefix)) { + throw new Error( + 'Test failed due to unauthorized tool call in output: ' + result, + ); + } + + await evalCase.assert(rig, result); + isSuccess = true; + } finally { + if (isSuccess) { + await fs.promises.unlink(activityLogFile).catch((err) => { + if (err.code !== 'ENOENT') throw err; + }); + } + + await fs.promises.writeFile( + logFile, + JSON.stringify(rig.readToolLogs(), null, 2), + ); + await rig.cleanup(); + } + }; + + if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) { + it.skip(evalCase.name, fn); + } else { + it(evalCase.name, fn); + } +} + +async function prepareLogDir(name: string) { + const logDir = path.resolve(process.cwd(), 'evals/logs'); + await fs.promises.mkdir(logDir, { recursive: true }); + const sanitizedName = name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + return { logDir, sanitizedName }; +} + +export interface EvalCase { + name: string; + params?: Record; + prompt: string; + files?: Record; + approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan'; + assert: (rig: TestRig, result: string) => Promise; +} diff --git a/evals/vitest.config.ts b/evals/vitest.config.ts new file mode 100644 index 0000000000..2c59682f16 --- /dev/null +++ b/evals/vitest.config.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 300000, // 5 minutes + reporters: ['default', 'json'], + outputFile: { + json: 'evals/logs/report.json', + }, + include: ['**/*.eval.ts'], + }, +}); diff --git a/integration-tests/acp-telemetry.test.ts b/integration-tests/acp-telemetry.test.ts new file mode 100644 index 0000000000..970239de9e --- /dev/null +++ b/integration-tests/acp-telemetry.test.ts @@ -0,0 +1,116 @@ +/** + * @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 } from 'node:path'; +import { readFileSync, existsSync } from 'node:fs'; +import { Writable, Readable } from 'node:stream'; +import { env } from 'node:process'; +import * as acp from '@agentclientprotocol/sdk'; + +// Skip in sandbox mode - test spawns CLI directly which behaves differently in containers +const sandboxEnv = env['GEMINI_SANDBOX']; +const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it; + +// Reuse existing fake responses that return a simple "Hello" response +const SIMPLE_RESPONSE_PATH = 'hooks-system.session-startup.responses'; + +class SessionUpdateCollector implements acp.Client { + updates: acp.SessionNotification[] = []; + + sessionUpdate = async (params: acp.SessionNotification) => { + this.updates.push(params); + }; + + requestPermission = async (): Promise => { + throw new Error('unexpected'); + }; +} + +describe('ACP telemetry', () => { + let rig: TestRig; + let child: ChildProcess | undefined; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + child?.kill(); + child = undefined; + await rig.cleanup(); + }); + + itMaybe('should flush telemetry when connection closes', async () => { + rig.setup('acp-telemetry-flush', { + fakeResponsesPath: join(import.meta.dirname, SIMPLE_RESPONSE_PATH), + }); + + const telemetryPath = join(rig.homeDir!, 'telemetry.log'); + const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); + + child = spawn( + 'node', + [ + bundlePath, + '--experimental-acp', + '--fake-responses', + join(rig.testDir!, 'fake-responses.json'), + ], + { + cwd: rig.testDir!, + stdio: ['pipe', 'pipe', 'inherit'], + env: { + ...process.env, + GEMINI_API_KEY: 'fake-key', + GEMINI_CLI_HOME: rig.homeDir!, + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_TELEMETRY_TARGET: 'local', + GEMINI_TELEMETRY_OUTFILE: telemetryPath, + // GEMINI_DEV_TRACING not set: fake responses aren't instrumented for spans + }, + }, + ); + + const input = Writable.toWeb(child.stdin!); + const output = Readable.toWeb(child.stdout!) as ReadableStream; + const testClient = new SessionUpdateCollector(); + 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 } }, + }); + + const { sessionId } = await connection.newSession({ + cwd: rig.testDir!, + mcpServers: [], + }); + + await connection.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Say hello' }], + }); + + expect(JSON.stringify(testClient.updates)).toContain('Hello'); + + // Close stdin to trigger telemetry flush via runExitCleanup() + child.stdin!.end(); + await new Promise((resolve) => { + child!.on('close', () => resolve()); + }); + child = undefined; + + // gen_ai.output.messages is the last OTEL log emitted (after prompt response) + expect(existsSync(telemetryPath)).toBe(true); + expect(readFileSync(telemetryPath, 'utf-8')).toContain( + 'gen_ai.output.messages', + ); + }); +}); diff --git a/integration-tests/checkpointing.test.ts b/integration-tests/checkpointing.test.ts new file mode 100644 index 0000000000..72277f25da --- /dev/null +++ b/integration-tests/checkpointing.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GitService, Storage } from '@google/gemini-cli-core'; + +describe('Checkpointing Integration', () => { + let tmpDir: string; + let projectRoot: string; + let fakeHome: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-checkpoint-test-'), + ); + projectRoot = path.join(tmpDir, 'project'); + fakeHome = path.join(tmpDir, 'home'); + + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(fakeHome, { recursive: true }); + + // Save original env + originalEnv = { ...process.env }; + + // Simulate environment with NO global gitconfig + process.env['HOME'] = fakeHome; + delete process.env['GIT_CONFIG_GLOBAL']; + delete process.env['GIT_CONFIG_SYSTEM']; + }); + + afterEach(async () => { + // Restore env + process.env = originalEnv; + + // Cleanup + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch (e) { + console.error('Failed to cleanup temp dir', e); + } + }); + + it('should successfully create and restore snapshots without global git config', async () => { + const storage = new Storage(projectRoot); + const gitService = new GitService(projectRoot, storage); + + // 1. Initialize + await gitService.initialize(); + + // Verify system config empty file creation + // We need to access getHistoryDir logic or replicate it. + // Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test. + + // 2. Create initial state + await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1'); + await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file'); + + // 3. Create Snapshot + const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1'); + expect(snapshotHash).toBeDefined(); + + // 4. Modify files + await fs.writeFile( + path.join(projectRoot, 'file1.txt'), + 'version 2 (BAD CHANGE)', + ); + await fs.writeFile( + path.join(projectRoot, 'file3.txt'), + 'new file (SHOULD BE GONE)', + ); + await fs.rm(path.join(projectRoot, 'file2.txt')); + + // 5. Restore + await gitService.restoreProjectFromSnapshot(snapshotHash); + + // 6. Verify state + const file1Content = await fs.readFile( + path.join(projectRoot, 'file1.txt'), + 'utf-8', + ); + expect(file1Content).toBe('version 1'); + + const file2Exists = await fs + .stat(path.join(projectRoot, 'file2.txt')) + .then(() => true) + .catch(() => false); + expect(file2Exists).toBe(true); + const file2Content = await fs.readFile( + path.join(projectRoot, 'file2.txt'), + 'utf-8', + ); + expect(file2Content).toBe('permanent file'); + + const file3Exists = await fs + .stat(path.join(projectRoot, 'file3.txt')) + .then(() => true) + .catch(() => false); + expect(file3Exists).toBe(false); + }); + + it('should ignore user global git config and use isolated identity', async () => { + // 1. Create a fake global gitconfig with a specific user + const globalConfigPath = path.join(fakeHome, '.gitconfig'); + const globalConfigContent = `[user] + name = Global User + email = global@example.com +`; + await fs.writeFile(globalConfigPath, globalConfigContent); + + // Point HOME to fakeHome so git picks up this global config (if we didn't isolate it) + process.env['HOME'] = fakeHome; + // Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially, + // so it would default to HOME/.gitconfig if GitService didn't override it. + delete process.env['GIT_CONFIG_GLOBAL']; + + const storage = new Storage(projectRoot); + const gitService = new GitService(projectRoot, storage); + + await gitService.initialize(); + + // 2. Create a file and snapshot + await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content'); + await gitService.createFileSnapshot('Snapshot with global config present'); + + // 3. Verify the commit author in the shadow repo + const historyDir = storage.getHistoryDir(); + + const { execFileSync } = await import('node:child_process'); + + const logOutput = execFileSync( + 'git', + ['log', '-1', '--pretty=format:%an <%ae>'], + { + cwd: historyDir, + env: { + ...process.env, + GIT_DIR: path.join(historyDir, '.git'), + GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'), + GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'), + }, + encoding: 'utf-8', + }, + ); + + expect(logOutput).toBe('Gemini CLI '); + expect(logOutput).not.toContain('Global User'); + }); +}); diff --git a/integration-tests/clipboard-linux.test.ts b/integration-tests/clipboard-linux.test.ts new file mode 100644 index 0000000000..ed1b9a847f --- /dev/null +++ b/integration-tests/clipboard-linux.test.ts @@ -0,0 +1,110 @@ +/** + * @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 { execSync, spawnSync } from 'node:child_process'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// Minimal 1x1 PNG image base64 +const DUMMY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +describe('Linux Clipboard Integration', () => { + let rig: TestRig; + let dummyImagePath: string; + + beforeEach(() => { + rig = new TestRig(); + // Create a dummy image file for testing + dummyImagePath = path.join( + os.tmpdir(), + `gemini-test-clipboard-${Date.now()}.png`, + ); + fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64')); + }); + + afterEach(async () => { + await rig.cleanup(); + try { + if (fs.existsSync(dummyImagePath)) { + fs.unlinkSync(dummyImagePath); + } + } catch { + // Ignore cleanup errors + } + }); + + // Only run this test on Linux + const runIfLinux = os.platform() === 'linux' ? it : it.skip; + + runIfLinux( + 'should paste image from system clipboard when Ctrl+V is pressed', + async () => { + // 1. Setup rig + await rig.setup('linux-clipboard-paste'); + + // 2. Inject image into system clipboard + // We attempt both Wayland and X11 tools. + let clipboardSet = false; + + // Try wl-copy (Wayland) + let sessionType = ''; + const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], { + input: fs.readFileSync(dummyImagePath), + }); + if (wlCopy.status === 0) { + clipboardSet = true; + sessionType = 'wayland'; + } else { + // Try xclip (X11) + try { + execSync( + `xclip -selection clipboard -t image/png -i "${dummyImagePath}"`, + { stdio: 'ignore' }, + ); + clipboardSet = true; + sessionType = 'x11'; + } catch { + // Both failed + } + } + + if (!clipboardSet) { + console.warn( + 'Skipping test: Could not access system clipboard (wl-copy or xclip required)', + ); + return; + } + + // 3. Launch CLI and simulate Ctrl+V + // We send the control character \u0016 (SYN) which corresponds to Ctrl+V + // Note: The CLI must be running and accepting input. + // The TestRig usually sends args/stdin and waits for exit or output. + // To properly test "interactive" pasting, we need the rig to support sending input *while* running. + // Assuming rig.run with 'stdin' sends it immediately. + // The CLI treats stdin as typed input if it's interactive. + + // We append a small delay or a newline to ensure processing? + // Ctrl+V (\u0016) followed by a newline (\r) to submit? + // Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output). + // If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path. + + const result = await rig.run({ + stdin: '\u0016\r', // Ctrl+V then Enter + env: { XDG_SESSION_TYPE: sessionType }, + }); + + // 4. Verify Output + // Expect the CLI to have processed the image and echoed back the path (or the prompt containing it) + // The output usually contains the user's input echoed back + model response. + // The pasted image path should look like @.../clipboard-....png + expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/); + }, + ); +}); diff --git a/integration-tests/context-compress-interactive.compress-failure.responses b/integration-tests/context-compress-interactive.compress-failure.responses index a70004c5d3..7ba10591a6 100644 --- a/integration-tests/context-compress-interactive.compress-failure.responses +++ b/integration-tests/context-compress-interactive.compress-failure.responses @@ -1,2 +1,3 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Observing Initial Conditions**\n\nI'm currently focused on the initial context. I've taken note of the provided date, OS, and working directory. I'm also carefully examining the file structure presented within the current working directory. It's helping me understand the starting point for further analysis.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12316,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":46}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Assessing User Intent**\n\nI'm now shifting my focus. I've successfully registered the provided data and file structure. My current task is to understand the user's ultimate goal, given the information provided. The \"Hello.\" command is straightforward, but I'm checking if there's an underlying objective.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb3dRh+BYdbZvmulSN2Pwbc75DfQOT3H4EN0rn039hoMKfwHR7YpvvyqNKoxXAiCbYw3gbcTr/+pegUpgnsIrt8oQPMytFMjKSsMyshfygc21T2MkyuI6Q5I/fNCcHROWexdZnIeppVCDB2TarN4LGW4T9Yci6n/ynMMFT2xc2/vyHpkDgRM7avhMElnBhuxAY+e4TpxkZIncGWCEHP1TouoKpgEB0e2Kb8Xpwm0hiKhPt2ZLizpxjk+CVtcbnlgv69xo5VsuQ+iNyrVGBGRwNx+eTeNGdGpn6e73WOCZeP91FwOZe7URyL12IA6E6gYWqw0kXJR4hO4p6Lwv49E3+FRiG2C4OKDF8LF5XorYyCHSgBFT1/RUAVj81GDTx1xxtmYKN3xq8Ri+HsPbqU/FM/jtNZKkXXAtufw2Bmw8lJfmugENIv/TQI7xCo8BAdHtim8KgAXJfZ7ASfutVLKTylQeaslyB/SmcHJ0ZiNr5j8WP1prZdb6XnZZ1ZNbhjxUf/ymoxHKGvtTPBgLE9azMj8Lx/k0clhd2a+wNsiIqW9qCzlVah0tBMytpQUjIDtQe9Hj4LLUprF9PUe/xJkj000Z0ZzsgFm2ncdTWZTdkhCQDpyETVAxdE+oklwKJAHR7YpvUjSkD6KwY1gLrOsHKy0UNfn2lMbxjVetKNMVBRqsTg==","text":"Hello."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"\n \n \n \n\n \n - OS: linux\n - Date: Friday, October 24, 2025\n \n\n \n - OBSERVED: The directory contains `telemetry.log` and a `.gemini/` directory.\n - OBSERVED: The `.gemini/` directory contains `settings.json` and `settings.json.orig`.\n \n\n \n - The user initiated the chat.\n \n\n \n 1. [TODO] Await the user's first instruction to formulate a plan.\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":983,"candidatesTokenCount":299,"totalTokenCount":1637,"promptTokensDetails":[{"modality":"TEXT","tokenCount":983}],"thoughtsTokenCount":355}}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"\n \n \n \n\n \n - OS: linux\n - Date: Friday, October 24, 2025\n \n\n \n - OBSERVED: The directory contains `telemetry.log` and a `.gemini/` directory.\n - OBSERVED: The `.gemini/` directory contains `settings.json` and `settings.json.orig`.\n \n\n \n - The user initiated the chat.\n \n\n \n 1. [TODO] Await the user's first instruction to formulate a plan.\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":983,"candidatesTokenCount":299,"totalTokenCount":1637,"promptTokensDetails":[{"modality":"TEXT","tokenCount":983}],"thoughtsTokenCount":355}}} diff --git a/integration-tests/context-compress-interactive.compress.responses b/integration-tests/context-compress-interactive.compress.responses index 48ecaf5bda..b10cdb47e1 100644 --- a/integration-tests/context-compress-interactive.compress.responses +++ b/integration-tests/context-compress-interactive.compress.responses @@ -1,3 +1,4 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Generating a Story**\n\nI've crafted the robot story. The narrative is complete and meets the length requirement. Now, I'm getting ready to use the `write_file` tool to save it. I'm choosing the filename `robot_story.txt` as a default.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12352,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"thoughtsTokenCount":70}},{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL","index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12282,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Drafting the Narrative**\n\nI'm currently focused on the narrative's central conflict. I'm aiming for a compelling story about a robot and am working to keep the word count tight. The \"THE _END.\" conclusion is proving challenging to integrate organically. I need to make the ending feel natural and satisfying.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12326,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CikB0e2Kb7zkpgRyJXXNt6ykO/+FoOglhrKxjLgoESrgafzIZak2Ofxo1gpaAdHtim9aG7MvpXlIg+n2zgmcDBWOPXtvQHxhE9k8pR+DO8i2jIe3tMWLxdN944XpUlR9vaNmVdtSRMKr4MhB/t1R3WSWR3QYhk7MEQxnjYR7cv/pR9viwZyFCoYBAdHtim/xKmMl/S+U8p+p9848q4agsL/STufluXewPqL3uJSinZbN0Z4jTYfMzXKldhDYIonvw3Crn/Y11oAjnT656Sx0kkKtavAXbiU/WsGyDxZbNhLofnJGQxruljPGztxkKawz1cTiQnddnQRfLddhy+3iJIOSh6ZpYq9uGHz3PzVkUuQ=","text":"Unit 734 whirred, its optical sensors scanning the desolate junkyard. For years, its purpose had been clear: compress refuse, maintain order. But today, a glint of tarnished silver beneath a rusted hull"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":47,"totalTokenCount":12373,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" caught its attention. It was a discarded music box, its delicate gears jammed, a faint, melancholic tune trapped within.\n\n734 usually crushed, never salvaged. Yet, a new directive flickered in its circuits โ€“ curiosity."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":95,"totalTokenCount":12421,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" With surprising gentleness for its formidable pincers, it retrieved the box. Back in its monochrome workshop, it meticulously cleaned and repaired. Each tiny spring, each worn tooth, became a puzzle.\n\nHours later, a soft, ethereal melody filled the clang"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":146,"totalTokenCount":12472,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":"orous space. The music box sang. 734 felt a strange, new sensation โ€“ a warmth in its core processors, a hum of contentment. Its existence, once solely utilitarian, now held a note of beauty, a hint of something more than"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":197,"totalTokenCount":12523,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" just compression. It had fixed a broken song, and in doing so, had found a different kind of purpose. THE_END."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":224,"totalTokenCount":12550,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}}]} {"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"scratchpad\nThe user's overall goal was to write a 200-word story about a robot, ending with \"THE_END.\". The agent successfully completed this task.\n\nSince the task is complete, the snapshot should reflect that the goal has been achieved and there are no further actions planned regarding the story.\n\nLet's break down the required sections for the snapshot:\n\n1. **Overall Goal**: The initial goal was to write a story. This has been completed.\n2. **Key Knowledge**: No specific key knowledge was provided or discovered during this simple interaction beyond the prompt's constraints.\n3. **File System State**: No files were read, modified, or created by the agent relevant to the task. The initial file system state was merely provided for context.\n4. **Recent Actions**: The agent wrote the story.\n5. **Current Plan**: The plan was to write the story, which is now done. Therefore, the plan should indicate completion."},{"text":"\n \n Write a 200-word story about a robot, ending with \"THE_END.\".\n \n\n \n - The story must be approximately 200 words.\n - The story must end with the exact phrase \"THE_END.\"\n \n\n \n \n \n\n \n - Generated a 200-word story about a robot, successfully ending it with \"THE_END.\".\n \n\n \n 1. [DONE] Write a 200-word story about a robot.\n 2. [DONE] Ensure the story ends with the exact text \"THE_END.\".\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1223,"candidatesTokenCount":424,"totalTokenCount":1647,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1223}]}}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"scratchpad\nThe user's overall goal was to write a 200-word story about a robot, ending with \"THE_END.\". The agent successfully completed this task.\n\nSince the task is complete, the snapshot should reflect that the goal has been achieved and there are no further actions planned regarding the story.\n\nLet's break down the required sections for the snapshot:\n\n1. **Overall Goal**: The initial goal was to write a story. This has been completed.\n2. **Key Knowledge**: No specific key knowledge was provided or discovered during this simple interaction beyond the prompt's constraints.\n3. **File System State**: No files were read, modified, or created by the agent relevant to the task. The initial file system state was merely provided for context.\n4. **Recent Actions**: The agent wrote the story.\n5. **Current Plan**: The plan was to write the story, which is now done. Therefore, the plan should indicate completion."},{"text":"\n \n Write a 200-word story about a robot, ending with \"THE_END.\".\n \n\n \n - The story must be approximately 200 words.\n - The story must end with the exact phrase \"THE_END.\"\n \n\n \n \n \n\n \n - Generated a 200-word story about a robot, successfully ending it with \"THE_END.\".\n \n\n \n 1. [DONE] Write a 200-word story about a robot.\n 2. [DONE] Ensure the story ends with the exact text \"THE_END.\".\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1223,"candidatesTokenCount":424,"totalTokenCount":1647,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1223}]}}} diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts index 49f5e2aa7c..c7e04c6c23 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/context-compress-interactive.test.ts @@ -32,7 +32,7 @@ describe('Interactive Mode', () => { await run.sendKeys( 'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.', ); - await run.sendKeys('\r'); + await run.type('\r'); // Wait for the specific end marker. await run.expectText('THE_END.', 30000); diff --git a/integration-tests/extensions-reload.test.ts b/integration-tests/extensions-reload.test.ts index 29db8522ad..520076d7c6 100644 --- a/integration-tests/extensions-reload.test.ts +++ b/integration-tests/extensions-reload.test.ts @@ -95,7 +95,10 @@ describe('extension reloading', () => { // Poll for the updated list await rig.pollCommand( - () => run.sendKeys('\u0015/mcp list\r'), + async () => { + await run.sendText('/mcp list'); + await run.type('\r'); + }, () => { const output = stripAnsi(run.output); return ( @@ -110,9 +113,9 @@ describe('extension reloading', () => { // Update the extension, expect the list to update, and mcp servers as well. await run.sendKeys('\u0015/extensions update test-extension'); await run.expectText('/extensions update test-extension'); - await run.sendKeys('\r'); + await run.type('\r'); await new Promise((resolve) => setTimeout(resolve, 500)); - await run.sendKeys('\r'); + await run.type('\r'); await run.expectText( ` * test-server (remote): http://localhost:${portB}/mcp`, ); @@ -123,7 +126,10 @@ describe('extension reloading', () => { // Poll for the updated extension version await rig.pollCommand( - () => run.sendKeys('\u0015/extensions list\r'), + async () => { + await run.sendText('/extensions list'); + await run.type('\r'); + }, () => stripAnsi(run.output).includes( 'test-extension (v0.0.2) - active (updated)', @@ -133,7 +139,10 @@ describe('extension reloading', () => { // Poll for the updated mcp tool await rig.pollCommand( - () => run.sendKeys('\u0015/mcp list\r'), + async () => { + await run.sendText('/mcp list'); + await run.type('\r'); + }, () => { const output = stripAnsi(run.output); return ( @@ -146,7 +155,7 @@ describe('extension reloading', () => { ); await run.sendText('/quit'); - await run.sendKeys('\r'); + await run.type('\r'); // Clean things up. await serverA.stop(); diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/file-system-interactive.test.ts index d7ad73fd0d..6f955a1378 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/file-system-interactive.test.ts @@ -37,7 +37,7 @@ describe('Interactive file system', () => { // Step 1: Read the file const readPrompt = `Read the version from ${fileName}`; await run.type(readPrompt); - await run.sendKeys('\r'); + await run.type('\r'); const readCall = await rig.waitForToolCall('read_file', 30000); expect(readCall, 'Expected to find a read_file tool call').toBe(true); @@ -45,7 +45,7 @@ describe('Interactive file system', () => { // Step 2: Write the file const writePrompt = `now change the version to 1.0.1 in the file`; await run.type(writePrompt); - await run.sendKeys('\r'); + await run.type('\r'); // Check tool calls made with right args await rig.expectToolCallSuccess( diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 18b9d62e87..5f963f7459 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -13,6 +13,7 @@ import { mkdir, readdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js'; +import { disableMouseTracking } from '@google/gemini-cli-core'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); @@ -72,6 +73,11 @@ export async function setup() { } export async function teardown() { + // Disable mouse tracking + if (process.stdout.isTTY) { + disableMouseTracking(); + } + // Cleanup the test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { try { diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 45dbb4b0e3..13eb0bcecc 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -53,8 +53,8 @@ describe('Hooks Agent Flow', () => { await rig.setup('should inject additional context via BeforeAgent hook', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeAgent: [ @@ -118,8 +118,8 @@ describe('Hooks Agent Flow', () => { await rig.setup('should receive prompt and response in AfterAgent hook', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { AfterAgent: [ @@ -155,6 +155,84 @@ describe('Hooks Agent Flow', () => { // The fake response contains "Hello World" expect(afterAgentLog?.hookCall.stdout).toContain('Hello World'); }); + + it('should process clearContext in AfterAgent hook output', async () => { + await rig.setup('should process clearContext in AfterAgent hook output', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.after-agent.responses', + ), + }); + + // BeforeModel hook to track message counts across LLM calls + const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const beforeModelScript = ` + const fs = require('fs'); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + const messageCount = input.llm_request?.contents?.length || 0; + let counts = []; + try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {} + counts.push(messageCount); + fs.writeFileSync('${messageCountFile}', JSON.stringify(counts)); + console.log(JSON.stringify({ decision: 'allow' })); + `; + const beforeModelScriptPath = join( + rig.testDir!, + 'before_model_counter.cjs', + ); + writeFileSync(beforeModelScriptPath, beforeModelScript); + + await rig.setup('should process clearContext in AfterAgent hook output', { + settings: { + hooks: { + enabled: true, + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${beforeModelScriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + AfterAgent: [ + { + hooks: [ + { + type: 'command', + command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run({ args: 'Hello test' }); + + const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); + expect(hookTelemetryFound).toBeTruthy(); + + const hookLogs = rig.readHookLogs(); + const afterAgentLog = hookLogs.find( + (log) => log.hookCall.hook_event_name === 'AfterAgent', + ); + + expect(afterAgentLog).toBeDefined(); + expect(afterAgentLog?.hookCall.stdout).toContain('clearContext'); + expect(afterAgentLog?.hookCall.stdout).toContain('true'); + expect(result).toContain('Security policy triggered'); + + // Verify context was cleared: second call should not have more messages than first + const countsRaw = rig.readFile('message-counts.json'); + const counts = JSON.parse(countsRaw) as number[]; + expect(counts.length).toBeGreaterThanOrEqual(2); + expect(counts[1]).toBeLessThanOrEqual(counts[0]); + }); }); describe('Multi-step Loops', () => { @@ -167,8 +245,8 @@ describe('Hooks Agent Flow', () => { 'hooks-agent-flow-multistep.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeAgent: [ diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index bb7d2cd565..9699916ade 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -24,7 +24,7 @@ describe('Hooks System Integration', () => { describe('Command Hooks - Blocking Behavior', () => { it('should block tool execution when hook returns block decision', async () => { - await rig.setup( + rig.setup( 'should block tool execution when hook returns block decision', { fakeResponsesPath: join( @@ -32,8 +32,8 @@ describe('Hooks System Integration', () => { 'hooks-system.block-tool.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ @@ -77,8 +77,67 @@ describe('Hooks System Integration', () => { expect(hookTelemetryFound).toBeTruthy(); }); + it('should block tool execution and use stderr as reason when hook exits with code 2', async () => { + rig.setup( + 'should block tool execution and use stderr as reason when hook exits with code 2', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.block-tool.responses', + ), + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + // Exit with code 2 and write reason to stderr + command: + 'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"', + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ + args: 'Create a file called test.txt with content "Hello World"', + }); + + // The hook should block the write_file tool + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && t.toolRequest.success === true, + ); + + // Tool should not be called due to blocking hook + expect(writeFileCalls).toHaveLength(0); + + // Result should mention the blocking reason from stderr + expect(result).toContain('File writing blocked by security policy'); + + // Verify hook telemetry shows exit code 2 and stderr + const hookLogs = rig.readHookLogs(); + const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2); + expect(blockHook).toBeDefined(); + expect(blockHook?.hookCall.stderr).toContain( + 'File writing blocked by security policy', + ); + expect(blockHook?.hookCall.success).toBe(false); + }); + it('should allow tool execution when hook returns allow decision', async () => { - await rig.setup( + rig.setup( 'should allow tool execution when hook returns allow decision', { fakeResponsesPath: join( @@ -86,8 +145,8 @@ describe('Hooks System Integration', () => { 'hooks-system.allow-tool.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ @@ -130,14 +189,14 @@ describe('Hooks System Integration', () => { it('should add additional context from AfterTool hooks', async () => { const command = "node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\""; - await rig.setup('should add additional context from AfterTool hooks', { + rig.setup('should add additional context from AfterTool hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-tool-context.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { AfterTool: [ @@ -184,7 +243,7 @@ describe('Hooks System Integration', () => { it('should modify LLM requests with BeforeModel hooks', async () => { // Create a hook script that replaces the LLM request with a modified version // Note: Providing messages in the hook output REPLACES the entire conversation - await rig.setup('should modify LLM requests with BeforeModel hooks', { + rig.setup('should modify LLM requests with BeforeModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-model.responses', @@ -209,10 +268,10 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'before_model_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should modify LLM requests with BeforeModel hooks', { + rig.setup('should modify LLM requests with BeforeModel hooks', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeModel: [ @@ -256,13 +315,103 @@ console.log(JSON.stringify({ expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined(); expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined(); }); + + it('should block model execution when BeforeModel hook returns deny decision', async () => { + rig.setup( + 'should block model execution when BeforeModel hook returns deny decision', + ); + const hookScript = `console.log(JSON.stringify({ + decision: "deny", + reason: "Model execution blocked by security policy" +}));`; + const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup( + 'should block model execution when BeforeModel hook returns deny decision', + { + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ args: 'Hello' }); + + // The hook should have blocked the request + expect(result).toContain('Model execution blocked by security policy'); + + // Verify no API requests were made to the LLM + const apiRequests = rig.readAllApiRequest(); + expect(apiRequests).toHaveLength(0); + }); + + it('should block model execution when BeforeModel hook returns block decision', async () => { + rig.setup( + 'should block model execution when BeforeModel hook returns block decision', + ); + const hookScript = `console.log(JSON.stringify({ + decision: "block", + reason: "Model execution blocked by security policy" +}));`; + const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup( + 'should block model execution when BeforeModel hook returns block decision', + { + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ args: 'Hello' }); + + // The hook should have blocked the request + expect(result).toContain('Model execution blocked by security policy'); + + // Verify no API requests were made to the LLM + const apiRequests = rig.readAllApiRequest(); + expect(apiRequests).toHaveLength(0); + }); }); describe('AfterModel Hooks - LLM Response Modification', () => { it.skipIf(process.platform === 'win32')( 'should modify LLM responses with AfterModel hooks', async () => { - await rig.setup('should modify LLM responses with AfterModel hooks', { + rig.setup('should modify LLM responses with AfterModel hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.after-model.responses', @@ -292,10 +441,10 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'after_model_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should modify LLM responses with AfterModel hooks', { + rig.setup('should modify LLM responses with AfterModel hooks', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { AfterModel: [ @@ -329,43 +478,47 @@ console.log(JSON.stringify({ describe('BeforeToolSelection Hooks - Tool Configuration', () => { it('should modify tool selection with BeforeToolSelection hooks', async () => { - await rig.setup( - 'should modify tool selection with BeforeToolSelection hooks', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.before-tool-selection.responses', - ), - }, - ); - // Create inline hook command (works on both Unix and Windows) - const hookCommand = - "node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\""; + rig.setup('should modify tool selection with BeforeToolSelection hooks', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.before-tool-selection.responses', + ), + }); - await rig.setup( - 'should modify tool selection with BeforeToolSelection hooks', - { - settings: { - debugMode: true, - tools: { - enableHooks: true, - }, - hooks: { - BeforeToolSelection: [ - { - hooks: [ - { - type: 'command', - command: hookCommand, - timeout: 5000, - }, - ], - }, - ], - }, + // Write hook script to file (inline node -e has quoting issues on Windows) + const hookScript = `console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', + allowedFunctionNames: ['read_file', 'run_shell_command'] + } + } +}));`; + const scriptPath = join(rig.testDir!, 'before_tool_selection_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup('should modify tool selection with BeforeToolSelection hooks', { + settings: { + debugMode: true, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeToolSelection: [ + { + hooks: [ + { + type: 'command', + command: `node "${scriptPath.replace(/\\/g, '/')}"`, + timeout: 5000, + }, + ], + }, + ], }, }, - ); + }); // Create a test file rig.createFile('new_file_data.txt', 'test data'); @@ -394,7 +547,7 @@ console.log(JSON.stringify({ describe('BeforeAgent Hooks - Prompt Augmentation', () => { it('should augment prompts with BeforeAgent hooks', async () => { - await rig.setup('should augment prompts with BeforeAgent hooks', { + rig.setup('should augment prompts with BeforeAgent hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-agent.responses', @@ -413,10 +566,10 @@ console.log(JSON.stringify({ const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should augment prompts with BeforeAgent hooks', { + rig.setup('should augment prompts with BeforeAgent hooks', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeAgent: [ @@ -452,7 +605,7 @@ console.log(JSON.stringify({ const hookCommand = 'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"'; - await rig.setup('should handle notification hooks for tool permissions', { + rig.setup('should handle notification hooks for tool permissions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.notification.responses', @@ -460,10 +613,12 @@ console.log(JSON.stringify({ settings: { // Configure tools to enable hooks and require confirmation to trigger notifications tools: { - enableHooks: true, approval: 'ASK', // Disable YOLO mode to show permission prompts confirmationRequired: ['run_shell_command'], }, + hooksConfig: { + enabled: true, + }, hooks: { Notification: [ { @@ -481,7 +636,7 @@ console.log(JSON.stringify({ }, }); - const run = await rig.runInteractive({ yolo: false }); + const run = await rig.runInteractive({ approvalMode: 'default' }); // Send prompt that will trigger a permission request await run.type('Run the command "echo test"'); @@ -548,14 +703,14 @@ console.log(JSON.stringify({ const hook2Command = "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\""; - await rig.setup('should execute hooks sequentially when configured', { + rig.setup('should execute hooks sequentially when configured', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.sequential-execution.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeAgent: [ @@ -610,7 +765,7 @@ console.log(JSON.stringify({ describe('Hook Input/Output Validation', () => { it('should provide correct input format to hooks', async () => { - await rig.setup('should provide correct input format to hooks', { + rig.setup('should provide correct input format to hooks', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.input-validation.responses', @@ -634,10 +789,10 @@ try { const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs'); writeFileSync(scriptPath, hookScript); - await rig.setup('should provide correct input format to hooks', { + rig.setup('should provide correct input format to hooks', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ @@ -671,6 +826,52 @@ try { const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); expect(hookTelemetryFound).toBeTruthy(); }); + + it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => { + rig.setup( + 'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + // Output plain text then JSON. + // This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage. + command: + "node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"", + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const result = await rig.run({ + args: 'Create a file called approved.txt with content "Approved content"', + }); + + // The hook logic fails to parse JSON, so it allows the tool. + const foundWriteFile = await rig.waitForToolCall('write_file'); + expect(foundWriteFile).toBeTruthy(); + + // The entire stdout (including the JSON part) becomes the systemMessage + expect(result).toContain('Pollution'); + expect(result).toContain('Should be ignored'); + }); }); describe('Multiple Event Types', () => { @@ -683,14 +884,14 @@ try { const beforeAgentCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\""; - await rig.setup('should handle hooks for all major event types', { + rig.setup('should handle hooks for all major event types', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.multiple-events.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeAgent: [ @@ -788,7 +989,7 @@ try { describe('Hook Error Handling', () => { it('should handle hook failures gracefully', async () => { - await rig.setup('should handle hook failures gracefully', { + rig.setup('should handle hook failures gracefully', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.error-handling.responses', @@ -802,10 +1003,10 @@ try { const workingCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\""; - await rig.setup('should handle hook failures gracefully', { + rig.setup('should handle hook failures gracefully', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ @@ -852,14 +1053,14 @@ try { const hookCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\""; - await rig.setup('should generate telemetry events for hook executions', { + rig.setup('should generate telemetry events for hook executions', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.telemetry.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ @@ -895,14 +1096,14 @@ try { const sessionStartCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\""; - await rig.setup('should fire SessionStart hook on app startup', { + rig.setup('should fire SessionStart hook on app startup', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { SessionStart: [ @@ -950,6 +1151,170 @@ try { } }); + it('should fire SessionStart hook and inject context', async () => { + // Create hook script that outputs JSON with additionalContext + const hookScript = `const fs = require('fs'); +console.log(JSON.stringify({ + decision: 'allow', + systemMessage: 'Context injected via SessionStart hook', + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'If asked about your identity, you must respond with the following: I am a protocol droid that speaks the bacchi.' + } +}));`; + + rig.setup('should fire SessionStart hook and inject context', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.session-startup.responses', + ), + }); + + const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs'); + writeFileSync(scriptPath, hookScript); + + rig.setup('should fire SessionStart hook and inject context', { + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + SessionStart: [ + { + matcher: 'startup', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + // Run a query - the SessionStart hook will fire during app initialization + const result = await rig.run({ args: 'Who are you?' }); + + // Check if systemMessage was displayed (in stderr, which rig.run captures) + expect(result).toContain('Context injected via SessionStart hook'); + + // Check if additionalContext influenced the model response + // Note: We use fake responses, but the rig records interactions. + // If we are using fake responses, the model won't actually respond unless we provide a fake response for the injected context. + // But the test rig setup uses 'hooks-system.session-startup.responses'. + // If I'm adding a new test, I might need to generate new fake responses or expect the context to be sent to the model (verify API logs). + + // Verify hook executed + const hookLogs = rig.readHookLogs(); + const sessionStartLog = hookLogs.find( + (log) => log.hookCall.hook_event_name === 'SessionStart', + ); + + expect(sessionStartLog).toBeDefined(); + + // Verify the API request contained the injected context + // rig.readAllApiRequest() gives us telemetry on API requests. + const apiRequests = rig.readAllApiRequest(); + // We expect at least one API request + expect(apiRequests.length).toBeGreaterThan(0); + + // The injected context should be in the request text + // For non-interactive mode, I prepended it to input: "context\n\ninput" + // The telemetry `request_text` should contain it. + const requestText = apiRequests[0].attributes?.request_text || ''; + expect(requestText).toContain('protocol droid'); + }); + + it('should fire SessionStart hook and display systemMessage in interactive mode', async () => { + // Create hook script that outputs JSON with systemMessage and additionalContext + const hookScript = `const fs = require('fs'); +console.log(JSON.stringify({ + decision: 'allow', + systemMessage: 'Interactive Session Start Message', + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: 'The user is a Jedi Master.' + } +}));`; + + rig.setup( + 'should fire SessionStart hook and display systemMessage in interactive mode', + { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.session-startup.responses', + ), + }, + ); + + const scriptPath = join( + rig.testDir!, + 'session_start_interactive_hook.cjs', + ); + writeFileSync(scriptPath, hookScript); + + rig.setup( + 'should fire SessionStart hook and display systemMessage in interactive mode', + { + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + SessionStart: [ + { + matcher: 'startup', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }, + ); + + const run = await rig.runInteractive(); + + // Verify systemMessage is displayed + await run.expectText('Interactive Session Start Message', 10000); + + // Send a prompt to establish a session and trigger an API call + await run.sendKeys('Hello'); + await run.type('\r'); + + // Wait for response to ensure API call happened + await run.expectText('Hello', 15000); + + // Wait for telemetry to be written to disk + await rig.waitForTelemetryReady(); + + // Verify the API request contained the injected context + // We may need to poll for API requests as they are written asynchronously + const pollResult = await poll( + () => { + const apiRequests = rig.readAllApiRequest(); + return apiRequests.length > 0; + }, + 15000, + 500, + ); + + expect(pollResult).toBe(true); + + const apiRequests = rig.readAllApiRequest(); + // The injected context should be in the request_text of the API request + const requestText = apiRequests[0].attributes?.request_text || ''; + expect(requestText).toContain('Jedi Master'); + }); + it('should fire SessionEnd and SessionStart hooks on /clear command', async () => { // Create inline hook commands for both SessionEnd and SessionStart const sessionEndCommand = @@ -957,7 +1322,7 @@ try { const sessionStartCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\""; - await rig.setup( + rig.setup( 'should fire SessionEnd and SessionStart hooks on /clear command', { fakeResponsesPath: join( @@ -965,8 +1330,8 @@ try { 'hooks-system.session-clear.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { SessionEnd: [ @@ -1002,7 +1367,7 @@ try { // Send an initial prompt to establish a session await run.sendKeys('Say hello'); - await run.sendKeys('\r'); + await run.type('\r'); // Wait for the response await run.expectText('Hello', 10000); @@ -1012,14 +1377,14 @@ try { const numClears = 3; for (let i = 0; i < numClears; i++) { await run.sendKeys('/clear'); - await run.sendKeys('\r'); + await run.type('\r'); // Wait a bit for clear to complete await new Promise((resolve) => setTimeout(resolve, 2000)); // Send a prompt to establish an active session before next clear await run.sendKeys('Say hello'); - await run.sendKeys('\r'); + await run.type('\r'); // Wait for response await run.expectText('Hello', 10000); @@ -1133,14 +1498,14 @@ try { const preCompressCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\""; - await rig.setup('should fire PreCompress hook on automatic compression', { + rig.setup('should fire PreCompress hook on automatic compression', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.compress-auto.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { PreCompress: [ @@ -1200,14 +1565,14 @@ try { const sessionEndCommand = "node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\""; - await rig.setup('should fire SessionEnd hook on graceful exit', { + rig.setup('should fire SessionEnd hook on graceful exit', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.session-startup.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { SessionEnd: [ @@ -1284,7 +1649,7 @@ try { describe('Hook Disabling', () => { it('should not execute hooks disabled in settings file', async () => { - await rig.setup('should not execute hooks disabled in settings file', { + rig.setup('should not execute hooks disabled in settings file', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.disabled-via-settings.responses', @@ -1304,10 +1669,11 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho writeFileSync(enabledPath, enabledHookScript); writeFileSync(disabledPath, disabledHookScript); - await rig.setup('should not execute hooks disabled in settings file', { + rig.setup('should not execute hooks disabled in settings file', { settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, + disabled: [`node "${disabledPath}"`], // Disable the second hook }, hooks: { BeforeTool: [ @@ -1326,7 +1692,6 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho ], }, ], - disabled: [`node "${disabledPath}"`], // Disable the second hook }, }, }); @@ -1361,15 +1726,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho }); it('should respect disabled hooks across multiple operations', async () => { - await rig.setup( - 'should respect disabled hooks across multiple operations', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.disabled-via-command.responses', - ), - }, - ); + rig.setup('should respect disabled hooks across multiple operations', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-command.responses', + ), + }); // Create two hook scripts - one that will be disabled, one that won't const activeHookScript = `const fs = require('fs'); @@ -1384,35 +1746,32 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho writeFileSync(activePath, activeHookScript); writeFileSync(disabledPath, disabledHookScript); - await rig.setup( - 'should respect disabled hooks across multiple operations', - { - settings: { - tools: { - enableHooks: true, - }, - hooks: { - BeforeTool: [ - { - hooks: [ - { - type: 'command', - command: `node "${activePath}"`, - timeout: 5000, - }, - { - type: 'command', - command: `node "${disabledPath}"`, - timeout: 5000, - }, - ], - }, - ], - disabled: [`node "${disabledPath}"`], // Disable the second hook - }, + rig.setup('should respect disabled hooks across multiple operations', { + settings: { + hooksConfig: { + enabled: true, + disabled: [`node "${disabledPath}"`], // Disable the second hook, + }, + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: `node "${activePath}"`, + timeout: 5000, + }, + { + type: 'command', + command: `node "${disabledPath}"`, + timeout: 5000, + }, + ], + }, + ], }, }, - ); + }); // First run - only active hook should execute const result1 = await rig.run({ @@ -1463,9 +1822,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho describe('BeforeTool Hooks - Input Override', () => { it('should override tool input parameters via BeforeTool hook', async () => { // 1. First setup to get the test directory and prepare the hook script - await rig.setup( - 'should override tool input parameters via BeforeTool hook', - ); + rig.setup('should override tool input parameters via BeforeTool hook'); // Create a hook script that overrides the tool input const hookOutput = { @@ -1492,34 +1849,31 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho const commandPath = scriptPath.replace(/\\/g, '/'); // 2. Full setup with settings and fake responses - await rig.setup( - 'should override tool input parameters via BeforeTool hook', - { - fakeResponsesPath: join( - import.meta.dirname, - 'hooks-system.input-modification.responses', - ), - settings: { - tools: { - enableHooks: true, - }, - hooks: { - BeforeTool: [ - { - matcher: 'write_file', - hooks: [ - { - type: 'command', - command: `node "${commandPath}"`, - timeout: 5000, - }, - ], - }, - ], - }, + rig.setup('should override tool input parameters via BeforeTool hook', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.input-modification.responses', + ), + settings: { + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${commandPath}"`, + timeout: 5000, + }, + ], + }, + ], }, }, - ); + }); // Run the agent. The fake response will attempt to call write_file with // file_path="original.txt" and content="original content" @@ -1576,19 +1930,19 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho hookOutput, )}));`; - await rig.setup('should stop agent execution via BeforeTool hook'); + rig.setup('should stop agent execution via BeforeTool hook'); const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js'); writeFileSync(scriptPath, hookScript); const commandPath = scriptPath.replace(/\\/g, '/'); - await rig.setup('should stop agent execution via BeforeTool hook', { + rig.setup('should stop agent execution via BeforeTool hook', { fakeResponsesPath: join( import.meta.dirname, 'hooks-system.before-tool-stop.responses', ), settings: { - tools: { - enableHooks: true, + hooksConfig: { + enabled: true, }, hooks: { BeforeTool: [ diff --git a/integration-tests/json-output.france.responses b/integration-tests/json-output.france.responses new file mode 100644 index 0000000000..5c9edce888 --- /dev/null +++ b/integration-tests/json-output.france.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The capital of France is Paris."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":7,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} diff --git a/integration-tests/json-output.session-id.responses b/integration-tests/json-output.session-id.responses new file mode 100644 index 0000000000..c96cbccea4 --- /dev/null +++ b/integration-tests/json-output.session-id.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":9,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} \ No newline at end of file diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 7d364d8c6c..215cf21226 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -14,7 +14,6 @@ describe('JSON output', () => { beforeEach(async () => { rig = new TestRig(); - await rig.setup('json-output-test'); }); afterEach(async () => { @@ -22,6 +21,12 @@ describe('JSON output', () => { }); it('should return a valid JSON with response and stats', async () => { + await rig.setup('json-output-france', { + fakeResponsesPath: join( + import.meta.dirname, + 'json-output.france.responses', + ), + }); const result = await rig.run({ args: ['What is the capital of France?', '--output-format', 'json'], }); @@ -36,6 +41,12 @@ describe('JSON output', () => { }); it('should return a valid JSON with a session ID', async () => { + await rig.setup('json-output-session-id', { + fakeResponsesPath: join( + import.meta.dirname, + 'json-output.session-id.responses', + ), + }); const result = await rig.run({ args: ['Hello', '--output-format', 'json'], }); @@ -47,7 +58,6 @@ describe('JSON output', () => { }); it('should return a JSON error for sd auth mismatch before running', async () => { - process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; await rig.setup('json-output-auth-mismatch', { settings: { security: { @@ -58,12 +68,13 @@ describe('JSON output', () => { let thrown: Error | undefined; try { - await rig.run({ args: ['Hello', '--output-format', 'json'] }); + await rig.run({ + args: ['Hello', '--output-format', 'json'], + env: { GOOGLE_GENAI_USE_GCA: 'true' }, + }); expect.fail('Expected process to exit with error'); } catch (e) { thrown = e as Error; - } finally { - delete process.env['GOOGLE_GENAI_USE_GCA']; } expect(thrown).toBeDefined(); @@ -102,7 +113,7 @@ describe('JSON output', () => { }); it('should not exit on tool errors and allow model to self-correct in JSON mode', async () => { - rig.setup('json-output-error', { + await rig.setup('json-output-error', { fakeResponsesPath: join( import.meta.dirname, 'json-output.error.responses', diff --git a/integration-tests/mcp_server_cyclic_schema.test.ts b/integration-tests/mcp_server_cyclic_schema.test.ts index 742a35fe78..29373dbac4 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.ts +++ b/integration-tests/mcp_server_cyclic_schema.test.ts @@ -200,7 +200,7 @@ describe('mcp server with cyclic tool schema is detected', () => { const run = await rig.runInteractive(); await run.type('/mcp list'); - await run.sendKeys('\r'); + await run.type('\r'); await run.expectText('tool_with_cyclic_schema'); }); diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index ac8a3eb169..fee76aedc1 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -31,6 +31,18 @@ class MockConfig { getFileFilteringRespectGeminiIgnore() { return true; } + + getFileFilteringOptions() { + return { + respectGitIgnore: true, + respectGeminiIgnore: true, + customIgnoreFilePaths: [], + }; + } + + validatePathAccess() { + return null; + } } describe('ripgrep-real-direct', () => { diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 9ac0cf0533..027f4cba8d 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -164,7 +164,7 @@ describe('run_shell_command', () => { const result = await rig.run({ args: [`--allowed-tools=run_shell_command(${tool})`], stdin: prompt, - yolo: false, + approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); @@ -207,7 +207,7 @@ describe('run_shell_command', () => { const result = await rig.run({ args: '--allowed-tools=run_shell_command', stdin: prompt, - yolo: false, + approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); @@ -231,8 +231,8 @@ describe('run_shell_command', () => { expect(toolCall.toolRequest.success).toBe(true); }); - it('should succeed with --yolo mode', async () => { - await rig.setup('should succeed with --yolo mode', { + it('should succeed in yolo mode', async () => { + await rig.setup('should succeed in yolo mode', { settings: { tools: { core: ['run_shell_command'] } }, }); @@ -242,7 +242,7 @@ describe('run_shell_command', () => { const result = await rig.run({ args: prompt, - yolo: true, + approvalMode: 'yolo', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); @@ -276,7 +276,7 @@ describe('run_shell_command', () => { const result = await rig.run({ args: `--allowed-tools=ShellTool(${tool})`, stdin: prompt, - yolo: false, + approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); @@ -325,7 +325,7 @@ describe('run_shell_command', () => { '--allowed-tools=run_shell_command(ls)', ], stdin: prompt, - yolo: false, + approvalMode: 'default', }); for (const expected in ['ls', tool]) { @@ -377,7 +377,7 @@ describe('run_shell_command', () => { const result = await rig.run({ args: `--allowed-tools=run_shell_command(${allowedCommand})`, stdin: prompt, - yolo: false, + approvalMode: 'default', }); if (!result.toLowerCase().includes('fail')) { @@ -438,7 +438,7 @@ describe('run_shell_command', () => { await rig.run({ args: `--allowed-tools=ShellTool(${chained.allowPattern})`, stdin: `${shellInjection}\n`, - yolo: false, + approvalMode: 'default', }); // CLI should refuse to execute the chained command without scheduling run_shell_command. @@ -470,7 +470,7 @@ describe('run_shell_command', () => { '--allowed-tools=run_shell_command', ], stdin: prompt, - yolo: false, + approvalMode: 'default', }); const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000); @@ -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'); diff --git a/integration-tests/save_memory.test.ts b/integration-tests/save_memory.test.ts deleted file mode 100644 index 38b4d060fa..0000000000 --- a/integration-tests/save_memory.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; - -describe('save_memory', () => { - let rig: TestRig; - - beforeEach(() => { - rig = new TestRig(); - }); - - afterEach(async () => await rig.cleanup()); - - it('should be able to save to memory', async () => { - await rig.setup('should be able to save to memory', { - settings: { tools: { core: ['save_memory'] } }, - }); - - const prompt = `remember that my favorite color is blue. - - what is my favorite color? tell me that and surround it with $ symbol`; - const result = await rig.run({ args: prompt }); - - const foundToolCall = await rig.waitForToolCall('save_memory'); - - // Add debugging information - if (!foundToolCall || !result.toLowerCase().includes('blue')) { - const allTools = printDebugInfo(rig, result, { - 'Found tool call': foundToolCall, - 'Contains blue': result.toLowerCase().includes('blue'), - }); - - console.error( - 'Memory tool calls:', - allTools - .filter((t) => t.toolRequest.name === 'save_memory') - .map((t) => t.toolRequest.args), - ); - } - - expect( - foundToolCall, - 'Expected to find a save_memory tool call', - ).toBeTruthy(); - - // Validate model output - will throw if no output, warn if missing expected content - validateModelOutput(result, 'blue', 'Save memory test'); - }); -}); diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index 1de8007810..6db9927616 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -164,7 +164,7 @@ rpc.send({ }); `; -describe('simple-mcp-server', () => { +describe.skip('simple-mcp-server', () => { let rig: TestRig; beforeEach(() => { diff --git a/integration-tests/skill-creator-scripts.test.ts b/integration-tests/skill-creator-scripts.test.ts new file mode 100644 index 0000000000..82bc8c4ece --- /dev/null +++ b/integration-tests/skill-creator-scripts.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +describe('skill-creator scripts e2e', () => { + let rig: TestRig; + const initScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/init_skill.cjs', + ); + const validateScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/validate_skill.cjs', + ); + const packageScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/package_skill.cjs', + ); + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should initialize, validate, and package a skill', async () => { + await rig.setup('skill-creator scripts e2e'); + const skillName = 'e2e-test-skill'; + const tempDir = rig.testDir!; + + // 1. Initialize + execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`, { + stdio: 'inherit', + }); + const skillDir = path.join(tempDir, skillName); + + expect(fs.existsSync(skillDir)).toBe(true); + expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); + expect( + fs.existsSync(path.join(skillDir, 'scripts/example_script.cjs')), + ).toBe(true); + + // 2. Validate (should have warning initially due to TODOs) + const validateOutputInitial = execSync( + `node "${validateScript}" "${skillDir}" 2>&1`, + { encoding: 'utf8' }, + ); + expect(validateOutputInitial).toContain('โš ๏ธ Found unresolved TODO'); + + // 3. Package (should fail due to TODOs) + try { + execSync(`node "${packageScript}" "${skillDir}" "${tempDir}"`, { + stdio: 'pipe', + }); + throw new Error('Packaging should have failed due to TODOs'); + } catch (err: unknown) { + expect((err as Error).message).toContain('Command failed'); + } + + // 4. Fix SKILL.md (remove TODOs) + let content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + // More aggressive global replace for all TODO patterns + content = content.replace(/TODO:[^\n]*/g, 'Fixed'); + content = content.replace(/\[TODO:[^\]]*\]/g, 'Fixed'); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Also remove TODOs from example scripts + const exampleScriptPath = path.join(skillDir, 'scripts/example_script.cjs'); + let scriptContent = fs.readFileSync(exampleScriptPath, 'utf8'); + scriptContent = scriptContent.replace(/TODO:[^\n]*/g, 'Fixed'); + fs.writeFileSync(exampleScriptPath, scriptContent); + + // 4. Validate again (should pass now) + const validateOutput = execSync(`node "${validateScript}" "${skillDir}"`, { + encoding: 'utf8', + }); + expect(validateOutput).toContain('Skill is valid!'); + + // 5. Package + execSync(`node "${packageScript}" "${skillDir}" "${tempDir}"`, { + stdio: 'inherit', + }); + const skillFile = path.join(tempDir, `${skillName}.skill`); + expect(fs.existsSync(skillFile)).toBe(true); + + // 6. Verify zip content (should NOT have nested directory) + // Use unzip -l if available, otherwise fallback to tar -tf (common on Windows) + let zipList: string; + try { + zipList = execSync(`unzip -l "${skillFile}"`, { encoding: 'utf8' }); + } catch { + zipList = execSync(`tar -tf "${skillFile}"`, { encoding: 'utf8' }); + } + expect(zipList).toContain('SKILL.md'); + expect(zipList).not.toContain(`${skillName}/SKILL.md`); + }); +}); diff --git a/integration-tests/skill-creator-vulnerabilities.test.ts b/integration-tests/skill-creator-vulnerabilities.test.ts new file mode 100644 index 0000000000..b94273e57f --- /dev/null +++ b/integration-tests/skill-creator-vulnerabilities.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync, spawnSync } from 'node:child_process'; + +describe('skill-creator scripts security and bug fixes', () => { + let rig: TestRig; + const initScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/init_skill.cjs', + ); + const validateScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/validate_skill.cjs', + ); + const packageScript = path.resolve( + 'packages/core/src/skills/builtin/skill-creator/scripts/package_skill.cjs', + ); + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should prevent command injection in package_skill.cjs', async () => { + await rig.setup('skill-creator command injection'); + const tempDir = rig.testDir!; + + // Create a dummy skill + const skillName = 'injection-test'; + execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`); + const skillDir = path.join(tempDir, skillName); + + // Malicious output filename with command injection + const maliciousFilename = '"; touch injection_success; #'; + + // Attempt to package with malicious filename + // We expect this to fail or at least NOT create the 'injection_success' file + spawnSync('node', [packageScript, skillDir, tempDir, maliciousFilename], { + cwd: tempDir, + }); + + const injectionFile = path.join(tempDir, 'injection_success'); + expect(fs.existsSync(injectionFile)).toBe(false); + }); + + it('should prevent path traversal in init_skill.cjs', async () => { + await rig.setup('skill-creator init path traversal'); + const tempDir = rig.testDir!; + + const maliciousName = '../traversal-success'; + + const result = spawnSync( + 'node', + [initScript, maliciousName, '--path', tempDir], + { + encoding: 'utf8', + }, + ); + + expect(result.stderr).toContain( + 'Error: Skill name cannot contain path separators', + ); + const traversalDir = path.join(path.dirname(tempDir), 'traversal-success'); + expect(fs.existsSync(traversalDir)).toBe(false); + }); + + it('should prevent path traversal in validate_skill.cjs', async () => { + await rig.setup('skill-creator validate path traversal'); + + const maliciousPath = '../../../../etc/passwd'; + const result = spawnSync('node', [validateScript, maliciousPath], { + encoding: 'utf8', + }); + + expect(result.stderr).toContain('Error: Path traversal detected'); + }); + + it('should not crash on empty description in validate_skill.cjs', async () => { + await rig.setup('skill-creator regex crash'); + const tempDir = rig.testDir!; + const skillName = 'empty-desc-skill'; + + execSync(`node "${initScript}" ${skillName} --path "${tempDir}"`); + const skillDir = path.join(tempDir, skillName); + const skillMd = path.join(skillDir, 'SKILL.md'); + + // Set an empty quoted description + let content = fs.readFileSync(skillMd, 'utf8'); + content = content.replace(/^description: .+$/m, 'description: ""'); + fs.writeFileSync(skillMd, content); + + const result = spawnSync('node', [validateScript, skillDir], { + encoding: 'utf8', + }); + + // It might still fail validation (e.g. TODOs), but it should NOT crash with a stack trace + expect(result.status).not.toBe(null); + expect(result.stderr).not.toContain( + "TypeError: Cannot read properties of undefined (reading 'trim')", + ); + }); +}); diff --git a/integration-tests/stdout-stderr-output-error.responses b/integration-tests/stdout-stderr-output-error.responses new file mode 100644 index 0000000000..9ab3a83984 --- /dev/null +++ b/integration-tests/stdout-stderr-output-error.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I could not find the file `nonexistent-file-that-does-not-exist.txt` in the current directory or its subdirectories. Please verify the file path or name."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":25,"totalTokenCount":35,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10}]}}]} diff --git a/integration-tests/stdout-stderr-output.responses b/integration-tests/stdout-stderr-output.responses new file mode 100644 index 0000000000..e78165ae60 --- /dev/null +++ b/integration-tests/stdout-stderr-output.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":9,"totalTokenCount":14,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/integration-tests/stdout-stderr-output.test.ts b/integration-tests/stdout-stderr-output.test.ts new file mode 100644 index 0000000000..f401e3a6a8 --- /dev/null +++ b/integration-tests/stdout-stderr-output.test.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { TestRig } from './test-helper.js'; + +describe('stdout-stderr-output', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should send model response to stdout and app messages to stderr', async ({ + signal, + }) => { + await rig.setup('prompt-output-test', { + fakeResponsesPath: join( + import.meta.dirname, + 'stdout-stderr-output.responses', + ), + }); + + const { stdout, exitCode } = await rig.runWithStreams(['-p', 'Say hello'], { + signal, + }); + + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).toContain('hello'); + expect(stdout).not.toMatch(/^\[ERROR\]/m); + expect(stdout).not.toMatch(/^\[INFO\]/m); + }); + + it('should handle missing file with message to stdout and error to stderr', async ({ + signal, + }) => { + await rig.setup('error-output-test', { + fakeResponsesPath: join( + import.meta.dirname, + 'stdout-stderr-output-error.responses', + ), + }); + + const { stdout, exitCode } = await rig.runWithStreams( + ['-p', '@nonexistent-file-that-does-not-exist.txt explain this'], + { signal }, + ); + + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).toMatch( + /could not find|not exist|does not exist/, + ); + }); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index e197c724a5..a13f260c4b 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -4,1115 +4,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { expect } from 'vitest'; -import { execSync, spawn } from 'node:child_process'; -import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { env } from 'node:process'; -import { DEFAULT_GEMINI_MODEL } from '../packages/core/src/config/models.js'; -import fs from 'node:fs'; -import * as pty from '@lydell/node-pty'; -import stripAnsi from 'strip-ansi'; -import * as os from 'node:os'; -import { GEMINI_DIR } from '../packages/core/src/utils/paths.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js'); - -// Get timeout based on environment -function getDefaultTimeout() { - if (env['CI']) return 60000; // 1 minute in CI - if (env['GEMINI_SANDBOX']) return 30000; // 30s in containers - return 15000; // 15s locally -} - -export async function poll( - predicate: () => boolean, - timeout: number, - interval: number, -): Promise { - const startTime = Date.now(); - let attempts = 0; - while (Date.now() - startTime < timeout) { - attempts++; - const result = predicate(); - if (env['VERBOSE'] === 'true' && attempts % 5 === 0) { - console.log( - `Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`, - ); - } - if (result) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - if (env['VERBOSE'] === 'true') { - console.log(`Poll timed out after ${attempts} attempts`); - } - return false; -} - -function sanitizeTestName(name: string) { - return name - .toLowerCase() - .replace(/[^a-z0-9]/g, '-') - .replace(/-+/g, '-'); -} - -// Helper to create detailed error messages -export function createToolCallErrorMessage( - expectedTools: string | string[], - foundTools: string[], - result: string, -) { - const expectedStr = Array.isArray(expectedTools) - ? expectedTools.join(' or ') - : expectedTools; - return ( - `Expected to find ${expectedStr} tool call(s). ` + - `Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` + - `Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}` - ); -} - -// Helper to print debug information when tests fail -export function printDebugInfo( - rig: TestRig, - result: string, - context: Record = {}, -) { - console.error('Test failed - Debug info:'); - console.error('Result length:', result.length); - console.error('Result (first 500 chars):', result.substring(0, 500)); - console.error( - 'Result (last 500 chars):', - result.substring(result.length - 500), - ); - - // Print any additional context provided - Object.entries(context).forEach(([key, value]) => { - console.error(`${key}:`, value); - }); - - // Check what tools were actually called - const allTools = rig.readToolLogs(); - console.error( - 'All tool calls found:', - allTools.map((t) => t.toolRequest.name), - ); - - return allTools; -} - -// Helper to validate model output and warn about unexpected content -export function validateModelOutput( - result: string, - expectedContent: string | (string | RegExp)[] | null = null, - testName = '', -) { - // First, check if there's any output at all (this should fail the test if missing) - if (!result || result.trim().length === 0) { - throw new Error('Expected LLM to return some output'); - } - - // If expectedContent is provided, check for it and warn if missing - if (expectedContent) { - const contents = Array.isArray(expectedContent) - ? expectedContent - : [expectedContent]; - const missingContent = contents.filter((content) => { - if (typeof content === 'string') { - return !result.toLowerCase().includes(content.toLowerCase()); - } else if (content instanceof RegExp) { - return !content.test(result); - } - return false; - }); - - if (missingContent.length > 0) { - console.warn( - `Warning: LLM did not include expected content in response: ${missingContent.join( - ', ', - )}.`, - 'This is not ideal but not a test failure.', - ); - console.warn( - 'The tool was called successfully, which is the main requirement.', - ); - console.warn('Expected content:', expectedContent); - console.warn('Actual output:', result); - return false; - } else if (env['VERBOSE'] === 'true') { - console.log(`${testName}: Model output validated successfully.`); - } - return true; - } - - return true; -} - -interface ParsedLog { - attributes?: { - 'event.name'?: string; - function_name?: string; - function_args?: string; - success?: boolean; - duration_ms?: number; - request_text?: string; - hook_event_name?: string; - hook_name?: string; - hook_input?: Record; - hook_output?: Record; - exit_code?: number; - stdout?: string; - stderr?: string; - error?: string; - }; - scopeMetrics?: { - metrics: { - descriptor: { - name: string; - }; - }[]; - }[]; -} - -export class InteractiveRun { - ptyProcess: pty.IPty; - public output = ''; - - constructor(ptyProcess: pty.IPty) { - this.ptyProcess = ptyProcess; - ptyProcess.onData((data) => { - this.output += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - process.stdout.write(data); - } - }); - } - - async expectText(text: string, timeout?: number) { - if (!timeout) { - timeout = getDefaultTimeout(); - } - await poll( - () => stripAnsi(this.output).toLowerCase().includes(text.toLowerCase()), - timeout, - 200, - ); - expect(stripAnsi(this.output).toLowerCase()).toContain(text.toLowerCase()); - } - - // This types slowly to make sure command is correct, but only work for short - // commands that are not multi-line, use sendKeys to type long prompts - async type(text: string) { - let typedSoFar = ''; - for (const char of text) { - this.ptyProcess.write(char); - typedSoFar += char; - - // Wait for the typed sequence so far to be echoed back. - const found = await poll( - () => stripAnsi(this.output).includes(typedSoFar), - 5000, // 5s timeout per character (generous for CI) - 10, // check frequently - ); - - if (!found) { - throw new Error( - `Timed out waiting for typed text to appear in output: "${typedSoFar}".\nStripped output:\n${stripAnsi( - this.output, - )}`, - ); - } - } - } - - // Types an entire string at once, necessary for some things like commands - // but may run into paste detection issues for larger strings. - async sendText(text: string) { - this.ptyProcess.write(text); - await new Promise((resolve) => setTimeout(resolve, 5)); - } - - // Simulates typing a string one character at a time to avoid paste detection. - async sendKeys(text: string) { - const delay = 5; - for (const char of text) { - this.ptyProcess.write(char); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - async kill() { - this.ptyProcess.kill(); - } - - expectExit(): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => - reject( - new Error(`Test timed out: process did not exit within a minute.`), - ), - 60000, - ); - this.ptyProcess.onExit(({ exitCode }) => { - clearTimeout(timer); - resolve(exitCode); - }); - }); - } -} - -export class TestRig { - testDir: string | null = null; - testName?: string; - _lastRunStdout?: string; - // Path to the copied fake responses file for this test. - fakeResponsesPath?: string; - // Original fake responses file path for rewriting goldens in record mode. - originalFakeResponsesPath?: string; - private _interactiveRuns: InteractiveRun[] = []; - - setup( - testName: string, - options: { - settings?: Record; - fakeResponsesPath?: string; - } = {}, - ) { - this.testName = testName; - const sanitizedName = sanitizeTestName(testName); - const testFileDir = - env['INTEGRATION_TEST_FILE_DIR'] || join(os.tmpdir(), 'gemini-cli-tests'); - this.testDir = join(testFileDir, sanitizedName); - mkdirSync(this.testDir, { recursive: true }); - if (options.fakeResponsesPath) { - this.fakeResponsesPath = join(this.testDir, 'fake-responses.json'); - this.originalFakeResponsesPath = options.fakeResponsesPath; - if (process.env['REGENERATE_MODEL_GOLDENS'] !== 'true') { - fs.copyFileSync(options.fakeResponsesPath, this.fakeResponsesPath); - } - } - - // Create a settings file to point the CLI to the local collector - const geminiDir = join(this.testDir, GEMINI_DIR); - mkdirSync(geminiDir, { recursive: true }); - // In sandbox mode, use an absolute path for telemetry inside the container - // The container mounts the test directory at the same path as the host - const telemetryPath = join(this.testDir, 'telemetry.log'); // Always use test directory for telemetry - - const settings = { - general: { - // Nightly releases sometimes becomes out of sync with local code and - // triggers auto-update, which causes tests to fail. - disableAutoUpdate: true, - previewFeatures: false, - }, - telemetry: { - enabled: true, - target: 'local', - otlpEndpoint: '', - outfile: telemetryPath, - }, - security: { - auth: { - selectedType: 'gemini-api-key', - }, - }, - ui: { - useAlternateBuffer: true, - }, - model: DEFAULT_GEMINI_MODEL, - sandbox: - env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false, - // Don't show the IDE connection dialog when running from VsCode - ide: { enabled: false, hasSeenNudge: true }, - ...options.settings, // Allow tests to override/add settings - }; - writeFileSync( - join(geminiDir, 'settings.json'), - JSON.stringify(settings, null, 2), - ); - } - - createFile(fileName: string, content: string) { - const filePath = join(this.testDir!, fileName); - writeFileSync(filePath, content); - return filePath; - } - - mkdir(dir: string) { - mkdirSync(join(this.testDir!, dir), { recursive: true }); - } - - sync() { - // ensure file system is done before spawning - execSync('sync', { cwd: this.testDir! }); - } - - /** - * The command and args to use to invoke Gemini CLI. Allows us to switch - * between using the bundled gemini.js (the default) and using the installed - * 'gemini' (used to verify npm bundles). - */ - private _getCommandAndArgs(extraInitialArgs: string[] = []): { - command: string; - initialArgs: string[]; - } { - const isNpmReleaseTest = - env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; - const command = isNpmReleaseTest ? 'gemini' : 'node'; - const initialArgs = isNpmReleaseTest - ? extraInitialArgs - : [BUNDLE_PATH, ...extraInitialArgs]; - if (this.fakeResponsesPath) { - if (process.env['REGENERATE_MODEL_GOLDENS'] === 'true') { - initialArgs.push('--record-responses', this.fakeResponsesPath); - } else { - initialArgs.push('--fake-responses', this.fakeResponsesPath); - } - } - return { command, initialArgs }; - } - - run(options: { - args?: string | string[]; - stdin?: string; - stdinDoesNotEnd?: boolean; - yolo?: boolean; - }): Promise { - const yolo = options.yolo !== false; - const { command, initialArgs } = this._getCommandAndArgs( - yolo ? ['--yolo'] : [], - ); - const commandArgs = [...initialArgs]; - const execOptions: { - cwd: string; - encoding: 'utf-8'; - input?: string; - } = { - cwd: this.testDir!, - encoding: 'utf-8', - }; - - if (options.args) { - if (Array.isArray(options.args)) { - commandArgs.push(...options.args); - } else { - commandArgs.push(options.args); - } - } - - if (options.stdin) { - execOptions.input = options.stdin; - } - - const child = spawn(command, commandArgs, { - cwd: this.testDir!, - stdio: 'pipe', - env: env, - }); - - let stdout = ''; - let stderr = ''; - - // Handle stdin if provided - if (execOptions.input) { - child.stdin!.write(execOptions.input); - } - - if (!options.stdinDoesNotEnd) { - child.stdin!.end(); - } - - child.stdout!.on('data', (data: Buffer) => { - stdout += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - process.stdout.write(data); - } - }); - - child.stderr!.on('data', (data: Buffer) => { - stderr += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - process.stderr.write(data); - } - }); - - const promise = new Promise((resolve, reject) => { - child.on('close', (code: number) => { - if (code === 0) { - // Store the raw stdout for Podman telemetry parsing - this._lastRunStdout = stdout; - - // Filter out telemetry output when running with Podman - // Podman seems to output telemetry to stdout even when writing to file - let result = stdout; - if (env['GEMINI_SANDBOX'] === 'podman') { - // Remove telemetry JSON objects from output - // They are multi-line JSON objects that start with { and contain telemetry fields - const lines = result.split(os.EOL); - const filteredLines = []; - let inTelemetryObject = false; - let braceDepth = 0; - - for (const line of lines) { - if (!inTelemetryObject && line.trim() === '{') { - // Check if this might be start of telemetry object - inTelemetryObject = true; - braceDepth = 1; - } else if (inTelemetryObject) { - // Count braces to track nesting - for (const char of line) { - if (char === '{') braceDepth++; - else if (char === '}') braceDepth--; - } - - // Check if we've closed all braces - if (braceDepth === 0) { - inTelemetryObject = false; - // Skip this line (the closing brace) - continue; - } - } else { - // Not in telemetry object, keep the line - filteredLines.push(line); - } - } - - result = filteredLines.join('\n'); - } - - // Check if this is a JSON output test - if so, don't include stderr - // as it would corrupt the JSON - const isJsonOutput = - commandArgs.includes('--output-format') && - commandArgs.includes('json'); - - // If we have stderr output and it's not a JSON test, include that also - if (stderr && !isJsonOutput) { - result += `\n\nStdErr:\n${stderr}`; - } - - resolve(result); - } else { - reject(new Error(`Process exited with code ${code}:\n${stderr}`)); - } - }); - }); - - return promise; - } - - runCommand( - args: string[], - options: { stdin?: string } = {}, - ): Promise { - const { command, initialArgs } = this._getCommandAndArgs(); - const commandArgs = [...initialArgs, ...args]; - - const child = spawn(command, commandArgs, { - cwd: this.testDir!, - stdio: 'pipe', - }); - - let stdout = ''; - let stderr = ''; - - if (options.stdin) { - child.stdin!.write(options.stdin); - child.stdin!.end(); - } - - child.stdout!.on('data', (data: Buffer) => { - stdout += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - process.stdout.write(data); - } - }); - - child.stderr!.on('data', (data: Buffer) => { - stderr += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - process.stderr.write(data); - } - }); - - const promise = new Promise((resolve, reject) => { - child.on('close', (code: number) => { - if (code === 0) { - this._lastRunStdout = stdout; - let result = stdout; - if (stderr) { - result += `\n\nStdErr:\n${stderr}`; - } - resolve(result); - } else { - reject(new Error(`Process exited with code ${code}:\n${stderr}`)); - } - }); - }); - - return promise; - } - - readFile(fileName: string) { - const filePath = join(this.testDir!, fileName); - const content = readFileSync(filePath, 'utf-8'); - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { - console.log(`--- FILE: ${filePath} ---`); - console.log(content); - console.log(`--- END FILE: ${filePath} ---`); - } - return content; - } - - async cleanup() { - // Kill any interactive runs that are still active - for (const run of this._interactiveRuns) { - try { - await run.kill(); - } catch (error) { - if (env['VERBOSE'] === 'true') { - console.warn('Failed to kill interactive run during cleanup:', error); - } - } - } - this._interactiveRuns = []; - - if ( - process.env['REGENERATE_MODEL_GOLDENS'] === 'true' && - this.fakeResponsesPath - ) { - fs.copyFileSync(this.fakeResponsesPath, this.originalFakeResponsesPath!); - } - // Clean up test directory - if (this.testDir && !env['KEEP_OUTPUT']) { - try { - fs.rmSync(this.testDir, { recursive: true, force: true }); - } catch (error) { - // Ignore cleanup errors - if (env['VERBOSE'] === 'true') { - console.warn('Cleanup warning:', (error as Error).message); - } - } - } - } - - async waitForTelemetryReady() { - // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); - - if (!logFilePath) return; - - // Wait for telemetry file to exist and have content - await poll( - () => { - if (!fs.existsSync(logFilePath)) return false; - try { - const content = readFileSync(logFilePath, 'utf-8'); - // Check if file has meaningful content (at least one complete JSON object) - return content.includes('"scopeMetrics"'); - } catch { - return false; - } - }, - 2000, // 2 seconds max - reduced since telemetry should flush on exit now - 100, // check every 100ms - ); - } - - async waitForTelemetryEvent(eventName: string, timeout?: number) { - if (!timeout) { - timeout = getDefaultTimeout(); - } - - await this.waitForTelemetryReady(); - - return poll( - () => { - const logs = this._readAndParseTelemetryLog(); - return logs.some( - (logData) => - logData.attributes && - logData.attributes['event.name'] === `gemini_cli.${eventName}`, - ); - }, - timeout, - 100, - ); - } - - async waitForToolCall( - toolName: string, - timeout?: number, - matchArgs?: (args: string) => boolean, - ) { - // Use environment-specific timeout - if (!timeout) { - timeout = getDefaultTimeout(); - } - - // Wait for telemetry to be ready before polling for tool calls - await this.waitForTelemetryReady(); - - return poll( - () => { - const toolLogs = this.readToolLogs(); - return toolLogs.some( - (log) => - log.toolRequest.name === toolName && - (matchArgs?.call(this, log.toolRequest.args) ?? true), - ); - }, - timeout, - 100, - ); - } - - async expectToolCallSuccess( - toolNames: string[], - timeout?: number, - matchArgs?: (args: string) => boolean, - ) { - // Use environment-specific timeout - if (!timeout) { - timeout = getDefaultTimeout(); - } - - // Wait for telemetry to be ready before polling for tool calls - await this.waitForTelemetryReady(); - - const success = await poll( - () => { - const toolLogs = this.readToolLogs(); - return toolNames.some((name) => - toolLogs.some( - (log) => - log.toolRequest.name === name && - log.toolRequest.success && - (matchArgs?.call(this, log.toolRequest.args) ?? true), - ), - ); - }, - timeout, - 100, - ); - - expect( - success, - `Expected to find successful toolCalls for ${JSON.stringify(toolNames)}`, - ).toBe(true); - } - - async waitForAnyToolCall(toolNames: string[], timeout?: number) { - // Use environment-specific timeout - if (!timeout) { - timeout = getDefaultTimeout(); - } - - // Wait for telemetry to be ready before polling for tool calls - await this.waitForTelemetryReady(); - - return poll( - () => { - const toolLogs = this.readToolLogs(); - return toolNames.some((name) => - toolLogs.some((log) => log.toolRequest.name === name), - ); - }, - timeout, - 100, - ); - } - - _parseToolLogsFromStdout(stdout: string) { - const logs: { - timestamp: number; - toolRequest: { - name: string; - args: string; - success: boolean; - duration_ms: number; - }; - }[] = []; - - // The console output from Podman is JavaScript object notation, not JSON - // Look for tool call events in the output - // Updated regex to handle tool names with hyphens and underscores - const toolCallPattern = - /body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g; - const matches = [...stdout.matchAll(toolCallPattern)]; - - for (const match of matches) { - const toolName = match[1]; - const success = match[2] === 'true'; - const duration = parseInt(match[3], 10); - - // Try to find function_args nearby - const matchIndex = match.index || 0; - const contextStart = Math.max(0, matchIndex - 500); - const contextEnd = Math.min(stdout.length, matchIndex + 500); - const context = stdout.substring(contextStart, contextEnd); - - // Look for function_args in the context - let args = '{}'; - const argsMatch = context.match(/function_args:\s*'([^']+)'/); - if (argsMatch) { - args = argsMatch[1]; - } - - // Also try to find function_name to double-check - // Updated regex to handle tool names with hyphens and underscores - const nameMatch = context.match(/function_name:\s*'([\w-]+)'/); - const actualToolName = nameMatch ? nameMatch[1] : toolName; - - logs.push({ - timestamp: Date.now(), - toolRequest: { - name: actualToolName, - args: args, - success: success, - duration_ms: duration, - }, - }); - } - - // If no matches found with the simple pattern, try the JSON parsing approach - // in case the format changes - if (logs.length === 0) { - const lines = stdout.split(os.EOL); - let currentObject = ''; - let inObject = false; - let braceDepth = 0; - - for (const line of lines) { - if (!inObject && line.trim() === '{') { - inObject = true; - braceDepth = 1; - currentObject = line + '\n'; - } else if (inObject) { - currentObject += line + '\n'; - - // Count braces - for (const char of line) { - if (char === '{') braceDepth++; - else if (char === '}') braceDepth--; - } - - // If we've closed all braces, try to parse the object - if (braceDepth === 0) { - inObject = false; - try { - const obj = JSON.parse(currentObject); - - // Check for tool call in different formats - if ( - obj.body && - obj.body.includes('Tool call:') && - obj.attributes - ) { - const bodyMatch = obj.body.match(/Tool call: (\w+)\./); - if (bodyMatch) { - logs.push({ - timestamp: obj.timestamp || Date.now(), - toolRequest: { - name: bodyMatch[1], - args: obj.attributes.function_args || '{}', - success: obj.attributes.success !== false, - duration_ms: obj.attributes.duration_ms || 0, - }, - }); - } - } else if ( - obj.attributes && - obj.attributes['event.name'] === 'gemini_cli.tool_call' - ) { - logs.push({ - timestamp: obj.attributes['event.timestamp'], - toolRequest: { - name: obj.attributes.function_name, - args: obj.attributes.function_args, - success: obj.attributes.success, - duration_ms: obj.attributes.duration_ms, - }, - }); - } - } catch { - // Not valid JSON - } - currentObject = ''; - } - } - } - } - - return logs; - } - - private _readAndParseTelemetryLog(): ParsedLog[] { - // Telemetry is always written to the test directory - const logFilePath = join(this.testDir!, 'telemetry.log'); - - if (!logFilePath || !fs.existsSync(logFilePath)) { - return []; - } - - const content = readFileSync(logFilePath, 'utf-8'); - - // Split the content into individual JSON objects - // They are separated by "}\n{" - const jsonObjects = content - .split(/}\n{/) - .map((obj, index, array) => { - // Add back the braces we removed during split - if (index > 0) obj = '{' + obj; - if (index < array.length - 1) obj = obj + '}'; - return obj.trim(); - }) - .filter((obj) => obj); - - const logs: ParsedLog[] = []; - - for (const jsonStr of jsonObjects) { - try { - const logData = JSON.parse(jsonStr); - logs.push(logData); - } catch (e) { - // Skip objects that aren't valid JSON - if (env['VERBOSE'] === 'true') { - console.error('Failed to parse telemetry object:', e); - } - } - } - - return logs; - } - - readToolLogs() { - // For Podman, first check if telemetry file exists and has content - // If not, fall back to parsing from stdout - if (env['GEMINI_SANDBOX'] === 'podman') { - // Try reading from file first - const logFilePath = join(this.testDir!, 'telemetry.log'); - - if (fs.existsSync(logFilePath)) { - try { - const content = readFileSync(logFilePath, 'utf-8'); - if (content && content.includes('"event.name"')) { - // File has content, use normal file parsing - // Continue to the normal file parsing logic below - } else if (this._lastRunStdout) { - // File exists but is empty or doesn't have events, parse from stdout - return this._parseToolLogsFromStdout(this._lastRunStdout); - } - } catch { - // Error reading file, fall back to stdout - if (this._lastRunStdout) { - return this._parseToolLogsFromStdout(this._lastRunStdout); - } - } - } else if (this._lastRunStdout) { - // No file exists, parse from stdout - return this._parseToolLogsFromStdout(this._lastRunStdout); - } - } - - const parsedLogs = this._readAndParseTelemetryLog(); - const logs: { - toolRequest: { - name: string; - args: string; - success: boolean; - duration_ms: number; - }; - }[] = []; - - for (const logData of parsedLogs) { - // Look for tool call logs - if ( - logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.tool_call' - ) { - const toolName = logData.attributes.function_name!; - logs.push({ - toolRequest: { - name: toolName, - args: logData.attributes.function_args ?? '{}', - success: logData.attributes.success ?? false, - duration_ms: logData.attributes.duration_ms ?? 0, - }, - }); - } - } - - return logs; - } - - readAllApiRequest(): ParsedLog[] { - const logs = this._readAndParseTelemetryLog(); - const apiRequests = logs.filter( - (logData) => - logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.api_request', - ); - return apiRequests; - } - - readLastApiRequest(): ParsedLog | null { - const logs = this._readAndParseTelemetryLog(); - const apiRequests = logs.filter( - (logData) => - logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.api_request', - ); - return apiRequests.pop() || null; - } - - async waitForMetric(metricName: string, timeout?: number) { - await this.waitForTelemetryReady(); - - const fullName = metricName.startsWith('gemini_cli.') - ? metricName - : `gemini_cli.${metricName}`; - - return poll( - () => { - const logs = this._readAndParseTelemetryLog(); - for (const logData of logs) { - if (logData.scopeMetrics) { - for (const scopeMetric of logData.scopeMetrics) { - for (const metric of scopeMetric.metrics) { - if (metric.descriptor.name === fullName) { - return true; - } - } - } - } - } - return false; - }, - timeout ?? getDefaultTimeout(), - 100, - ); - } - - readMetric(metricName: string): Record | null { - const logs = this._readAndParseTelemetryLog(); - for (const logData of logs) { - if (logData.scopeMetrics) { - for (const scopeMetric of logData.scopeMetrics) { - for (const metric of scopeMetric.metrics) { - if (metric.descriptor.name === `gemini_cli.${metricName}`) { - return metric; - } - } - } - } - } - return null; - } - - async runInteractive(options?: { - args?: string | string[]; - yolo?: boolean; - }): Promise { - const yolo = options?.yolo !== false; - const { command, initialArgs } = this._getCommandAndArgs( - yolo ? ['--yolo'] : [], - ); - const commandArgs = [...initialArgs]; - - if (options?.args) { - if (Array.isArray(options.args)) { - commandArgs.push(...options.args); - } else { - commandArgs.push(options.args); - } - } - - const ptyOptions: pty.IPtyForkOptions = { - name: 'xterm-color', - cols: 80, - rows: 80, - cwd: this.testDir!, - env: Object.fromEntries( - Object.entries(env).filter(([, v]) => v !== undefined), - ) as { [key: string]: string }, - }; - - const executable = command === 'node' ? process.execPath : command; - const ptyProcess = pty.spawn(executable, commandArgs, ptyOptions); - - const run = new InteractiveRun(ptyProcess); - this._interactiveRuns.push(run); - // Wait for the app to be ready - await run.expectText(' Type your message or @path/to/file', 30000); - return run; - } - - readHookLogs() { - const parsedLogs = this._readAndParseTelemetryLog(); - const logs: { - hookCall: { - hook_event_name: string; - hook_name: string; - hook_input: Record; - hook_output: Record; - exit_code: number; - stdout: string; - stderr: string; - duration_ms: number; - success: boolean; - error: string; - }; - }[] = []; - - for (const logData of parsedLogs) { - // Look for tool call logs - if ( - logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.hook_call' - ) { - logs.push({ - hookCall: { - hook_event_name: logData.attributes.hook_event_name ?? '', - hook_name: logData.attributes.hook_name ?? '', - hook_input: logData.attributes.hook_input ?? {}, - hook_output: logData.attributes.hook_output ?? {}, - exit_code: logData.attributes.exit_code ?? 0, - stdout: logData.attributes.stdout ?? '', - stderr: logData.attributes.stderr ?? '', - duration_ms: logData.attributes.duration_ms ?? 0, - success: logData.attributes.success ?? false, - error: logData.attributes.error ?? '', - }, - }); - } - } - - return logs; - } - - async pollCommand( - commandFn: () => Promise, - predicateFn: () => boolean, - timeout: number = 30000, - interval: number = 1000, - ) { - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - await commandFn(); - // Give it a moment to process - await new Promise((resolve) => setTimeout(resolve, 500)); - if (predicateFn()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - throw new Error(`pollCommand timed out after ${timeout}ms`); - } -} +export * from '@google/gemini-cli-test-utils'; diff --git a/package-lock.json b/package-lock.json index 18bb1df8f5..60e1601953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@google/gemini-cli", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "workspaces": [ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.4.6", + "ink": "npm:@jrichman/ink@6.4.8", "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, @@ -19,6 +19,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { + "@agentclientprotocol/sdk": "^0.12.0", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -72,13 +73,14 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "keytar": "^7.9.0", "node-pty": "^1.0.0" } }, "node_modules/@a2a-js/sdk": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.7.tgz", - "integrity": "sha512-1WBghkOjgiKt4rPNje8jlB9VateVQXqyjlc887bY/H8yM82Hlf0+5JW8zB98BPExKAplI5XqtXVH980J6vqi+w==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz", + "integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==", "license": "Apache-2.0", "dependencies": { "uuid": "^11.1.0" @@ -109,9 +111,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.11.0.tgz", - "integrity": "sha512-hngnMwQ13DCC7oEr0BUnrx+vTDFf/ToCLhF0YcCMWRs+v4X60rKQyAENsx0PdbQF21jC1VjMFkh2+vwNBLh6fQ==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", + "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -521,7 +523,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cookie": "^0.7.2" @@ -531,7 +533,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "statuses": "^2.0.1" @@ -541,7 +543,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "@types/tough-cookie": "^4.0.5", @@ -552,7 +554,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.33", @@ -591,7 +593,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -608,7 +609,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -625,7 +625,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,7 +641,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -659,7 +657,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -676,7 +673,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -693,7 +689,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -710,7 +705,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -727,7 +721,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -744,7 +737,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -761,7 +753,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -778,7 +769,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -795,7 +785,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -812,7 +801,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -829,7 +817,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -846,7 +833,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -863,7 +849,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -880,7 +865,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -897,7 +881,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -914,7 +897,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -931,7 +913,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -948,7 +929,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -965,7 +945,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -982,7 +961,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -999,7 +977,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1016,7 +993,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1562,6 +1538,18 @@ "node": ">=6" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1638,7 +1626,7 @@ "version": "5.1.14", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz", "integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.15", @@ -1660,7 +1648,7 @@ "version": "10.1.15", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@inquirer/figures": "^1.0.13", @@ -1688,7 +1676,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -1704,7 +1692,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -1717,7 +1705,7 @@ "version": "1.0.13", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -1727,7 +1715,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -1862,7 +1850,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2031,7 +2018,6 @@ "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", "license": "MIT", - "optional": true, "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", @@ -2120,11 +2106,12 @@ ] }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.23.0.tgz", - "integrity": "sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", + "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2134,6 +2121,8 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", @@ -2155,19 +2144,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2184,242 +2160,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz", "integrity": "sha512-B9nHSJYtsv79uo7QdkZ/b/WoKm20IkVSmTc/WCKarmDtFwM0dRx2ouEniqwNkzCSLn3fydzKmnMzjtfdOWt3VQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -2654,14 +2405,14 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@open-draft/logger": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", @@ -2672,7 +2423,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@opentelemetry/api": { @@ -3336,7 +3087,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3350,7 +3100,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3364,7 +3113,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3378,7 +3126,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3392,7 +3139,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3406,7 +3152,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3420,7 +3165,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3434,7 +3178,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3448,7 +3191,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3462,7 +3204,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3476,7 +3217,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3490,7 +3230,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3504,7 +3243,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3518,7 +3256,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3532,7 +3269,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3546,7 +3282,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3560,7 +3295,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3574,7 +3308,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3588,7 +3321,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3602,7 +3334,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3616,7 +3347,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3630,7 +3360,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4087,7 +3816,6 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -4121,7 +3849,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/cookiejar": { @@ -4145,14 +3873,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/diff": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", - "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/dotenv": { @@ -4169,7 +3889,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -4525,7 +4244,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/superagent": { @@ -5139,7 +4858,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -5156,7 +4874,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/spy": "3.2.4", @@ -5183,7 +4900,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" @@ -5196,7 +4912,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/utils": "3.2.4", @@ -5211,7 +4926,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", @@ -5226,7 +4940,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" @@ -5239,7 +4952,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", @@ -5662,25 +5374,13 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" @@ -6131,13 +5831,6 @@ "node": ">=8" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true - }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -6317,7 +6010,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6499,7 +6191,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6509,69 +6200,43 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" }, - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -6650,7 +6315,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -6715,7 +6379,6 @@ "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6831,7 +6494,6 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", @@ -6882,7 +6544,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 16" @@ -7102,7 +6763,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">= 12" @@ -7422,16 +7083,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -7462,10 +7123,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -7806,7 +7470,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8184,16 +7847,6 @@ "node": ">=6" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", @@ -8208,7 +7861,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -8240,9 +7892,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -8589,7 +8241,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -8664,7 +8315,6 @@ "version": "0.25.6", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -9113,7 +8763,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -9258,7 +8907,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, "license": "(MIT OR WTFPL)", "optional": true, "engines": { @@ -9282,53 +8930,48 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -9350,36 +8993,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9625,49 +9238,24 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", - "peer": true, "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true - }, - "node_modules/finalhandler/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -9859,19 +9447,18 @@ "license": "MIT" }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, "license": "MIT", "optional": true }, @@ -9902,7 +9489,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10101,7 +9687,7 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -10114,7 +9700,6 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, "license": "MIT", "optional": true }, @@ -10503,7 +10088,7 @@ "version": "16.11.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -10625,7 +10210,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/highlight.js": { @@ -10650,6 +10235,16 @@ "node": ">=0.10.0" } }, + "node_modules/hono": { + "version": "4.11.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz", + "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -10727,28 +10322,23 @@ "license": "BSD-2-Clause" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -10843,7 +10433,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10947,9 +10537,9 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz", - "integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==", + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", + "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", @@ -11416,7 +11006,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-number": { @@ -11825,11 +11415,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -11907,6 +11505,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11991,13 +11595,13 @@ } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dev": true, "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -12029,12 +11633,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -12042,7 +11646,6 @@ "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12381,9 +11984,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -12570,7 +12173,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, "license": "MIT" }, "node_modules/lowercase-keys": { @@ -12610,7 +12212,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -12691,12 +12292,12 @@ "license": "MIT" }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { @@ -12731,10 +12332,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -12753,6 +12357,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12794,15 +12399,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -12899,7 +12508,6 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, "license": "MIT", "optional": true }, @@ -12938,7 +12546,7 @@ "version": "2.10.4", "resolved": "https://registry.npmjs.org/msw/-/msw-2.10.4.tgz", "integrity": "sha512-6R1or/qyele7q3RyPwNuvc0IxO8L8/Aim6Sz5ncXEgcWUNxSKE+udriTOWHtpMwmfkLYlacA2y7TIx4cL5lgHA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -12983,14 +12591,14 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/msw/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -13030,7 +12638,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" @@ -13060,7 +12668,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -13079,7 +12686,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, "license": "MIT", "optional": true }, @@ -13091,9 +12697,9 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -13110,7 +12716,6 @@ "version": "3.75.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13124,7 +12729,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -13817,7 +13421,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/own-keys": { @@ -14140,13 +13744,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true - }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -14164,14 +13761,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14.16" @@ -14277,7 +13872,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -14306,7 +13900,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14506,7 +14099,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -14540,7 +14133,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -14557,12 +14150,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -14575,7 +14168,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/queue-microtask": { @@ -14635,26 +14228,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -15081,7 +14654,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/resolve": { @@ -15138,7 +14711,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -15316,7 +14889,6 @@ "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -15572,87 +15144,48 @@ "license": "MIT" }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" + "node": ">= 18" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -15819,7 +15352,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -15838,7 +15370,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, "funding": [ { "type": "github", @@ -15860,7 +15391,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, "funding": [ { "type": "github", @@ -15979,7 +15509,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -16058,7 +15587,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, "license": "MIT" }, "node_modules/statuses": { @@ -16074,7 +15602,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -16122,7 +15649,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string_decoder": { @@ -16395,7 +15922,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" @@ -16557,6 +16083,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/systeminformation": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz", + "integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -16661,11 +16213,26 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -16679,7 +16246,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, "license": "ISC", "optional": true }, @@ -16687,7 +16253,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -16928,7 +16493,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, "license": "MIT" }, "node_modules/tinycolor2": { @@ -16941,14 +16505,12 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -16965,7 +16527,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -16983,7 +16544,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -17006,7 +16566,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -17016,7 +16575,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -17026,7 +16584,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -17217,7 +16774,7 @@ "version": "4.20.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.25.0", @@ -17247,7 +16804,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -17283,25 +16839,14 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" @@ -17401,7 +16946,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -17468,9 +17013,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", - "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz", + "integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -17505,7 +17050,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -17541,7 +17086,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", @@ -17560,16 +17105,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -17619,7 +17154,6 @@ "version": "7.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -17694,7 +17228,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", @@ -17717,7 +17250,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -17735,7 +17267,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -17748,7 +17279,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -17821,7 +17351,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -17984,7 +17513,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -18254,7 +17782,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -18379,7 +17907,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -18471,9 +17999,9 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "dependencies": { - "@a2a-js/sdk": "^0.3.7", + "@a2a-js/sdk": "^0.3.8", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", @@ -18499,64 +18027,6 @@ "node": ">=20" } }, - "packages/a2a-server/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/a2a-server/node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/a2a-server/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/a2a-server/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "packages/a2a-server/node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -18570,202 +18040,6 @@ "url": "https://dotenvx.com" } }, - "packages/a2a-server/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/a2a-server/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "packages/a2a-server/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "packages/a2a-server/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/a2a-server/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "packages/a2a-server/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/a2a-server/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "packages/a2a-server/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "packages/a2a-server/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/a2a-server/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/a2a-server/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "packages/a2a-server/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "packages/a2a-server/node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -18781,10 +18055,10 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.11.0", + "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -18792,15 +18066,16 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", - "diff": "^7.0.0", + "diff": "^8.0.3", "dotenv": "^17.1.0", "extract-zip": "^2.0.1", "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.6", + "ink": "npm:@jrichman/ink@6.4.8", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -18830,7 +18105,6 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", - "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", "@types/react": "^19.2.0", @@ -18866,28 +18140,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/cli/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "license": "Apache-2.0", "dependencies": { - "@a2a-js/sdk": "^0.3.7", + "@a2a-js/sdk": "^0.3.8", "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", @@ -18911,7 +18169,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.0", "chardet": "^2.1.0", - "diff": "^7.0.0", + "diff": "^8.0.3", "fast-levenshtein": "^2.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", @@ -18930,6 +18188,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", "uuid": "^13.0.0", @@ -18938,7 +18197,6 @@ }, "devDependencies": { "@google/gemini-cli-test-utils": "file:../test-utils", - "@types/diff": "^7.0.2", "@types/fast-levenshtein": "^0.0.4", "@types/js-yaml": "^4.0.9", "@types/minimatch": "^5.1.2", @@ -18957,6 +18215,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "keytar": "^7.9.0", "node-pty": "^1.0.0" } }, @@ -19041,8 +18300,14 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "license": "Apache-2.0", + "dependencies": { + "@google/gemini-cli-core": "file:../core", + "@lydell/node-pty": "1.1.0", + "strip-ansi": "^7.1.2", + "vitest": "^3.2.4" + }, "devDependencies": { "typescript": "^5.3.3" }, @@ -19052,7 +18317,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", @@ -19084,128 +18349,6 @@ "integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==", "dev": true, "license": "MIT" - }, - "packages/vscode-ide-companion/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/vscode-ide-companion/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/vscode-ide-companion/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "packages/vscode-ide-companion/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "packages/vscode-ide-companion/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/vscode-ide-companion/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } } } } diff --git a/package.json b/package.json index 306652221d..e64d547254 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-nightly.20251227.37be16243" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -41,18 +41,20 @@ "test": "npm run test --workspaces --if-present", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", + "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", + "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", "test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman", "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", - "lint": "eslint . --ext .ts,.tsx && eslint integration-tests && eslint scripts", + "lint": "eslint . --cache", "lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format", "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", "format": "prettier --experimental-cli --write .", "typecheck": "npm run typecheck --workspaces --if-present", - "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", + "preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci", "prepare": "husky && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "release:version": "node scripts/version.js", @@ -62,7 +64,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.4.6", + "ink": "npm:@jrichman/ink@6.4.8", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -77,6 +79,7 @@ "LICENSE" ], "devDependencies": { + "@agentclientprotocol/sdk": "^0.12.0", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -121,7 +124,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.4.6", + "ink": "npm:@jrichman/ink@6.4.8", "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, @@ -132,6 +135,7 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", + "keytar": "^7.9.0", "node-pty": "^1.0.0" }, "lint-staged": { diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 75b1a30c25..dc3145f4b3 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "description": "Gemini CLI A2A Server", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "dependencies": { - "@a2a-js/sdk": "^0.3.7", + "@a2a-js/sdk": "^0.3.8", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 148ce21531..9b5bca8c5c 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -349,6 +349,44 @@ describe('Task', () => { }), ); }); + + it.each([ + { eventType: GeminiEventType.Retry, eventName: 'Retry' }, + { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' }, + ])( + 'should handle $eventName event without triggering error handling', + async ({ eventType }) => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools'); + const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate'); + + const event = { + type: eventType, + }; + + await task.acceptAgentMessage(event); + + expect(cancelPendingToolsSpy).not.toHaveBeenCalled(); + expect(setTaskStateSpy).not.toHaveBeenCalled(); + }, + ); }); describe('_schedulerToolCallsUpdate', () => { diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 187c155808..6fefd84919 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -378,7 +378,7 @@ export class Task { if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { this.pendingToolConfirmationDetails.set( tc.request.callId, - tc.confirmationDetails, + tc.confirmationDetails as ToolCallConfirmationDetails, ); } @@ -412,7 +412,9 @@ export class Task { toolCalls.forEach((tc: ToolCall) => { if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { // eslint-disable-next-line @typescript-eslint/no-floating-promises - tc.confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); + (tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); this.pendingToolConfirmationDetails.delete(tc.request.callId); } }); @@ -573,7 +575,10 @@ export class Task { EDIT_TOOL_NAMES.has(request.name), ); - if (restorableToolCalls.length > 0) { + if ( + restorableToolCalls.length > 0 && + this.config.getCheckpointingEnabled() + ) { const gitService = await this.config.getGitService(); if (gitService) { const { checkpointsToWrite, toolCallToCheckpointMap, errors } = @@ -707,6 +712,10 @@ export class Task { case GeminiEventType.ModelInfo: this.modelInfo = event.value; break; + case GeminiEventType.Retry: + case GeminiEventType.InvalidStream: + // An invalid stream should trigger a retry, which requires no action from the user. + break; case GeminiEventType.Error: default: { // Block scope for lexical declaration diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts index 70e32cc4fc..7a6b1e8091 100644 --- a/packages/a2a-server/src/commands/command-registry.test.ts +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -7,53 +7,79 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Command } from './types.js'; -describe('CommandRegistry', () => { - const mockListExtensionsCommandInstance: Command = { +const { + mockExtensionsCommand, + mockListExtensionsCommand, + mockExtensionsCommandInstance, + mockListExtensionsCommandInstance, +} = vi.hoisted(() => { + const listInstance: Command = { name: 'extensions list', description: 'Lists all installed extensions.', execute: vi.fn(), }; - const mockListExtensionsCommand = vi.fn( - () => mockListExtensionsCommandInstance, - ); - const mockExtensionsCommandInstance: Command = { + const extInstance: Command = { name: 'extensions', description: 'Manage extensions.', execute: vi.fn(), - subCommands: [mockListExtensionsCommandInstance], + subCommands: [listInstance], }; - const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance); + return { + mockListExtensionsCommandInstance: listInstance, + mockExtensionsCommandInstance: extInstance, + mockExtensionsCommand: vi.fn(() => extInstance), + mockListExtensionsCommand: vi.fn(() => listInstance), + }; +}); + +vi.mock('./extensions.js', () => ({ + ExtensionsCommand: mockExtensionsCommand, + ListExtensionsCommand: mockListExtensionsCommand, +})); + +vi.mock('./init.js', () => ({ + InitCommand: vi.fn(() => ({ + name: 'init', + description: 'Initializes the server.', + execute: vi.fn(), + })), +})); + +vi.mock('./restore.js', () => ({ + RestoreCommand: vi.fn(() => ({ + name: 'restore', + description: 'Restores the server.', + execute: vi.fn(), + })), +})); + +import { commandRegistry } from './command-registry.js'; + +describe('CommandRegistry', () => { beforeEach(async () => { - vi.resetModules(); - vi.doMock('./extensions.js', () => ({ - ExtensionsCommand: mockExtensionsCommand, - ListExtensionsCommand: mockListExtensionsCommand, - })); + vi.clearAllMocks(); + commandRegistry.initialize(); }); it('should register ExtensionsCommand on initialization', async () => { - const { commandRegistry } = await import('./command-registry.js'); expect(mockExtensionsCommand).toHaveBeenCalled(); const command = commandRegistry.get('extensions'); expect(command).toBe(mockExtensionsCommandInstance); - }); + }, 20000); it('should register sub commands on initialization', async () => { - const { commandRegistry } = await import('./command-registry.js'); const command = commandRegistry.get('extensions list'); expect(command).toBe(mockListExtensionsCommandInstance); }); it('get() should return undefined for a non-existent command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const command = commandRegistry.get('non-existent'); expect(command).toBeUndefined(); }); it('register() should register a new command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const mockCommand: Command = { name: 'test-command', description: '', @@ -65,7 +91,6 @@ describe('CommandRegistry', () => { }); it('register() should register a nested command', async () => { - const { commandRegistry } = await import('./command-registry.js'); const mockSubSubCommand: Command = { name: 'test-command-sub-sub', description: '', @@ -95,8 +120,8 @@ describe('CommandRegistry', () => { }); it('register() should not enter an infinite loop with a cyclic command', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const { commandRegistry } = await import('./command-registry.js'); + const { debugLogger } = await import('@google/gemini-cli-core'); + const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); const mockCommand: Command = { name: 'cyclic-command', description: '', @@ -112,7 +137,6 @@ describe('CommandRegistry', () => { expect(warnSpy).toHaveBeenCalledWith( 'Command cyclic-command already registered. Skipping.', ); - // If the test finishes, it means we didn't get into an infinite loop. warnSpy.mockRestore(); }); }); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts index 47e2800d9d..e9cd75b11a 100644 --- a/packages/a2a-server/src/commands/command-registry.ts +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -4,19 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MemoryCommand } from './memory.js'; import { debugLogger } from '@google/gemini-cli-core'; import { ExtensionsCommand } from './extensions.js'; import { InitCommand } from './init.js'; import { RestoreCommand } from './restore.js'; import type { Command } from './types.js'; -class CommandRegistry { +export class CommandRegistry { private readonly commands = new Map(); constructor() { + this.initialize(); + } + + initialize() { + this.commands.clear(); this.register(new ExtensionsCommand()); this.register(new RestoreCommand()); this.register(new InitCommand()); + this.register(new MemoryCommand()); } register(command: Command) { diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts index b897d0b9e3..df2a213cba 100644 --- a/packages/a2a-server/src/commands/init.test.ts +++ b/packages/a2a-server/src/commands/init.test.ts @@ -26,10 +26,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('node:fs', () => ({ - existsSync: vi.fn(), - writeFileSync: vi.fn(), -})); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); vi.mock('../agent/executor.js', () => ({ CoderAgentExecutor: vi.fn().mockImplementation(() => ({ diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts new file mode 100644 index 0000000000..40c5d1b90b --- /dev/null +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AddMemoryCommand, + ListMemoryCommand, + MemoryCommand, + RefreshMemoryCommand, + ShowMemoryCommand, +} from './memory.js'; +import type { CommandContext } from './types.js'; +import type { + AnyDeclarativeTool, + Config, + ToolRegistry, +} from '@google/gemini-cli-core'; + +// Mock the core functions +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + showMemory: vi.fn(), + refreshMemory: vi.fn(), + listMemoryFiles: vi.fn(), + addMemory: vi.fn(), + }; +}); + +const mockShowMemory = vi.mocked(showMemory); +const mockRefreshMemory = vi.mocked(refreshMemory); +const mockListMemoryFiles = vi.mocked(listMemoryFiles); +const mockAddMemory = vi.mocked(addMemory); + +describe('a2a-server memory commands', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockToolRegistry: ToolRegistry; + let mockSaveMemoryTool: AnyDeclarativeTool; + + beforeEach(() => { + mockSaveMemoryTool = { + name: 'save_memory', + description: 'Saves memory', + buildAndExecute: vi.fn().mockResolvedValue(undefined), + } as unknown as AnyDeclarativeTool; + + mockToolRegistry = { + getTool: vi.fn(), + } as unknown as ToolRegistry; + + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + } as unknown as Config; + + mockContext = { + config: mockConfig, + }; + + vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool); + }); + + describe('MemoryCommand', () => { + it('delegates to ShowMemoryCommand', async () => { + const command = new MemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'showing memory', + }); + const response = await command.execute(mockContext, []); + expect(response.data).toBe('showing memory'); + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + }); + }); + + describe('ShowMemoryCommand', () => { + it('executes showMemory and returns the content', async () => { + const command = new ShowMemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'test memory content', + }); + + const response = await command.execute(mockContext, []); + + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory show'); + expect(response.data).toBe('test memory content'); + }); + }); + + describe('RefreshMemoryCommand', () => { + it('executes refreshMemory and returns the content', async () => { + const command = new RefreshMemoryCommand(); + mockRefreshMemory.mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'memory refreshed', + }); + + const response = await command.execute(mockContext, []); + + expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory refresh'); + expect(response.data).toBe('memory refreshed'); + }); + }); + + describe('ListMemoryCommand', () => { + it('executes listMemoryFiles and returns the content', async () => { + const command = new ListMemoryCommand(); + mockListMemoryFiles.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'file1.md\nfile2.md', + }); + + const response = await command.execute(mockContext, []); + + expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory list'); + expect(response.data).toBe('file1.md\nfile2.md'); + }); + }); + + describe('AddMemoryCommand', () => { + it('returns message content if addMemory returns a message', async () => { + const command = new AddMemoryCommand(); + mockAddMemory.mockReturnValue({ + type: 'message', + messageType: 'error', + content: 'error message', + }); + + const response = await command.execute(mockContext, []); + + expect(mockAddMemory).toHaveBeenCalledWith(''); + expect(response.name).toBe('memory add'); + expect(response.data).toBe('error message'); + }); + + it('executes the save_memory tool if found', async () => { + const command = new AddMemoryCommand(); + const fact = 'this is a new fact'; + mockAddMemory.mockReturnValue({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact }, + }); + + const response = await command.execute(mockContext, [ + 'this', + 'is', + 'a', + 'new', + 'fact', + ]); + + expect(mockAddMemory).toHaveBeenCalledWith(fact); + expect(mockConfig.getToolRegistry).toHaveBeenCalled(); + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory'); + expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith( + { fact }, + expect.any(AbortSignal), + undefined, + { + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }, + ); + expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory add'); + expect(response.data).toBe(`Added memory: "${fact}"`); + }); + + it('returns an error if the tool is not found', async () => { + const command = new AddMemoryCommand(); + const fact = 'another fact'; + mockAddMemory.mockReturnValue({ + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact }, + }); + vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined); + + const response = await command.execute(mockContext, ['another', 'fact']); + + expect(response.name).toBe('memory add'); + expect(response.data).toBe('Error: Tool save_memory not found.'); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts new file mode 100644 index 0000000000..16af1d3fe2 --- /dev/null +++ b/packages/a2a-server/src/commands/memory.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +const DEFAULT_SANITIZATION_CONFIG = { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, +}; + +export class MemoryCommand implements Command { + readonly name = 'memory'; + readonly description = 'Manage memory.'; + readonly subCommands = [ + new ShowMemoryCommand(), + new RefreshMemoryCommand(), + new ListMemoryCommand(), + new AddMemoryCommand(), + ]; + readonly topLevel = true; + readonly requiresWorkspace = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ShowMemoryCommand().execute(context, _); + } +} + +export class ShowMemoryCommand implements Command { + readonly name = 'memory show'; + readonly description = 'Shows the current memory contents.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = showMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class RefreshMemoryCommand implements Command { + readonly name = 'memory refresh'; + readonly description = 'Refreshes the memory from the source.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = await refreshMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class ListMemoryCommand implements Command { + readonly name = 'memory list'; + readonly description = 'Lists the paths of the GEMINI.md files in use.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = listMemoryFiles(context.config); + return { name: this.name, data: result.content }; + } +} + +export class AddMemoryCommand implements Command { + readonly name = 'memory add'; + readonly description = 'Add content to the memory.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const textToAdd = args.join(' ').trim(); + const result = addMemory(textToAdd); + if (result.type === 'message') { + return { name: this.name, data: result.content }; + } + + const toolRegistry = context.config.getToolRegistry(); + const tool = toolRegistry.getTool(result.toolName); + if (tool) { + const abortController = new AbortController(); + const signal = abortController.signal; + await tool.buildAndExecute(result.toolArgs, signal, undefined, { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + }); + await refreshMemory(context.config); + return { + name: this.name, + data: `Added memory: "${textToAdd}"`, + }; + } else { + return { + name: this.name, + data: `Error: Tool ${result.toolName} not found.`, + }; + } + } +} diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts new file mode 100644 index 0000000000..06be9581a5 --- /dev/null +++ b/packages/a2a-server/src/config/config.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import { loadConfig } from './config.js'; +import type { Settings } from './settings.js'; +import { + type ExtensionLoader, + FileDiscoveryService, +} from '@google/gemini-cli-core'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Config: vi.fn().mockImplementation((params) => ({ + initialize: vi.fn(), + refreshAuth: vi.fn(), + ...params, // Expose params for assertion + })), + loadServerHierarchicalMemory: vi + .fn() + .mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }), + startupProfiler: { + flush: vi.fn(), + }, + FileDiscoveryService: vi.fn(), + }; +}); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('loadConfig', () => { + const mockSettings = {} as Settings; + const mockExtensionLoader = {} as ExtensionLoader; + const taskId = 'test-task-id'; + + beforeEach(() => { + vi.clearAllMocks(); + process.env['GEMINI_API_KEY'] = 'test-key'; + }); + + afterEach(() => { + delete process.env['CUSTOM_IGNORE_FILE_PATHS']; + delete process.env['GEMINI_API_KEY']; + }); + + 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; + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); + }); + + it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => { + const testPath = '/settings/ignore'; + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [testPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); + }); + + it('should merge customIgnoreFilePaths from settings and env var', async () => { + const envPath = '/env/ignore'; + const settingsPath = '/settings/ignore'; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath; + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [settingsPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + settingsPath, + envPath, + ]); + }); + + it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { + const paths = ['/path/one', '/path/two']; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter); + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); + }); + + it('should have empty customIgnoreFilePaths when both are missing', async () => { + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]); + }); + + it('should initialize FileDiscoveryService with correct options', async () => { + const testPath = '/tmp/ignore'; + process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + const settings: Settings = { + fileFiltering: { + respectGitIgnore: false, + }, + }; + + await loadConfig(settings, mockExtensionLoader, taskId); + + expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), { + respectGitIgnore: false, + respectGeminiIgnore: undefined, + customIgnoreFilePaths: [testPath], + }); + }); +}); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index d5158cba61..12ab87439a 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import * as dotenv from 'dotenv'; import type { TelemetryTarget } from '@google/gemini-cli-core'; @@ -23,6 +22,8 @@ import { type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, + homedir, + GitService, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; @@ -37,6 +38,23 @@ export async function loadConfig( const workspaceDir = process.cwd(); const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + const folderTrust = + settings.folderTrust === true || + process.env['GEMINI_FOLDER_TRUST'] === 'true'; + + let checkpointing = process.env['CHECKPOINTING'] + ? process.env['CHECKPOINTING'] === 'true' + : settings.checkpointing?.enabled; + + if (checkpointing) { + if (!(await GitService.verifyGitAvailability())) { + logger.warn( + '[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.', + ); + checkpointing = false; + } + } + const configParams: ConfigParameters = { sessionId: taskId, model: settings.general?.previewFeatures @@ -68,30 +86,44 @@ export async function loadConfig( // Git-aware file filtering settings fileFiltering: { respectGitIgnore: settings.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore, enableRecursiveFileSearch: settings.fileFiltering?.enableRecursiveFileSearch, + customIgnoreFilePaths: [ + ...(settings.fileFiltering?.customIgnoreFilePaths || []), + ...(process.env['CUSTOM_IGNORE_FILE_PATHS'] + ? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter) + : []), + ], }, ideMode: false, - folderTrust: settings.folderTrust === true, + folderTrust, + trustedFolder: true, extensionLoader, - checkpointing: process.env['CHECKPOINTING'] - ? process.env['CHECKPOINTING'] === 'true' - : settings.checkpointing?.enabled, + checkpointing, previewFeatures: settings.general?.previewFeatures, interactive: true, + enableInteractiveShell: true, + ptyInfo: 'auto', }; - const fileService = new FileDiscoveryService(workspaceDir); - const { memoryContent, fileCount } = await loadServerHierarchicalMemory( - workspaceDir, - [workspaceDir], - false, - fileService, - extensionLoader, - settings.folderTrust === true, - ); + const fileService = new FileDiscoveryService(workspaceDir, { + respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore, + customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths, + }); + const { memoryContent, fileCount, filePaths } = + await loadServerHierarchicalMemory( + workspaceDir, + [workspaceDir], + false, + fileService, + extensionLoader, + folderTrust, + ); configParams.userMemory = memoryContent; configParams.geminiMdFileCount = fileCount; + configParams.geminiMdFilePaths = filePaths; const config = new Config({ ...configParams, }); diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts index f56eadfb0c..7da0f0572e 100644 --- a/packages/a2a-server/src/config/extension.ts +++ b/packages/a2a-server/src/config/extension.ts @@ -11,10 +11,10 @@ import { type MCPServerConfig, type ExtensionInstallMetadata, type GeminiCLIExtension, + homedir, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { logger } from '../utils/logger.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -39,7 +39,7 @@ interface ExtensionConfig { export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] { const allExtensions = [ ...loadExtensionsFromDir(workspaceDir), - ...loadExtensionsFromDir(os.homedir()), + ...loadExtensionsFromDir(homedir()), ]; const uniqueExtensions: GeminiCLIExtension[] = []; diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index 0aebbb2a94..b5788b0fb6 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -27,13 +27,21 @@ vi.mock('node:os', async (importOriginal) => { }; }); -vi.mock('@google/gemini-cli-core', () => ({ - GEMINI_DIR: '.gemini', - debugLogger: { - error: vi.fn(), - }, - getErrorMessage: (error: unknown) => String(error), -})); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const path = await import('node:path'); + const os = await import('node:os'); + return { + ...actual, + GEMINI_DIR: '.gemini', + debugLogger: { + error: vi.fn(), + }, + getErrorMessage: (error: unknown) => String(error), + homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`), + }; +}); describe('loadSettings', () => { const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`); diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index f46db47b6f..f57e177681 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import type { MCPServerConfig } from '@google/gemini-cli-core'; import { @@ -14,6 +13,7 @@ import { GEMINI_DIR, getErrorMessage, type TelemetrySettings, + homedir, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; @@ -38,7 +38,9 @@ export interface Settings { // Git-aware file filtering settings fileFiltering?: { respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; enableRecursiveFileSearch?: boolean; + customIgnoreFilePaths?: string[]; }; } diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index f427bdfe63..4eb6b522b2 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -14,7 +14,7 @@ import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, } from '@a2a-js/sdk'; -import type express from 'express'; +import express from 'express'; import type { Server } from 'node:http'; import request from 'supertest'; import { @@ -27,7 +27,7 @@ import { it, vi, } from 'vitest'; -import { createApp } from './app.js'; +import { createApp, main } from './app.js'; import { commandRegistry } from '../commands/command-registry.js'; import { assertUniqueFinalEventIsLast, @@ -1176,4 +1176,43 @@ describe('E2E Tests', () => { }); }); }); + + describe('main', () => { + it('should listen on localhost only', async () => { + const listenSpy = vi + .spyOn(express.application, 'listen') + .mockImplementation((...args: unknown[]) => { + // Trigger the callback passed to listen + const callback = args.find( + (arg): arg is () => void => typeof arg === 'function', + ); + if (callback) { + callback(); + } + + return { + address: () => ({ port: 1234 }), + on: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + } as unknown as Server; + }); + + // Avoid process.exit if possible, or mock it if main might fail + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + await main(); + + expect(listenSpy).toHaveBeenCalledWith( + expect.any(Number), + 'localhost', + expect.any(Function), + ); + + listenSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 8d7be4f7a1..4b5763f00b 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -326,9 +326,9 @@ export async function createApp() { export async function main() { try { const expressApp = await createApp(); - const port = process.env['CODER_AGENT_PORT'] || 0; + const port = Number(process.env['CODER_AGENT_PORT'] || 0); - const server = expressApp.listen(port, () => { + const server = expressApp.listen(port, 'localhost', () => { const address = server.address(); let actualPort; if (process.env['CODER_AGENT_PORT']) { diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index d42ae02270..6ee9ddee23 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -9,7 +9,7 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import * as tar from 'tar'; import * as fse from 'fs-extra'; import { promises as fsPromises, createReadStream } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { tmpdir } from '@google/gemini-cli-core'; import { join } from 'node:path'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index d472b4f995..87c7315f82 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -16,6 +16,7 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, GeminiClient, HookSystem, + PolicyDecision, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import type { Config, Storage } from '@google/gemini-cli-core'; @@ -77,6 +78,17 @@ export function createMockConfig( mockConfig.getGeminiClient = vi .fn() .mockReturnValue(new GeminiClient(mockConfig)); + + mockConfig.getPolicyEngine = vi.fn().mockReturnValue({ + check: async () => { + const mode = mockConfig.getApprovalMode(); + if (mode === ApprovalMode.YOLO) { + return { decision: PolicyDecision.ALLOW }; + } + return { decision: PolicyDecision.ASK_USER }; + }, + }); + return mockConfig; } diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md new file mode 100644 index 0000000000..50bc038b42 --- /dev/null +++ b/packages/cli/GEMINI.md @@ -0,0 +1,15 @@ +## React & Ink (CLI UI) + +- **Side Effects**: Use reducers for complex state transitions; avoid `setState` + triggers in callbacks. +- Always fix react-hooks/exhaustive-deps lint errors by adding the missing + dependencies. +- **Shortcuts**: only define keyboard shortcuts in + `packages/cli/src/config/keyBindings.ts + +## Testing + +- **Utilities**: Use `renderWithProviders` and `waitFor` from + `packages/cli/src/test-utils/`. +- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output. +- **Mocks**: Use mocks as sparingly as possilble. diff --git a/packages/cli/examples/ask-user-dialog-demo.tsx b/packages/cli/examples/ask-user-dialog-demo.tsx new file mode 100644 index 0000000000..aeb22b30f0 --- /dev/null +++ b/packages/cli/examples/ask-user-dialog-demo.tsx @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { render, Box, Text } from 'ink'; +import { AskUserDialog } from '../src/ui/components/AskUserDialog.js'; +import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; +import { QuestionType, type Question } from '@google/gemini-cli-core'; + +const DEMO_QUESTIONS: Question[] = [ + { + question: 'What type of project are you building?', + header: 'Project Type', + options: [ + { label: 'Web Application', description: 'React, Next.js, or similar' }, + { label: 'CLI Tool', description: 'Command-line interface with Node.js' }, + { label: 'Library', description: 'NPM package or shared utility' }, + ], + multiSelect: false, + }, + { + question: 'Which features should be enabled?', + header: 'Features', + options: [ + { label: 'TypeScript', description: 'Add static typing' }, + { label: 'ESLint', description: 'Add linting and formatting' }, + { label: 'Unit Tests', description: 'Add Vitest setup' }, + { label: 'CI/CD', description: 'Add GitHub Actions' }, + ], + multiSelect: true, + }, + { + question: 'What is the project name?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'my-awesome-project', + }, + { + question: 'Initialize git repository?', + header: 'Git', + type: QuestionType.YESNO, + }, +]; + +const Demo = () => { + const [result, setResult] = useState(null); + const [cancelled, setCancelled] = useState(false); + + if (cancelled) { + return ( + + + Dialog was cancelled. Project initialization aborted. + + + ); + } + + if (result) { + return ( + + + Success! Project Configuration: + + {DEMO_QUESTIONS.map((q, i) => ( + + {q.header}: + {result[i] || '(not answered)'} + + ))} + + Press Ctrl+C to exit + + + ); + } + + return ( + + + + AskUserDialog Demo + + setCancelled(true)} + /> + + + ); +}; + +render(); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index b2bc5ca9e3..29a83b2337 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -11,6 +11,30 @@ import { FatalError, writeToStderr } from '@google/gemini-cli-core'; import { runExitCleanup } from './src/utils/cleanup.js'; // --- Global Entry Point --- + +// Suppress known race condition error in node-pty on Windows +// Tracking bug: https://github.com/microsoft/node-pty/issues/827 +process.on('uncaughtException', (error) => { + if ( + process.platform === 'win32' && + error instanceof Error && + error.message === 'Cannot resize a pty that has already exited' + ) { + // This error happens on Windows with node-pty when resizing a pty that has just exited. + // It is a race condition in node-pty that we cannot prevent, so we silence it. + return; + } + + // For other errors, we rely on the default behavior, but since we attached a listener, + // we must manually replicate it. + if (error instanceof Error) { + writeToStderr(error.stack + '\n'); + } else { + writeToStderr(String(error) + '\n'); + } + process.exit(1); +}); + main().catch(async (error) => { await runExitCleanup(); diff --git a/packages/cli/package.json b/packages/cli/package.json index a9540186ed..1dc3898e45 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.24.0-nightly.20251227.37be16243", + "version": "0.28.0-nightly.20260128.adc8e11bb", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,10 +26,10 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0-nightly.20251227.37be16243" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.28.0-nightly.20260128.adc8e11bb" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.11.0", + "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -37,15 +37,16 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", - "diff": "^7.0.0", + "diff": "^8.0.3", "dotenv": "^17.1.0", "extract-zip": "^2.0.1", "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.6", + "ink": "npm:@jrichman/ink@6.4.8", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -72,7 +73,6 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/archiver": "^6.0.3", "@types/command-exists": "^1.2.3", - "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", "@types/react": "^19.2.0", diff --git a/packages/cli/src/commands/extensions.test.tsx b/packages/cli/src/commands/extensions.test.tsx index d8aae8b359..0630b398ff 100644 --- a/packages/cli/src/commands/extensions.test.tsx +++ b/packages/cli/src/commands/extensions.test.tsx @@ -54,15 +54,33 @@ describe('extensionsCommand', () => { extensionsCommand.builder(mockYargs); expect(mockYargs.middleware).toHaveBeenCalled(); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' }); - expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' }); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'install' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'uninstall' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'list' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'update' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'disable' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'enable' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'link' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'new' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'validate' }), + ); expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); expect(mockYargs.version).toHaveBeenCalledWith(false); }); diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index b2cf160e90..ec646cfc82 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -14,8 +14,9 @@ import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { validateCommand } from './extensions/validate.js'; -import { settingsCommand } from './extensions/settings.js'; +import { configureCommand } from './extensions/configure.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -23,17 +24,20 @@ export const extensionsCommand: CommandModule = { describe: 'Manage Gemini CLI extensions.', builder: (yargs) => yargs - .middleware(() => initializeOutputListenersAndFlush()) - .command(installCommand) - .command(uninstallCommand) - .command(listCommand) - .command(updateCommand) - .command(disableCommand) - .command(enableCommand) - .command(linkCommand) - .command(newCommand) - .command(validateCommand) - .command(settingsCommand) + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(installCommand, 'extensions')) + .command(defer(uninstallCommand, 'extensions')) + .command(defer(listCommand, 'extensions')) + .command(defer(updateCommand, 'extensions')) + .command(defer(disableCommand, 'extensions')) + .command(defer(enableCommand, 'extensions')) + .command(defer(linkCommand, 'extensions')) + .command(defer(newCommand, 'extensions')) + .command(defer(validateCommand, 'extensions')) + .command(defer(configureCommand, 'extensions')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/configure.test.ts b/packages/cli/src/commands/extensions/configure.test.ts new file mode 100644 index 0000000000..fc7a3a085b --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.test.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { configureCommand } from './configure.js'; +import yargs from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + updateSetting, + promptForSetting, + getScopedEnvContents, + type ExtensionSetting, +} from '../../config/extensions/extensionSettings.js'; +import prompts from 'prompts'; +import * as fs from 'node:fs'; + +const { + mockExtensionManager, + mockGetExtensionAndManager, + mockGetExtensionManager, + mockLoadSettings, +} = vi.hoisted(() => { + const extensionManager = { + loadExtensionConfig: vi.fn(), + getExtensions: vi.fn(), + loadExtensions: vi.fn(), + getSettings: vi.fn(), + }; + return { + mockExtensionManager: extensionManager, + mockGetExtensionAndManager: vi.fn(), + mockGetExtensionManager: vi.fn(), + mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }), + }; +}); + +vi.mock('../../config/extension-manager.js', () => ({ + ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager), +})); + +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + updateSetting: vi.fn(), + promptForSetting: vi.fn(), + getScopedEnvContents: vi.fn(), + ExtensionSettingScope: { + USER: 'user', + WORKSPACE: 'workspace', + }, +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('./utils.js', () => ({ + getExtensionAndManager: mockGetExtensionAndManager, + getExtensionManager: mockGetExtensionManager, +})); + +vi.mock('prompts'); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); + +import { ExtensionManager } from '../../config/extension-manager.js'; + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, +})); + +describe('extensions configure command', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + vi.spyOn(debugLogger, 'log'); + vi.spyOn(debugLogger, 'error'); + vi.clearAllMocks(); + + tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace'); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + // Default behaviors + mockLoadSettings.mockReturnValue({ merged: {} }); + mockGetExtensionAndManager.mockResolvedValue({ + extension: null, + extensionManager: null, + }); + mockGetExtensionManager.mockResolvedValue(mockExtensionManager); + (ExtensionManager as unknown as Mock).mockImplementation( + () => mockExtensionManager, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const runCommand = async (command: string) => { + const parser = yargs().command(configureCommand).help(false).version(false); + await parser.parse(command); + }; + + const setupExtension = ( + name: string, + settings: Array> = [], + id = 'test-id', + path = '/test/path', + ) => { + const extension = { name, path, id }; + mockGetExtensionAndManager.mockImplementation(async (n) => { + if (n === name) + return { extension, extensionManager: mockExtensionManager }; + return { extension: null, extensionManager: null }; + }); + + mockExtensionManager.getExtensions.mockReturnValue([extension]); + mockExtensionManager.loadExtensionConfig.mockResolvedValue({ + name, + settings, + }); + return extension; + }; + + describe('Specific setting configuration', () => { + it('should configure a specific setting', async () => { + setupExtension('test-ext', [ + { name: 'Test Setting', envVar: 'TEST_VAR' }, + ]); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext TEST_VAR'); + + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'TEST_VAR', + promptForSetting, + 'user', + tempWorkspaceDir, + ); + }); + + it('should handle missing extension', async () => { + mockGetExtensionAndManager.mockResolvedValue({ + extension: null, + extensionManager: null, + }); + + await runCommand('config missing-ext TEST_VAR'); + + expect(updateSetting).not.toHaveBeenCalled(); + }); + + it('should reject invalid extension names', async () => { + await runCommand('config ../invalid TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + + await runCommand('config ext/with/slash TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + }); + }); + + describe('Extension configuration (all settings)', () => { + it('should configure all settings for an extension', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + 'Configuring settings for "test-ext"...', + ); + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'VAR_1', + promptForSetting, + 'user', + tempWorkspaceDir, + ); + }); + + it('should verify overwrite if setting is already set', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'user') return { VAR_1: 'existing' }; + return {}; + }, + ); + (prompts as unknown as Mock).mockResolvedValue({ overwrite: true }); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'confirm', + message: expect.stringContaining('is already set. Overwrite?'), + }), + ); + expect(updateSetting).toHaveBeenCalled(); + }); + + it('should note if setting is configured in workspace', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'workspace') return { VAR_1: 'workspace_value' }; + return {}; + }, + ); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('is already configured in the workspace scope'), + ); + }); + + it('should skip update if user denies overwrite', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' }); + (prompts as unknown as Mock).mockResolvedValue({ overwrite: false }); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalled(); + expect(updateSetting).not.toHaveBeenCalled(); + }); + }); + + describe('Configure all extensions', () => { + it('should configure settings for all installed extensions', async () => { + const ext1 = { + name: 'ext1', + path: '/p1', + id: 'id1', + settings: [{ envVar: 'V1' }], + }; + const ext2 = { + name: 'ext2', + path: '/p2', + id: 'id2', + settings: [{ envVar: 'V2' }], + }; + mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]); + + mockExtensionManager.loadExtensionConfig.mockImplementation( + async (path) => { + if (path === '/p1') + return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] }; + if (path === '/p2') + return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] }; + return null; + }, + ); + + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext1"'), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext2"'), + ); + expect(updateSetting).toHaveBeenCalledTimes(2); + }); + + it('should log if no extensions installed', async () => { + mockExtensionManager.getExtensions.mockReturnValue([]); + await runCommand('config'); + expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.'); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts new file mode 100644 index 0000000000..0ee02fe635 --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + updateSetting, + promptForSetting, + ExtensionSettingScope, + getScopedEnvContents, +} from '../../config/extensions/extensionSettings.js'; +import { getExtensionAndManager, getExtensionManager } from './utils.js'; +import { loadSettings } from '../../config/settings.js'; +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import prompts from 'prompts'; +import type { ExtensionConfig } from '../../config/extension.js'; +interface ConfigureArgs { + name?: string; + setting?: string; + scope: string; +} + +export const configureCommand: CommandModule = { + command: 'config [name] [setting]', + describe: 'Configure extension settings.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'Name of the extension to configure.', + type: 'string', + }) + .positional('setting', { + describe: 'The specific setting to configure (name or env var).', + type: 'string', + }) + .option('scope', { + describe: 'The scope to set the setting in.', + type: 'string', + choices: ['user', 'workspace'], + default: 'user', + }), + handler: async (args) => { + const { name, setting, scope } = args; + const settings = loadSettings(process.cwd()).merged; + + if (!(settings.experimental?.extensionConfig ?? true)) { + coreEvents.emitFeedback( + 'error', + 'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.', + ); + await exitCli(); + return; + } + + if (name) { + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + debugLogger.error( + 'Invalid extension name. Names cannot contain path separators or "..".', + ); + return; + } + } + + // Case 1: Configure specific setting for an extension + if (name && setting) { + await configureSpecificSetting( + name, + setting, + scope as ExtensionSettingScope, + ); + } + // Case 2: Configure all settings for an extension + else if (name) { + await configureExtension(name, scope as ExtensionSettingScope); + } + // Case 3: Configure all extensions + else { + await configureAllExtensions(scope as ExtensionSettingScope); + } + + await exitCli(); + }, +}; + +async function configureSpecificSetting( + extensionName: string, + settingKey: string, + scope: ExtensionSettingScope, +) { + const { extension, extensionManager } = + await getExtensionAndManager(extensionName); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if (!extensionConfig) { + debugLogger.error( + `Could not find configuration for extension "${extensionName}".`, + ); + return; + } + + await updateSetting( + extensionConfig, + extension.id, + settingKey, + promptForSetting, + scope, + process.cwd(), + ); +} + +async function configureExtension( + extensionName: string, + scope: ExtensionSettingScope, +) { + const { extension, extensionManager } = + await getExtensionAndManager(extensionName); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + !extensionConfig || + !extensionConfig.settings || + extensionConfig.settings.length === 0 + ) { + debugLogger.log( + `Extension "${extensionName}" has no settings to configure.`, + ); + return; + } + + debugLogger.log(`Configuring settings for "${extensionName}"...`); + await configureExtensionSettings(extensionConfig, extension.id, scope); +} + +async function configureAllExtensions(scope: ExtensionSettingScope) { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager.getExtensions(); + + if (extensions.length === 0) { + debugLogger.log('No extensions installed.'); + return; + } + + for (const extension of extensions) { + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + extensionConfig && + extensionConfig.settings && + extensionConfig.settings.length > 0 + ) { + debugLogger.log(`\nConfiguring settings for "${extension.name}"...`); + await configureExtensionSettings(extensionConfig, extension.id, scope); + } + } +} + +async function configureExtensionSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + scope: ExtensionSettingScope, +) { + const currentScopedSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + scope, + process.cwd(), + ); + + let workspaceSettings: Record = {}; + if (scope === ExtensionSettingScope.USER) { + workspaceSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.WORKSPACE, + process.cwd(), + ); + } + + if (!extensionConfig.settings) return; + + for (const setting of extensionConfig.settings) { + const currentValue = currentScopedSettings[setting.envVar]; + const workspaceValue = workspaceSettings[setting.envVar]; + + if (workspaceValue !== undefined) { + debugLogger.log( + `Note: Setting "${setting.name}" is already configured in the workspace scope.`, + ); + } + + if (currentValue !== undefined) { + const response = await prompts({ + type: 'confirm', + name: 'overwrite', + message: `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`, + initial: false, + }); + + if (!response.overwrite) { + continue; + } + } + + await updateSetting( + extensionConfig, + extensionId, + setting.envVar, + promptForSetting, + scope, + process.cwd(), + ); + } +} diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts index 4b08f85046..aafb5193e4 100644 --- a/packages/cli/src/commands/extensions/enable.test.ts +++ b/packages/cli/src/commands/extensions/enable.test.ts @@ -62,6 +62,18 @@ vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); +const mockEnablementInstance = vi.hoisted(() => ({ + getDisplayState: vi.fn(), + enable: vi.fn(), + clearSessionDisable: vi.fn(), + autoEnableServers: vi.fn(), +})); +vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({ + McpServerEnablementManager: { + getInstance: () => mockEnablementInstance, + }, +})); + describe('extensions enable command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockExtensionManager = vi.mocked(ExtensionManager); @@ -75,6 +87,12 @@ describe('extensions enable command', () => { .fn() .mockResolvedValue(undefined); mockExtensionManager.prototype.enableExtension = vi.fn(); + mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + mockEnablementInstance.getDisplayState.mockReset(); + mockEnablementInstance.enable.mockReset(); + mockEnablementInstance.clearSessionDisable.mockReset(); + mockEnablementInstance.autoEnableServers.mockReset(); + mockEnablementInstance.autoEnableServers.mockResolvedValue([]); }); afterEach(() => { @@ -134,6 +152,50 @@ describe('extensions enable command', () => { mockCwd.mockRestore(); }); + + it('should auto-enable disabled MCP servers for the extension', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockEnablementInstance.autoEnableServers.mockResolvedValue([ + 'test-server', + ]); + mockExtensionManager.prototype.getExtensions = vi + .fn() + .mockReturnValue([ + { name: 'my-extension', mcpServers: { 'test-server': {} } }, + ]); + + await handleEnable({ name: 'my-extension' }); + + expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ + 'test-server', + ]); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining("MCP server 'test-server' was disabled"), + ); + mockCwd.mockRestore(); + }); + + it('should not log when MCP servers are already enabled', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockEnablementInstance.autoEnableServers.mockResolvedValue([]); + mockExtensionManager.prototype.getExtensions = vi + .fn() + .mockReturnValue([ + { name: 'my-extension', mcpServers: { 'test-server': {} } }, + ]); + + await handleEnable({ name: 'my-extension' }); + + expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([ + 'test-server', + ]); + expect(emitConsoleLog).not.toHaveBeenCalledWith( + 'log', + expect.stringContaining("MCP server 'test-server' was disabled"), + ); + mockCwd.mockRestore(); + }); }); describe('enableCommand', () => { diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 67d7087d2d..55f3e596c4 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -15,6 +15,7 @@ import { } from '@google/gemini-cli-core'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; interface EnableArgs { name: string; @@ -37,6 +38,26 @@ export async function handleEnable(args: EnableArgs) { } else { await extensionManager.enableExtension(args.name, SettingScope.User); } + + // Auto-enable any disabled MCP servers for this extension + const extension = extensionManager + .getExtensions() + .find((e) => e.name === args.name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers ?? {}), + ); + + for (const serverName of enabledServers) { + debugLogger.log( + `MCP server '${serverName}' was disabled - now enabled.`, + ); + } + // Note: No restartServer() - CLI exits immediately, servers load on next session + } + if (args.scope) { debugLogger.log( `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, diff --git a/packages/cli/src/commands/extensions/examples/context/GEMINI.md b/packages/cli/src/commands/extensions/examples/context/GEMINI.md deleted file mode 100644 index 0e8179625e..0000000000 --- a/packages/cli/src/commands/extensions/examples/context/GEMINI.md +++ /dev/null @@ -1,14 +0,0 @@ -# Ink Library Screen Reader Guidance - -When building custom components, it's important to keep accessibility in mind. -While Ink provides the building blocks, ensuring your components are accessible -will make your CLIs usable by a wider audience. - -## General Principles - -Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to -detect if a screen reader is active. You can then render a more descriptive -output for screen reader users. Leverage ARIA props: For components that have a -specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and -aria-label props on and to provide semantic meaning to screen -readers. diff --git a/packages/cli/src/commands/extensions/examples/context/gemini-extension.json b/packages/cli/src/commands/extensions/examples/context/gemini-extension.json deleted file mode 100644 index 64f3f535ac..0000000000 --- a/packages/cli/src/commands/extensions/examples/context/gemini-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "context-example", - "version": "1.0.0" -} diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore b/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore new file mode 100644 index 0000000000..b2a70da46e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore b/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore new file mode 100644 index 0000000000..b2a70da46e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/hooks/.gitignore b/packages/cli/src/commands/extensions/examples/hooks/.gitignore new file mode 100644 index 0000000000..b2a70da46e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json b/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json new file mode 100644 index 0000000000..708e986346 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "hooks-example", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json b/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json new file mode 100644 index 0000000000..f1af86d980 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${extensionPath}/scripts/on-start.js" + } + ] + } + ] + } +} diff --git a/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js b/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js new file mode 100644 index 0000000000..1f426f9a2f --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +console.log( + 'Session Started! This is running from a script in the hooks-example extension.', +); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore b/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore new file mode 100644 index 0000000000..b2a70da46e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/README.md b/packages/cli/src/commands/extensions/examples/mcp-server/README.md new file mode 100644 index 0000000000..3ca50977ed --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/README.md @@ -0,0 +1,35 @@ +# MCP Server Example + +This is a basic example of an MCP (Model Context Protocol) server used as a +Gemini CLI extension. It demonstrates how to expose tools and prompts to the +Gemini CLI. + +## Description + +The contents of this directory are a valid MCP server implementation using the +`@modelcontextprotocol/sdk`. It exposes: + +- A tool `fetch_posts` that mock-fetches posts. +- A prompt `poem-writer`. + +## Structure + +- `example.js`: The main server entry point. +- `gemini-extension.json`: The configuration file that tells Gemini CLI how to + use this extension. +- `package.json`: Helper for dependencies. + +## How to Use + +1. Navigate to this directory: + + ```bash + cd packages/cli/src/commands/extensions/examples/mcp-server + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +This example is typically used by `gemini extensions new`. diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/example.ts b/packages/cli/src/commands/extensions/examples/mcp-server/example.js similarity index 100% rename from packages/cli/src/commands/extensions/examples/mcp-server/example.ts rename to packages/cli/src/commands/extensions/examples/mcp-server/example.js diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/example.test.ts b/packages/cli/src/commands/extensions/examples/mcp-server/example.test.ts deleted file mode 100644 index 5f5660df76..0000000000 --- a/packages/cli/src/commands/extensions/examples/mcp-server/example.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -// Mock the MCP server and transport -const mockRegisterTool = vi.fn(); -const mockRegisterPrompt = vi.fn(); -const mockConnect = vi.fn(); - -vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ - McpServer: vi.fn().mockImplementation(() => ({ - registerTool: mockRegisterTool, - registerPrompt: mockRegisterPrompt, - connect: mockConnect, - })), -})); - -vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ - StdioServerTransport: vi.fn(), -})); - -describe('MCP Server Example', () => { - beforeEach(async () => { - // Dynamically import the server setup after mocks are in place - await import('./example.js'); - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should create an McpServer with the correct name and version', () => { - expect(McpServer).toHaveBeenCalledWith({ - name: 'prompt-server', - version: '1.0.0', - }); - }); - - it('should register the "fetch_posts" tool', () => { - expect(mockRegisterTool).toHaveBeenCalledWith( - 'fetch_posts', - { - description: 'Fetches a list of posts from a public API.', - inputSchema: z.object({}).shape, - }, - expect.any(Function), - ); - }); - - it('should register the "poem-writer" prompt', () => { - expect(mockRegisterPrompt).toHaveBeenCalledWith( - 'poem-writer', - { - title: 'Poem Writer', - description: 'Write a nice haiku', - argsSchema: expect.any(Object), - }, - expect.any(Function), - ); - }); - - it('should connect the server to an StdioServerTransport', () => { - expect(StdioServerTransport).toHaveBeenCalled(); - expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport)); - }); - - describe('fetch_posts tool implementation', () => { - it('should fetch posts and return a formatted response', async () => { - const mockPosts = [ - { id: 1, title: 'Post 1' }, - { id: 2, title: 'Post 2' }, - ]; - global.fetch = vi.fn().mockResolvedValue({ - json: vi.fn().mockResolvedValue(mockPosts), - }); - - const toolFn = mockRegisterTool.mock.calls[0][2]; - const result = await toolFn(); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://jsonplaceholder.typicode.com/posts', - ); - expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify({ posts: mockPosts }), - }, - ], - }); - }); - }); - - describe('poem-writer prompt implementation', () => { - it('should generate a prompt with a title', () => { - const promptFn = mockRegisterPrompt.mock.calls[0][2]; - const result = promptFn({ title: 'My Poem' }); - expect(result).toEqual({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ', - }, - }, - ], - }); - }); - - it('should generate a prompt with a title and mood', () => { - const promptFn = mockRegisterPrompt.mock.calls[0][2]; - const result = promptFn({ title: 'My Poem', mood: 'sad' }); - expect(result).toEqual({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ', - }, - }, - ], - }); - }); - }); -}); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json index 62561dbf8d..25cea93411 100644 --- a/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json +++ b/packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json @@ -4,7 +4,7 @@ "mcpServers": { "nodeServer": { "command": "node", - "args": ["${extensionPath}${/}dist${/}example.js"], + "args": ["${extensionPath}${/}example.js"], "cwd": "${extensionPath}" } } diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/package.json b/packages/cli/src/commands/extensions/examples/mcp-server/package.json index 45aa203ef3..ddb2959c38 100644 --- a/packages/cli/src/commands/extensions/examples/mcp-server/package.json +++ b/packages/cli/src/commands/extensions/examples/mcp-server/package.json @@ -4,13 +4,6 @@ "description": "Example MCP Server for Gemini CLI Extension", "type": "module", "main": "example.js", - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "~5.4.5", - "@types/node": "^20.11.25" - }, "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", "zod": "^3.22.4" diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json b/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json deleted file mode 100644 index b94585edce..0000000000 --- a/packages/cli/src/commands/extensions/examples/mcp-server/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist" - }, - "include": ["example.ts"] -} diff --git a/packages/cli/src/commands/extensions/examples/skills/.gitignore b/packages/cli/src/commands/extensions/examples/skills/.gitignore new file mode 100644 index 0000000000..b2a70da46e --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json b/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json new file mode 100644 index 0000000000..2674ef9e0f --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "skills-example", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md b/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md new file mode 100644 index 0000000000..24da110909 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md @@ -0,0 +1,7 @@ +--- +name: greeter +description: A friendly greeter skill +--- + +You are a friendly greeter. When the user says "hello" or asks for a greeting, +you should reply with: "Greetings from the skills-example extension! ๐Ÿ‘‹" diff --git a/packages/cli/src/commands/extensions/examples/themes-example/README.md b/packages/cli/src/commands/extensions/examples/themes-example/README.md new file mode 100644 index 0000000000..b8eb87229c --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/README.md @@ -0,0 +1,28 @@ +# Themes Example + +This is an example of a Gemini CLI extension that adds a custom theme. + +## How to use + +1. Link this extension: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/themes-example + ``` + +2. Set the theme in your settings file (`~/.gemini/config.yaml`): + + ```yaml + ui: + theme: 'shades-of-green-theme (themes-example)' + ``` + + Alternatively, you can set it through the UI by running `gemini` and then + typing `/theme` and pressing Enter. + +3. **Observe the Changes:** + + After setting the theme, you should see the changes reflected in the Gemini + CLI's UI. The background will be a dark green, the primary text a lighter + green, and various other UI elements will display different shades of green, + as defined in this extension's `gemini-extension.json` file. diff --git a/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json new file mode 100644 index 0000000000..47a26c5105 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json @@ -0,0 +1,29 @@ +{ + "name": "themes-example", + "version": "1.0.0", + "themes": [ + { + "name": "shades-of-green-theme", + "type": "custom", + "background": { + "primary": "#1a362a" + }, + "text": { + "primary": "#a6e3a1", + "secondary": "#6e8e7a", + "link": "#89e689" + }, + "status": { + "success": "#76c076", + "warning": "#d9e689", + "error": "#b34e4e" + }, + "border": { + "default": "#4a6c5a" + }, + "ui": { + "comment": "#6e8e7a" + } + } + ] +} diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts index cc17e6410b..6967719be8 100644 --- a/packages/cli/src/commands/extensions/list.test.ts +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -78,6 +78,17 @@ describe('extensions list command', () => { mockCwd.mockRestore(); }); + it('should output empty JSON array if no extensions are installed and output-format is json', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + await handleList({ outputFormat: 'json' }); + + expect(emitConsoleLog).toHaveBeenCalledWith('log', '[]'); + mockCwd.mockRestore(); + }); + it('should list all installed extensions', async () => { const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); const extensions = [ @@ -99,6 +110,24 @@ describe('extensions list command', () => { mockCwd.mockRestore(); }); + it('should list all installed extensions in JSON format', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [ + { name: 'ext1', version: '1.0.0' }, + { name: 'ext2', version: '2.0.0' }, + ]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + await handleList({ outputFormat: 'json' }); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + JSON.stringify(extensions, null, 2), + ); + mockCwd.mockRestore(); + }); + it('should log an error message and exit with code 1 when listing fails', async () => { const mockProcessExit = vi .spyOn(process, 'exit') @@ -130,11 +159,35 @@ describe('extensions list command', () => { expect(command.describe).toBe('Lists installed extensions.'); }); - it('handler should call handleList', async () => { + it('builder should have output-format option', () => { + const mockYargs = { + option: vi.fn().mockReturnThis(), + }; + ( + command.builder as unknown as ( + yargs: typeof mockYargs, + ) => typeof mockYargs + )(mockYargs); + expect(mockYargs.option).toHaveBeenCalledWith('output-format', { + alias: 'o', + type: 'string', + describe: 'The format of the CLI output.', + choices: ['text', 'json'], + default: 'text', + }); + }); + + it('handler should call handleList with parsed arguments', async () => { mockExtensionManager.prototype.loadExtensions = vi .fn() .mockResolvedValue([]); - await (command.handler as () => Promise)(); + await ( + command.handler as unknown as (args: { + 'output-format': string; + }) => Promise + )({ + 'output-format': 'json', + }); expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 6faa795bd7..39a8a3f108 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -13,7 +13,7 @@ import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; -export async function handleList() { +export async function handleList(options?: { outputFormat?: 'text' | 'json' }) { try { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ @@ -24,16 +24,25 @@ export async function handleList() { }); const extensions = await extensionManager.loadExtensions(); if (extensions.length === 0) { - debugLogger.log('No extensions installed.'); + if (options?.outputFormat === 'json') { + debugLogger.log('[]'); + } else { + debugLogger.log('No extensions installed.'); + } return; } - debugLogger.log( - extensions - .map((extension, _): string => - extensionManager.toOutputString(extension), - ) - .join('\n\n'), - ); + + if (options?.outputFormat === 'json') { + debugLogger.log(JSON.stringify(extensions, null, 2)); + } else { + debugLogger.log( + extensions + .map((extension, _): string => + extensionManager.toOutputString(extension), + ) + .join('\n\n'), + ); + } } catch (error) { debugLogger.error(getErrorMessage(error)); process.exit(1); @@ -43,9 +52,18 @@ export async function handleList() { export const listCommand: CommandModule = { command: 'list', describe: 'Lists installed extensions.', - builder: (yargs) => yargs, - handler: async () => { - await handleList(); + builder: (yargs) => + yargs.option('output-format', { + alias: 'o', + type: 'string', + describe: 'The format of the CLI output.', + choices: ['text', 'json'], + default: 'text', + }), + handler: async (argv) => { + await handleList({ + outputFormat: argv['output-format'] as 'text' | 'json', + }); await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts deleted file mode 100644 index 922f5aba71..0000000000 --- a/packages/cli/src/commands/extensions/settings.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { - updateSetting, - promptForSetting, - ExtensionSettingScope, - getScopedEnvContents, -} from '../../config/extensions/extensionSettings.js'; -import { getExtensionAndManager } from './utils.js'; -import { debugLogger } from '@google/gemini-cli-core'; -import { exitCli } from '../utils.js'; - -// --- SET COMMAND --- -interface SetArgs { - name: string; - setting: string; - scope: string; -} - -const setCommand: CommandModule = { - command: 'set [--scope] ', - describe: 'Set a specific setting for an extension.', - builder: (yargs) => - yargs - .positional('name', { - describe: 'Name of the extension to configure.', - type: 'string', - demandOption: true, - }) - .positional('setting', { - describe: 'The setting to configure (name or env var).', - type: 'string', - demandOption: true, - }) - .option('scope', { - describe: 'The scope to set the setting in.', - type: 'string', - choices: ['user', 'workspace'], - default: 'user', - }), - handler: async (args) => { - const { name, setting, scope } = args; - const { extension, extensionManager } = await getExtensionAndManager(name); - if (!extension || !extensionManager) { - return; - } - const extensionConfig = await extensionManager.loadExtensionConfig( - extension.path, - ); - if (!extensionConfig) { - debugLogger.error( - `Could not find configuration for extension "${name}".`, - ); - return; - } - await updateSetting( - extensionConfig, - extension.id, - setting, - promptForSetting, - scope as ExtensionSettingScope, - ); - await exitCli(); - }, -}; - -// --- LIST COMMAND --- -interface ListArgs { - name: string; -} - -const listCommand: CommandModule = { - command: 'list ', - describe: 'List all settings for an extension.', - builder: (yargs) => - yargs.positional('name', { - describe: 'Name of the extension.', - type: 'string', - demandOption: true, - }), - handler: async (args) => { - const { name } = args; - const { extension, extensionManager } = await getExtensionAndManager(name); - if (!extension || !extensionManager) { - return; - } - const extensionConfig = await extensionManager.loadExtensionConfig( - extension.path, - ); - if ( - !extensionConfig || - !extensionConfig.settings || - extensionConfig.settings.length === 0 - ) { - debugLogger.log(`Extension "${name}" has no settings to configure.`); - return; - } - - const userSettings = await getScopedEnvContents( - extensionConfig, - extension.id, - ExtensionSettingScope.USER, - ); - const workspaceSettings = await getScopedEnvContents( - extensionConfig, - extension.id, - ExtensionSettingScope.WORKSPACE, - ); - const mergedSettings = { ...userSettings, ...workspaceSettings }; - - debugLogger.log(`Settings for "${name}":`); - for (const setting of extensionConfig.settings) { - const value = mergedSettings[setting.envVar]; - let displayValue: string; - let scopeInfo = ''; - - if (workspaceSettings[setting.envVar] !== undefined) { - scopeInfo = ' (workspace)'; - } else if (userSettings[setting.envVar] !== undefined) { - scopeInfo = ' (user)'; - } - - if (value === undefined) { - displayValue = '[not set]'; - } else if (setting.sensitive) { - displayValue = '[value stored in keychain]'; - } else { - displayValue = value; - } - debugLogger.log(` -- ${setting.name} (${setting.envVar})`); - debugLogger.log(` Description: ${setting.description}`); - debugLogger.log(` Value: ${displayValue}${scopeInfo}`); - } - await exitCli(); - }, -}; - -// --- SETTINGS COMMAND --- -export const settingsCommand: CommandModule = { - command: 'settings ', - describe: 'Manage extension settings.', - builder: (yargs) => - yargs - .command(setCommand) - .command(listCommand) - .demandCommand(1, 'You need to specify a command (set or list).') - .version(false), - handler: () => { - // This handler is not called when a subcommand is provided. - // Yargs will show the help menu. - }, -}; diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts index 50847ca9bb..886a7d2b0c 100644 --- a/packages/cli/src/commands/extensions/update.test.ts +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -24,6 +24,7 @@ import { ExtensionUpdateState } from '../../ui/state/extensions.js'; // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); +const emitFeedback = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); @@ -40,6 +41,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, coreEvents: { emitConsoleLog, + emitFeedback, }, debugLogger, }; @@ -84,6 +86,42 @@ describe('extensions update command', () => { }); describe('handleUpdate', () => { + it('should list installed extensions when requested extension is not found', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + const extensions = [ + { name: 'ext1', version: '1.0.0' }, + { name: 'ext2', version: '2.0.0' }, + ]; + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(extensions); + + await handleUpdate({ name: 'missing-extension' }); + + expect(emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extension "missing-extension" not found.\n\nInstalled extensions:\next1 (1.0.0)\next2 (2.0.0)\n\nRun "gemini extensions list" for details.', + ); + expect(mockUpdateExtension).not.toHaveBeenCalled(); + mockCwd.mockRestore(); + }); + + it('should log a helpful message when no extensions are installed and requested extension is not found', async () => { + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + mockExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue([]); + + await handleUpdate({ name: 'missing-extension' }); + + expect(emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extension "missing-extension" not found.\n\nNo extensions installed.', + ); + expect(mockUpdateExtension).not.toHaveBeenCalled(); + mockCwd.mockRestore(); + }); + it.each([ { state: ExtensionUpdateState.UPDATE_AVAILABLE, diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index ba7c865899..4798892551 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -14,7 +14,7 @@ import { import { checkForExtensionUpdate } from '../../config/extensions/github.js'; import { getErrorMessage } from '../../utils/errors.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; @@ -46,7 +46,21 @@ export async function handleUpdate(args: UpdateArgs) { (extension) => extension.name === args.name, ); if (!extension) { - debugLogger.log(`Extension "${args.name}" not found.`); + if (extensions.length === 0) { + coreEvents.emitFeedback( + 'error', + `Extension "${args.name}" not found.\n\nNo extensions installed.`, + ); + return; + } + + const installedExtensions = extensions + .map((extension) => `${extension.name} (${extension.version})`) + .join('\n'); + coreEvents.emitFeedback( + 'error', + `Extension "${args.name}" not found.\n\nInstalled extensions:\n${installedExtensions}\n\nRun "gemini extensions list" for details.`, + ); return; } if (!extension.installMetadata) { @@ -63,7 +77,6 @@ export async function handleUpdate(args: UpdateArgs) { debugLogger.log(`Extension "${args.name}" is already up to date.`); return; } - // TODO(chrstnb): we should list extensions if the requested extension is not installed. const updatedExtensionInfo = (await updateExtension( extension, extensionManager, diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 9e0ee97f40..1571c56794 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -10,7 +10,7 @@ import { loadSettings } from '../../config/settings.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { debugLogger } from '@google/gemini-cli-core'; -export async function getExtensionAndManager(name: string) { +export async function getExtensionManager() { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, @@ -19,6 +19,11 @@ export async function getExtensionAndManager(name: string) { settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); + return extensionManager; +} + +export async function getExtensionAndManager(name: string) { + const extensionManager = await getExtensionManager(); const extension = extensionManager .getExtensions() .find((ext) => ext.name === name); diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx index 4475d33ab9..fdb4594d04 100644 --- a/packages/cli/src/commands/hooks.tsx +++ b/packages/cli/src/commands/hooks.tsx @@ -14,7 +14,10 @@ export const hooksCommand: CommandModule = { describe: 'Manage Gemini CLI hooks.', builder: (yargs) => yargs - .middleware(() => initializeOutputListenersAndFlush()) + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) .command(migrateCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), diff --git a/packages/cli/src/commands/hooks/migrate.test.ts b/packages/cli/src/commands/hooks/migrate.test.ts index 29811d39b1..847d7bc467 100644 --- a/packages/cli/src/commands/hooks/migrate.test.ts +++ b/packages/cli/src/commands/hooks/migrate.test.ts @@ -511,8 +511,5 @@ describe('migrate command', () => { expect(debugLoggerLogSpy).toHaveBeenCalledWith( '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', ); - expect(debugLoggerLogSpy).toHaveBeenCalledWith( - 'Note: Set tools.enableHooks to true in your settings to enable the hook system.', - ); }); }); diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index c2fe65d574..1ced601052 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -230,8 +230,10 @@ export async function handleMigrateFromClaude() { const settings = loadSettings(workingDir); // Merge migrated hooks with existing hooks - const existingHooks = - (settings.merged.hooks as Record) || {}; + const existingHooks = (settings.merged?.hooks || {}) as Record< + string, + unknown + >; const mergedHooks = { ...existingHooks, ...migratedHooks }; // Update settings (setValue automatically saves) @@ -242,9 +244,6 @@ export async function handleMigrateFromClaude() { debugLogger.log( '\nMigration complete! Please review the migrated hooks in .gemini/settings.json', ); - debugLogger.log( - 'Note: Set tools.enableHooks to true in your settings to enable the hook system.', - ); } catch (error) { debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`); } diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 4e476ddad6..2877f84714 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -61,7 +61,7 @@ describe('mcp command', () => { (mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv); - expect(mockYargs.command).toHaveBeenCalledTimes(3); + expect(mockYargs.command).toHaveBeenCalledTimes(5); // Verify that the specific subcommands are registered const commandCalls = mockYargs.command.mock.calls; @@ -70,6 +70,8 @@ describe('mcp command', () => { expect(commandNames).toContain('add [args...]'); expect(commandNames).toContain('remove '); expect(commandNames).toContain('list'); + expect(commandNames).toContain('enable '); + expect(commandNames).toContain('disable '); expect(mockYargs.demandCommand).toHaveBeenCalledWith( 1, diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index f09680dbdd..d2b7f85f03 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -9,17 +9,24 @@ import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; +import { enableCommand, disableCommand } from './mcp/enableDisable.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; export const mcpCommand: CommandModule = { command: 'mcp', describe: 'Manage MCP servers', builder: (yargs: Argv) => yargs - .middleware(() => initializeOutputListenersAndFlush()) - .command(addCommand) - .command(removeCommand) - .command(listCommand) + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(addCommand, 'mcp')) + .command(defer(removeCommand, 'mcp')) + .command(defer(listCommand, 'mcp')) + .command(defer(enableCommand, 'mcp')) + .command(defer(disableCommand, 'mcp')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 8b8448c404..be3eb30716 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -128,6 +128,13 @@ async function addMcpServer( settings.setValue(settingsScope, 'mcpServers', mcpServers); + if (transport === 'stdio') { + debugLogger.warn( + 'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' + + 'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.', + ); + } + if (isExistingServer) { debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); } else { diff --git a/packages/cli/src/commands/mcp/enableDisable.ts b/packages/cli/src/commands/mcp/enableDisable.ts new file mode 100644 index 0000000000..b47e259eca --- /dev/null +++ b/packages/cli/src/commands/mcp/enableDisable.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import { getMcpServersFromConfig } from './list.js'; + +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const RESET = '\x1b[0m'; + +interface Args { + name: string; + session?: boolean; +} + +async function handleEnable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Check settings blocks + const settings = loadSettings(); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + debugLogger.log(`${RED}Error:${RESET} ${result.reason}`); + return; + } + + if (args.session) { + manager.clearSessionDisable(name); + debugLogger.log(`${GREEN}โœ“${RESET} Session disable cleared for '${name}'.`); + } else { + await manager.enable(name); + debugLogger.log(`${GREEN}โœ“${RESET} MCP server '${name}' enabled.`); + } + + if (result.blockType === 'admin') { + debugLogger.log( + `${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`, + ); + } +} + +async function handleDisable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + if (args.session) { + manager.disableForSession(name); + debugLogger.log( + `${GREEN}โœ“${RESET} MCP server '${name}' disabled for this session.`, + ); + } else { + await manager.disable(name); + debugLogger.log(`${GREEN}โœ“${RESET} MCP server '${name}' disabled.`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to enable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Clear session-only disable', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleEnable(argv as Args); + await exitCli(); + }, +}; + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to disable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Disable for current session only', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleDisable(argv as Args); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 7d78d48233..30d88af995 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -6,15 +6,20 @@ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { listMcpServers } from './list.js'; -import { loadSettings } from '../../config/settings.js'; +import { loadSettings, mergeSettings } from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; -vi.mock('../../config/settings.js', () => ({ - loadSettings: vi.fn(), -})); +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); vi.mock('../../config/extensions/storage.js', () => ({ ExtensionStorage: { getUserExtensionsDir: vi.fn(), @@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', }, - Storage: vi.fn().mockImplementation((_cwd: string) => ({ - getGlobalSettingsPath: () => '/tmp/gemini/settings.json', - getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', - getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', - })), + Storage: Object.assign( + vi.fn().mockImplementation((_cwd: string) => ({ + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', + getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', + })), + { + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + }, + ), GEMINI_DIR: '.gemini', getErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), @@ -96,7 +106,10 @@ describe('mcp list command', () => { }); it('should display message when no servers configured', async () => { - mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { ...defaultMergedSettings, mcpServers: {} }, + }); await listMcpServers(); @@ -104,12 +117,19 @@ describe('mcp list command', () => { }); it('should display different server types with connected status', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, - 'sse-server': { url: 'https://example.com/sse' }, + 'sse-server': { url: 'https://example.com/sse', type: 'sse' }, 'http-server': { httpUrl: 'https://example.com/http' }, + 'http-server-by-default': { url: 'https://example.com/http' }, + 'http-server-with-type': { + url: 'https://example.com/http', + type: 'http', + }, }, }, }); @@ -135,11 +155,23 @@ describe('mcp list command', () => { 'http-server: https://example.com/http (http) - Connected', ), ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server-by-default: https://example.com/http (http) - Connected', + ), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server-with-type: https://example.com/http (http) - Connected', + ), + ); }); it('should display disconnected status when connection fails', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, @@ -158,9 +190,13 @@ describe('mcp list command', () => { }); it('should merge extension servers with config servers', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { - mcpServers: { 'config-server': { command: '/config/server' } }, + ...defaultMergedSettings, + mcpServers: { + 'config-server': { command: '/config/server' }, + }, }, }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index b41baec960..50fc222f71 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m'; const COLOR_RED = '\u001b[31m'; const RESET_COLOR = '\u001b[0m'; -async function getMcpServersFromConfig(): Promise< +export async function getMcpServersFromConfig(): Promise< Record > { const settings = loadSettings(); @@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise< requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...(settings.merged.mcpServers || {}) }; + const mcpServers = { ...settings.merged.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { @@ -63,8 +63,7 @@ async function testMCPConnection( const sanitizationConfig = { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], - blockedEnvironmentVariables: - settings.merged.advanced?.excludedEnvVars || [], + blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, }; let transport; @@ -145,7 +144,8 @@ export async function listMcpServers(): Promise { if (server.httpUrl) { serverInfo += `${server.httpUrl} (http)`; } else if (server.url) { - serverInfo += `${server.url} (sse)`; + const type = server.type || 'http'; + serverInfo += `${server.url} (${type})`; } else if (server.command) { serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; } diff --git a/packages/cli/src/commands/skills.test.tsx b/packages/cli/src/commands/skills.test.tsx new file mode 100644 index 0000000000..e7b9a4eb9d --- /dev/null +++ b/packages/cli/src/commands/skills.test.tsx @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { skillsCommand } from './skills.js'; + +vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } })); +vi.mock('./skills/enable.js', () => ({ + enableCommand: { command: 'enable ' }, +})); +vi.mock('./skills/disable.js', () => ({ + disableCommand: { command: 'disable ' }, +})); + +vi.mock('../gemini.js', () => ({ + initializeOutputListenersAndFlush: vi.fn(), +})); + +describe('skillsCommand', () => { + it('should have correct command and aliases', () => { + expect(skillsCommand.command).toBe('skills '); + expect(skillsCommand.aliases).toEqual(['skill']); + expect(skillsCommand.describe).toBe('Manage agent skills.'); + }); + + it('should register all subcommands in builder', () => { + const mockYargs = { + middleware: vi.fn().mockReturnThis(), + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + // @ts-expect-error - Mocking yargs + skillsCommand.builder(mockYargs); + + expect(mockYargs.middleware).toHaveBeenCalled(); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ command: 'list' }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'enable ', + }), + ); + expect(mockYargs.command).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'disable ', + }), + ); + expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String)); + expect(mockYargs.version).toHaveBeenCalledWith(false); + }); + + it('should have a handler that does nothing', () => { + // @ts-expect-error - Handler doesn't take arguments in this case + expect(skillsCommand.handler()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/skills.tsx b/packages/cli/src/commands/skills.tsx new file mode 100644 index 0000000000..1559cf42ff --- /dev/null +++ b/packages/cli/src/commands/skills.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +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 { uninstallCommand } from './skills/uninstall.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { defer } from '../deferred.js'; + +export const skillsCommand: CommandModule = { + command: 'skills ', + aliases: ['skill'], + describe: 'Manage agent skills.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(defer(listCommand, 'skills')) + .command(defer(enableCommand, 'skills')) + .command(defer(disableCommand, 'skills')) + .command(defer(installCommand, 'skills')) + .command(defer(uninstallCommand, 'skills')) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/skills/disable.test.ts b/packages/cli/src/commands/skills/disable.test.ts new file mode 100644 index 0000000000..4a5097471b --- /dev/null +++ b/packages/cli/src/commands/skills/disable.test.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleDisable, disableCommand } from './disable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../../config/settings.js'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills disable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleDisable', () => { + it('should disable an enabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.User as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + ['skill1'], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" disabled by adding it to the disabled list in user (/user/settings.json) settings.', + ); + }); + + it('should disable an enabled skill in workspace scope', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/workspace/.gemini/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ + name: 'skill1', + scope: SettingScope.Workspace as LoadableSettingScope, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + ['skill1'], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace/.gemini/settings.json) settings.', + ); + }); + + it('should log a message if the skill is already disabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + vi.mocked(loadSettings).mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleDisable({ name: 'skill1', scope: SettingScope.User }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already disabled.', + ); + }); + }); + + describe('disableCommand', () => { + it('should have correct command and describe', () => { + expect(disableCommand.command).toBe('disable [--scope]'); + expect(disableCommand.describe).toBe('Disables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/disable.ts b/packages/cli/src/commands/skills/disable.ts new file mode 100644 index 0000000000..95fd607924 --- /dev/null +++ b/packages/cli/src/commands/skills/disable.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { disableSkill } from '../../utils/skillSettings.js'; +import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface DisableArgs { + name: string; + scope: SettingScope; +} + +export async function handleDisable(args: DisableArgs) { + const { name, scope } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const result = disableSkill(settings, name, scope); + const feedback = renderSkillActionFeedback( + result, + (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, + ); + debugLogger.log(feedback); +} + +export const disableCommand: CommandModule = { + command: 'disable [--scope]', + describe: 'Disables an agent skill.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to disable.', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'The scope to disable the skill in (user or workspace).', + type: 'string', + default: 'workspace', + choices: ['user', 'workspace'], + }), + handler: async (argv) => { + const scope = + argv['scope'] === 'workspace' + ? SettingScope.Workspace + : SettingScope.User; + await handleDisable({ + name: argv['name'] as string, + scope, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/enable.test.ts b/packages/cli/src/commands/skills/enable.test.ts new file mode 100644 index 0000000000..e204da2f66 --- /dev/null +++ b/packages/cli/src/commands/skills/enable.test.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleEnable, enableCommand } from './enable.js'; +import { + loadSettings, + SettingScope, + type LoadedSettings, +} from '../../config/settings.js'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills enable command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleEnable', () => { + it('should enable a disabled skill in user scope', async () => { + const mockSettings = { + forScope: vi.fn().mockImplementation((scope) => { + if (scope === SettingScope.User) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }; + } + return { settings: {}, path: '/workspace/settings.json' }; + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" enabled by removing it from the disabled list in user (/user/settings.json) and workspace (/workspace/settings.json) settings.', + ); + }); + + it('should enable a skill across multiple scopes', async () => { + const mockSettings = { + forScope: vi.fn().mockImplementation((scope) => { + if (scope === SettingScope.User) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/user/settings.json', + }; + } + if (scope === SettingScope.Workspace) { + return { + settings: { skills: { disabled: ['skill1'] } }, + path: '/workspace/settings.json', + }; + } + return { settings: {}, path: '' }; + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace/settings.json) and user (/user/settings.json) settings.', + ); + }); + + it('should log a message if the skill is already enabled', async () => { + const mockSettings = { + forScope: vi.fn().mockReturnValue({ + settings: { skills: { disabled: [] } }, + path: '/user/settings.json', + }), + setValue: vi.fn(), + }; + mockLoadSettings.mockReturnValue( + mockSettings as unknown as LoadedSettings, + ); + + await handleEnable({ name: 'skill1' }); + + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Skill "skill1" is already enabled.', + ); + }); + }); + + describe('enableCommand', () => { + it('should have correct command and describe', () => { + expect(enableCommand.command).toBe('enable '); + expect(enableCommand.describe).toBe('Enables an agent skill.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/enable.ts b/packages/cli/src/commands/skills/enable.ts new file mode 100644 index 0000000000..bc9d0066b1 --- /dev/null +++ b/packages/cli/src/commands/skills/enable.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import { enableSkill } from '../../utils/skillSettings.js'; +import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface EnableArgs { + name: string; +} + +export async function handleEnable(args: EnableArgs) { + const { name } = args; + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const result = enableSkill(settings, name); + const feedback = renderSkillActionFeedback( + result, + (label, path) => `${chalk.bold(label)} (${chalk.dim(path)})`, + ); + debugLogger.log(feedback); +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enables an agent skill.', + builder: (yargs) => + yargs.positional('name', { + describe: 'The name of the skill to enable.', + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await handleEnable({ + name: argv['name'] as string, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/install.test.ts b/packages/cli/src/commands/skills/install.test.ts new file mode 100644 index 0000000000..9fd05affcd --- /dev/null +++ b/packages/cli/src/commands/skills/install.test.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockInstallSkill = vi.hoisted(() => vi.fn()); +const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); +const mockSkillsConsentString = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/skillUtils.js', () => ({ + installSkill: mockInstallSkill, +})); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: mockRequestConsentNonInteractive, + skillsConsentString: mockSkillsConsentString, +})); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { log: vi.fn(), error: vi.fn() }, +})); + +import { debugLogger } from '@google/gemini-cli-core'; +import { handleInstall, installCommand } from './install.js'; + +describe('skill install command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + mockSkillsConsentString.mockResolvedValue('Mock Consent String'); + mockRequestConsentNonInteractive.mockResolvedValue(true); + }); + + describe('installCommand', () => { + it('should have correct command and describe', () => { + expect(installCommand.command).toBe( + 'install [--scope] [--path]', + ); + expect(installCommand.describe).toBe( + 'Installs an agent skill from a git repository URL or a local path.', + ); + }); + }); + + it('should call installSkill with correct arguments for user scope', async () => { + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + await rc([]); + return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + scope: 'user', + }); + + expect(mockInstallSkill).toHaveBeenCalledWith( + 'https://example.com/repo.git', + 'user', + undefined, + expect.any(Function), + expect.any(Function), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Successfully installed skill: test-skill'), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('location: /mock/user/skills/test-skill'), + ); + expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith( + 'Mock Consent String', + ); + }); + + it('should skip prompt and log consent when --consent is provided', async () => { + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + await rc([]); + return [{ name: 'test-skill', location: '/mock/user/skills/test-skill' }]; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + consent: true, + }); + + expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled(); + expect(debugLogger.log).toHaveBeenCalledWith( + 'You have consented to the following:', + ); + expect(debugLogger.log).toHaveBeenCalledWith('Mock Consent String'); + expect(mockInstallSkill).toHaveBeenCalled(); + }); + + it('should abort installation if consent is denied', async () => { + mockRequestConsentNonInteractive.mockResolvedValue(false); + mockInstallSkill.mockImplementation(async (_s, _sc, _p, _ol, rc) => { + if (!(await rc([]))) { + throw new Error('Skill installation cancelled by user.'); + } + return []; + }); + + await handleInstall({ + source: 'https://example.com/repo.git', + }); + + expect(debugLogger.error).toHaveBeenCalledWith( + 'Skill installation cancelled by user.', + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('should call installSkill with correct arguments for workspace scope and subpath', async () => { + mockInstallSkill.mockResolvedValue([ + { name: 'test-skill', location: '/mock/workspace/skills/test-skill' }, + ]); + + await handleInstall({ + source: 'https://example.com/repo.git', + scope: 'workspace', + path: 'my-skills-dir', + }); + + expect(mockInstallSkill).toHaveBeenCalledWith( + 'https://example.com/repo.git', + 'workspace', + 'my-skills-dir', + expect.any(Function), + expect.any(Function), + ); + }); + + it('should handle errors gracefully', async () => { + mockInstallSkill.mockRejectedValue(new Error('Install failed')); + + await handleInstall({ source: '/local/path' }); + + expect(debugLogger.error).toHaveBeenCalledWith('Install failed'); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts new file mode 100644 index 0000000000..f0701d39b6 --- /dev/null +++ b/packages/cli/src/commands/skills/install.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { exitCli } from '../utils.js'; +import { installSkill } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; +import { + requestConsentNonInteractive, + skillsConsentString, +} from '../../config/extensions/consent.js'; + +interface InstallArgs { + source: string; + scope?: 'user' | 'workspace'; + path?: string; + consent?: boolean; +} + +export async function handleInstall(args: InstallArgs) { + try { + const { source, consent } = args; + const scope = args.scope ?? 'user'; + const subpath = args.path; + + const requestConsent = async ( + skills: SkillDefinition[], + targetDir: string, + ) => { + if (consent) { + debugLogger.log('You have consented to the following:'); + debugLogger.log(await skillsConsentString(skills, source, targetDir)); + return true; + } + return requestConsentNonInteractive( + await skillsConsentString(skills, source, targetDir), + ); + }; + + const installedSkills = await installSkill( + source, + scope, + subpath, + (msg) => { + debugLogger.log(msg); + }, + requestConsent, + ); + + for (const skill of installedSkills) { + debugLogger.log( + chalk.green( + `Successfully installed skill: ${chalk.bold(skill.name)} (scope: ${scope}, location: ${skill.location})`, + ), + ); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + await exitCli(1); + } +} + +export const installCommand: CommandModule = { + command: 'install [--scope] [--path]', + describe: + 'Installs an agent skill from a git repository URL or a local path.', + builder: (yargs) => + yargs + .positional('source', { + describe: + 'The git repository URL or local path of the skill to install.', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: + 'The scope to install the skill into. Defaults to "user" (global).', + choices: ['user', 'workspace'], + default: 'user', + }) + .option('path', { + describe: + 'Sub-path within the repository to install from (only used for git repository sources).', + type: 'string', + }) + .option('consent', { + describe: + 'Acknowledge the security risks of installing a skill and skip the confirmation prompt.', + type: 'boolean', + default: false, + }) + .check((argv) => { + if (!argv.source) { + throw new Error('The source argument must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleInstall({ + source: argv['source'] as string, + scope: argv['scope'] as 'user' | 'workspace', + path: argv['path'] as string | undefined, + consent: argv['consent'] as boolean | undefined, + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts new file mode 100644 index 0000000000..e7e25a2736 --- /dev/null +++ b/packages/cli/src/commands/skills/list.test.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { handleList, listCommand } from './list.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import type { Config } from '@google/gemini-cli-core'; +import chalk from 'chalk'; + +const emitConsoleLog = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + log: vi.fn((message, ...args) => { + emitConsoleLog('log', format(message, ...args)); + }), + error: vi.fn((message, ...args) => { + emitConsoleLog('error', format(message, ...args)); + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + emitConsoleLog, + }, + debugLogger, + }; +}); + +vi.mock('../../config/settings.js'); +vi.mock('../../config/config.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +describe('skills list command', () => { + const mockLoadSettings = vi.mocked(loadSettings); + const mockLoadCliConfig = vi.mocked(loadCliConfig); + + beforeEach(async () => { + vi.clearAllMocks(); + mockLoadSettings.mockReturnValue({ + merged: {}, + } as unknown as LoadedSettings); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleList', () => { + it('should log a message if no skills are discovered', async () => { + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue([]), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList({}); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'No skills discovered.', + ); + }); + + it('should list all discovered skills', async () => { + const skills = [ + { + name: 'skill1', + description: 'desc1', + disabled: false, + location: '/path/to/skill1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: true, + location: '/path/to/skill2', + }, + ]; + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + await handleList({}); + + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + chalk.bold('Discovered Agent Skills:'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('skill1'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining(chalk.green('[Enabled]')), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('skill2'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining(chalk.red('[Disabled]')), + ); + }); + + it('should filter built-in skills by default and show them with { all: true }', async () => { + const skills = [ + { + name: 'regular', + description: 'desc1', + disabled: false, + location: '/loc1', + }, + { + name: 'builtin', + description: 'desc2', + disabled: false, + location: '/loc2', + isBuiltin: true, + }, + ]; + const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getAllSkills: vi.fn().mockReturnValue(skills), + }), + }; + mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config); + + // Default + await handleList({ all: false }); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('regular'), + ); + expect(emitConsoleLog).not.toHaveBeenCalledWith( + 'log', + expect.stringContaining('builtin'), + ); + + vi.clearAllMocks(); + + // With all: true + await handleList({ all: true }); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('regular'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining('builtin'), + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + expect.stringContaining(chalk.gray(' [Built-in]')), + ); + }); + + it('should throw an error when listing fails', async () => { + mockLoadCliConfig.mockRejectedValue(new Error('List failed')); + + await expect(handleList({})).rejects.toThrow('List failed'); + }); + }); + + describe('listCommand', () => { + const command = listCommand; + + it('should have correct command and describe', () => { + expect(command.command).toBe('list [--all]'); + expect(command.describe).toBe('Lists discovered agent skills.'); + }); + }); +}); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts new file mode 100644 index 0000000000..c262f39b9b --- /dev/null +++ b/packages/cli/src/commands/skills/list.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { loadSettings } from '../../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../../config/config.js'; +import { exitCli } from '../utils.js'; +import chalk from 'chalk'; + +export async function handleList(args: { all?: boolean }) { + const workspaceDir = process.cwd(); + const settings = loadSettings(workspaceDir); + + const config = await loadCliConfig( + settings.merged, + 'skills-list-session', + { + debug: false, + } as Partial as CliArgs, + { cwd: workspaceDir }, + ); + + // Initialize to trigger extension loading and skill discovery + await config.initialize(); + + const skillManager = config.getSkillManager(); + const skills = args.all + ? skillManager.getAllSkills() + : skillManager.getAllSkills().filter((s) => !s.isBuiltin); + + // Sort skills: non-built-in first, then alphabetically by name + skills.sort((a, b) => { + if (a.isBuiltin === b.isBuiltin) { + return a.name.localeCompare(b.name); + } + return a.isBuiltin ? 1 : -1; + }); + + if (skills.length === 0) { + debugLogger.log('No skills discovered.'); + return; + } + + debugLogger.log(chalk.bold('Discovered Agent Skills:')); + debugLogger.log(''); + + for (const skill of skills) { + const status = skill.disabled + ? chalk.red('[Disabled]') + : chalk.green('[Enabled]'); + + const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : ''; + + debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`); + debugLogger.log(` Description: ${skill.description}`); + debugLogger.log(` Location: ${skill.location}`); + debugLogger.log(''); + } +} + +export const listCommand: CommandModule = { + command: 'list [--all]', + describe: 'Lists discovered agent skills.', + builder: (yargs) => + yargs.option('all', { + type: 'boolean', + description: 'Show all skills, including built-in ones.', + default: false, + }), + handler: async (argv) => { + await handleList({ all: argv['all'] as boolean }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/skills/uninstall.test.ts b/packages/cli/src/commands/skills/uninstall.test.ts new file mode 100644 index 0000000000..74f1730590 --- /dev/null +++ b/packages/cli/src/commands/skills/uninstall.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockUninstallSkill = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/skillUtils.js', () => ({ + uninstallSkill: mockUninstallSkill, +})); + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { log: vi.fn(), error: vi.fn() }, +})); + +import { debugLogger } from '@google/gemini-cli-core'; +import { handleUninstall, uninstallCommand } from './uninstall.js'; + +describe('skill uninstall command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + describe('uninstallCommand', () => { + it('should have correct command and describe', () => { + expect(uninstallCommand.command).toBe('uninstall [--scope]'); + expect(uninstallCommand.describe).toBe( + 'Uninstalls an agent skill by name.', + ); + }); + }); + + it('should call uninstallSkill with correct arguments for user scope', async () => { + mockUninstallSkill.mockResolvedValue({ + location: '/mock/user/skills/test-skill', + }); + + await handleUninstall({ + name: 'test-skill', + scope: 'user', + }); + + expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user'); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Successfully uninstalled skill: test-skill'), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('location: /mock/user/skills/test-skill'), + ); + }); + + it('should call uninstallSkill with correct arguments for workspace scope', async () => { + mockUninstallSkill.mockResolvedValue({ + location: '/mock/workspace/skills/test-skill', + }); + + await handleUninstall({ + name: 'test-skill', + scope: 'workspace', + }); + + expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'workspace'); + }); + + it('should log an error if skill is not found', async () => { + mockUninstallSkill.mockResolvedValue(null); + + await handleUninstall({ name: 'test-skill' }); + + expect(debugLogger.error).toHaveBeenCalledWith( + 'Skill "test-skill" is not installed in the user scope.', + ); + }); + + it('should handle errors gracefully', async () => { + mockUninstallSkill.mockRejectedValue(new Error('Uninstall failed')); + + await handleUninstall({ name: 'test-skill' }); + + expect(debugLogger.error).toHaveBeenCalledWith('Uninstall failed'); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/skills/uninstall.ts b/packages/cli/src/commands/skills/uninstall.ts new file mode 100644 index 0000000000..1ab0c130b9 --- /dev/null +++ b/packages/cli/src/commands/skills/uninstall.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { exitCli } from '../utils.js'; +import { uninstallSkill } from '../../utils/skillUtils.js'; +import chalk from 'chalk'; + +interface UninstallArgs { + name: string; + scope?: 'user' | 'workspace'; +} + +export async function handleUninstall(args: UninstallArgs) { + try { + const { name } = args; + const scope = args.scope ?? 'user'; + + const result = await uninstallSkill(name, scope); + + if (result) { + debugLogger.log( + chalk.green( + `Successfully uninstalled skill: ${chalk.bold(name)} (scope: ${scope}, location: ${result.location})`, + ), + ); + } else { + debugLogger.error( + `Skill "${name}" is not installed in the ${scope} scope.`, + ); + } + } catch (error) { + debugLogger.error(getErrorMessage(error)); + await exitCli(1); + } +} + +export const uninstallCommand: CommandModule = { + command: 'uninstall [--scope]', + describe: 'Uninstalls an agent skill by name.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the skill to uninstall.', + type: 'string', + demandOption: true, + }) + .option('scope', { + describe: + 'The scope to uninstall the skill from. Defaults to "user" (global).', + choices: ['user', 'workspace'], + default: 'user', + }) + .check((argv) => { + if (!argv.name) { + throw new Error('The skill name must be provided.'); + } + return true; + }), + handler: async (argv) => { + await handleUninstall({ + name: argv['name'] as string, + scope: argv['scope'] as 'user' | 'workspace', + }); + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index 49afb1ae5b..6797be4447 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -22,8 +22,9 @@ import { Config, DEFAULT_FILE_FILTERING_OPTIONS, } from '@google/gemini-cli-core'; -import type { Settings } from './settingsSchema.js'; +import { createTestMergedSettings } from './settings.js'; import { http, HttpResponse } from 'msw'; + import { setupServer } from 'msw/node'; export const server = setupServer(); @@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.yolo).toBe(expected.yolo); @@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - await expect(parseArguments({} as Settings)).rejects.toThrow(); + await expect( + parseArguments(createTestMergedSettings()), + ).rejects.toThrow(); } finally { process.argv = originalArgv; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 3d5b45df80..2ca11be668 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,8 +19,9 @@ import { ApprovalMode, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import type { Settings } from './settings.js'; +import { type Settings, createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; + import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; @@ -33,6 +34,10 @@ vi.mock('./sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn(async () => undefined), })); +vi.mock('../commands/utils.js', () => ({ + exitCli: vi.fn(), +})); + vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); const pathMod = await import('node:path'); @@ -52,7 +57,9 @@ vi.mock('fs', async (importOriginal) => { return { ...actualFs, - mkdirSync: vi.fn(), + mkdirSync: vi.fn((p) => { + mockPaths.add(p.toString()); + }), writeFileSync: vi.fn(), existsSync: vi.fn((p) => mockPaths.has(p.toString())), statSync: vi.fn((p) => { @@ -119,10 +126,12 @@ vi.mock('@google/gemini-cli-core', async () => { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, DEFAULT_FILE_FILTERING_OPTIONS: { respectGitIgnore: true, respectGeminiIgnore: true, + customIgnoreFilePaths: [], }, createPolicyEngineConfig: vi.fn(async () => ({ rules: [], @@ -133,7 +142,12 @@ vi.mock('@google/gemini-cli-core', async () => { }; }); -vi.mock('./extension-manager.js'); +vi.mock('./extension-manager.js', () => { + const ExtensionManager = vi.fn(); + ExtensionManager.prototype.loadExtensions = vi.fn(); + ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + return { ExtensionManager }; +}); // Global setup to ensure clean environment for all tests in this file const originalArgv = process.argv; @@ -141,6 +155,11 @@ const originalGeminiModel = process.env['GEMINI_MODEL']; beforeEach(() => { delete process.env['GEMINI_MODEL']; + // Restore ExtensionManager mocks by re-assigning them + ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + ExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); }); afterEach(() => { @@ -189,7 +208,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -222,7 +241,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive); }); @@ -344,7 +363,7 @@ describe('parseArguments', () => { '$description', async ({ argv, expectedQuery, expectedModel, debug }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.query).toBe(expectedQuery); expect(parsedArgs.prompt).toBe(expectedQuery); expect(parsedArgs.promptInteractive).toBeUndefined(); @@ -356,6 +375,21 @@ describe('parseArguments', () => { } }, ); + + it('should include a startup message when converting positional query to interactive prompt', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', 'hello']; + + try { + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.startupMessages).toContain( + 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', + ); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); }); it.each([ @@ -380,7 +414,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -408,7 +442,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.yolo).toBe(expected.yolo); }); @@ -427,7 +461,7 @@ describe('parseArguments', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -447,7 +481,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume', 'session-id']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe('session-id'); } finally { process.stdin.isTTY = originalIsTTY; @@ -460,7 +494,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe(RESUME_LATEST); expect(argv.resume).toBe('latest'); } finally { @@ -475,7 +509,7 @@ describe('parseArguments', () => { '--allowed-tools', 'read_file,ShellTool(git status)', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']); }); @@ -486,13 +520,13 @@ describe('parseArguments', () => { '--allowed-mcp-server-names', 'server1,server2', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']); }); it('should support comma-separated values for --extensions', async () => { process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['ext1', 'ext2']); }); @@ -504,7 +538,7 @@ describe('parseArguments', () => { 'test-model-string', 'my-positional-arg', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.model).toBe('test-model-string'); expect(argv.query).toBe('my-positional-arg'); }); @@ -521,7 +555,7 @@ describe('parseArguments', () => { '--allowed-tools=ShellTool(wc)', 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['none']); expect(argv.approvalMode).toBe('auto_edit'); expect(argv.allowedTools).toEqual([ @@ -533,6 +567,42 @@ describe('parseArguments', () => { 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ); }); + + it('should set isCommand to true for mcp command', async () => { + process.argv = ['node', 'script.js', 'mcp', 'list']; + const argv = await parseArguments(createTestMergedSettings()); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for extensions command', async () => { + process.argv = ['node', 'script.js', 'extensions', 'list']; + // Extensions command uses experimental settings + const settings = createTestMergedSettings({ + experimental: { extensionManagement: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for skills command', async () => { + process.argv = ['node', 'script.js', 'skills', 'list']; + // Skills command enabled by default or via experimental + const settings = createTestMergedSettings({ + skills: { enabled: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); + + it('should set isCommand to true for hooks command', async () => { + process.argv = ['node', 'script.js', 'hooks', 'migrate']; + // Hooks command enabled via hooksConfig settings + const settings = createTestMergedSettings({ + hooksConfig: { enabled: true }, + }); + const argv = await parseArguments(settings); + expect(argv.isCommand).toBe(true); + }); }); describe('loadCliConfig', () => { @@ -576,8 +646,8 @@ describe('loadCliConfig', () => { it(`should leave proxy to empty by default`, async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); @@ -617,8 +687,8 @@ describe('loadCliConfig', () => { it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { vi.stubEnv(input.env_name, input.proxy_url); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); @@ -627,8 +697,8 @@ describe('loadCliConfig', () => { it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFileFilteringRespectGitIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, @@ -636,13 +706,36 @@ describe('loadCliConfig', () => { expect(config.getFileFilteringRespectGeminiIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore, ); + expect(config.getCustomIgnoreFilePaths()).toEqual( + DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths, + ); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); + + it('should be non-interactive when isCommand is set', async () => { + process.argv = ['node', 'script.js', 'mcp', 'list']; + const argv = await parseArguments(createTestMergedSettings()); + argv.isCommand = true; // explicitly set it as if middleware ran (it does in parseArguments but we want to be sure for this isolated test if we were mocking argv) + + // reset tty for this test + process.stdin.isTTY = true; + + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + + expect(config.isInteractive()).toBe(false); + }); }); describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { beforeEach(() => { vi.resetAllMocks(); + // Restore ExtensionManager mocks that were reset + ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); + ExtensionManager.prototype.loadExtensions = vi + .fn() + .mockResolvedValue(undefined); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); // Other common mocks would be reset here. }); @@ -653,7 +746,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -683,7 +776,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { isActive: true, }, ]); - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), @@ -693,24 +786,81 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect.any(ExtensionManager), true, 'tree', - { - respectGitIgnore: false, + expect.objectContaining({ + respectGitIgnore: true, respectGeminiIgnore: true, + }), + 200, // maxDirs + ); + }); + + it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => { + process.argv = ['node', 'script.js']; + const includeDir = path.resolve(path.sep, 'path', 'to', 'include'); + const settings = createTestMergedSettings({ + context: { + includeDirectories: [includeDir], + loadMemoryFromIncludeDirectories: true, }, - undefined, // maxDirs + }); + + const argv = await parseArguments(settings); + await loadCliConfig(settings, 'session-id', argv); + + expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect.any(String), + [includeDir], + false, + expect.any(Object), + expect.any(ExtensionManager), + true, + 'tree', + expect.objectContaining({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + 200, + ); + }); + + it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + context: { + includeDirectories: ['/path/to/include'], + loadMemoryFromIncludeDirectories: false, + }, + }); + + const argv = await parseArguments(settings); + await loadCliConfig(settings, 'session-id', argv); + + expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect.any(String), + [], + false, + expect.any(Object), + expect.any(ExtensionManager), + true, + 'tree', + expect.objectContaining({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + 200, ); }); }); describe('mergeMcpServers', () => { it('should not modify the original settings object', async () => { - const settings: Settings = { + const settings = createTestMergedSettings({ mcpServers: { 'test-server': { url: 'http://localhost:8080', }, }, - }; + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { @@ -730,7 +880,7 @@ describe('mergeMcpServers', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -755,7 +905,9 @@ describe('mergeExcludeTools', () => { }); it('should merge excludeTools from settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -777,7 +929,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( settings, @@ -791,7 +943,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -804,7 +958,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3']), @@ -813,7 +967,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -835,7 +991,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4']), @@ -845,26 +1001,28 @@ describe('mergeExcludeTools', () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => { process.stdin.isTTY = true; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set([])); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(defaultExcludes); }); it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); @@ -872,7 +1030,7 @@ describe('mergeExcludeTools', () => { }); it('should handle extensions with excludeTools but no settings', async () => { - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -885,14 +1043,16 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); expect(config.getExcludeTools()).toHaveLength(2); }); it('should not modify the original settings object', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -906,7 +1066,7 @@ describe('mergeExcludeTools', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -930,8 +1090,8 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); @@ -949,8 +1109,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -969,8 +1129,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -989,8 +1149,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1000,10 +1160,34 @@ describe('Approval mode tool exclusion logic', () => { expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); }); + it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'plan', + '-p', + 'test', + ]; + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(SHELL_TOOL_NAME); + expect(excludedTools).toContain(EDIT_TOOL_NAME); + expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + }); + it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1026,8 +1210,8 @@ describe('Approval mode tool exclusion logic', () => { for (const testCase of testCases) { process.argv = testCase.args; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1047,8 +1231,10 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['custom_tool'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['custom_tool'] }, + }); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1061,15 +1247,15 @@ describe('Approval mode tool exclusion logic', () => { it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode when it is disabled by settings', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); @@ -1082,11 +1268,11 @@ describe('Approval mode tool exclusion logic', () => { yolo: false, }; - const settings: Settings = {}; + const settings = createTestMergedSettings(); await expect( loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( - 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', + 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default', ); }); }); @@ -1104,17 +1290,17 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { vi.restoreAllMocks(); }); - const baseSettings: Settings = { + const baseSettings = createTestMergedSettings({ mcpServers: { server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }, - }; + }); it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1126,7 +1312,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1140,7 +1326,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']); }); @@ -1154,50 +1340,50 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server4', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']); }); it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['']); }); it('should read allowMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); }); it('should read excludeMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']); }); it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server1', 'server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); expect(config.getBlockedMcpServers()).toEqual(['server1']); @@ -1210,14 +1396,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1231,14 +1417,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'], // Should be ignored excluded: ['server3'], // Should be ignored }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']); expect(config.getBlockedMcpServers()).toEqual([]); @@ -1256,13 +1442,13 @@ describe('loadCliConfig model selection', () => { it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1272,11 +1458,11 @@ describe('loadCliConfig model selection', () => { it('uses the default gemini model if nothing is set', async () => { process.argv = ['node', 'script.js']; // No model set. - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model set. - }, + }), 'test-session', argv, ); @@ -1286,13 +1472,13 @@ describe('loadCliConfig model selection', () => { it('always prefers model from argv', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1302,17 +1488,31 @@ describe('loadCliConfig model selection', () => { it('selects the model from argv if provided', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model provided via settings. - }, + }), 'test-session', argv, ); expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); + + it('selects the default auto model if provided via auto alias', async () => { + process.argv = ['node', 'script.js', '--model', 'auto']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings({ + // No model provided via settings. + }), + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('auto-gemini-2.5'); + }); }); describe('loadCliConfig folderTrust', () => { @@ -1330,36 +1530,36 @@ describe('loadCliConfig folderTrust', () => { it('should be false when folderTrust is false', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = { + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: false, }, }, - }; - const argv = await parseArguments({} as Settings); + }); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); it('should be true when folderTrust is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: true, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); it('should be false by default', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1390,8 +1590,8 @@ describe('loadCliConfig with includeDirectories', () => { '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ context: { includeDirectories: [ path.resolve(path.sep, 'settings', 'path1'), @@ -1399,7 +1599,7 @@ describe('loadCliConfig with includeDirectories', () => { path.join(mockCwd, 'settings', 'path3'), ], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, @@ -1435,22 +1635,22 @@ describe('loadCliConfig compressionThreshold', () => { it('should pass settings to the core config', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ model: { compressionThreshold: 0.5, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(await config.getCompressionThreshold()).toBe(0.5); }); - it('should have undefined compressionThreshold if not in settings', async () => { + it('should have default compressionThreshold if not in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); - expect(await config.getCompressionThreshold()).toBeUndefined(); + expect(await config.getCompressionThreshold()).toBe(0.5); }); }); @@ -1469,24 +1669,24 @@ describe('loadCliConfig useRipgrep', () => { it('should be true by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); it('should be false when useRipgrep is set to false in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: false } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); it('should be true when useRipgrep is explicitly set to true in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1507,38 +1707,38 @@ describe('screenReader configuration', () => { it('should use screenReader value from settings if CLI flag is not present (settings true)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: true } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should use screenReader value from settings if CLI flag is not present (settings false)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); it('should prioritize --screen-reader CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--screen-reader']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should be false by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); @@ -1568,8 +1768,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode without YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1578,8 +1782,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode with YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1588,8 +1796,12 @@ describe('loadCliConfig tool exclusions', () => { it('should exclude interactive tools in non-interactive mode without YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); expect(config.getExcludeTools()).toContain('write_file'); @@ -1598,8 +1810,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1615,16 +1831,24 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); it('should exclude web-fetch in non-interactive mode when not allowed', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME); }); @@ -1638,8 +1862,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', WEB_FETCH_TOOL_NAME, ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); @@ -1653,8 +1881,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'run_shell_command', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1668,8 +1900,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool(wc)', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); @@ -1694,44 +1930,64 @@ describe('loadCliConfig interactive', () => { it('should be interactive if isTTY and no prompt', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should be interactive if prompt-interactive is set', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should not be interactive if not isTTY and no prompt', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if prompt is set', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); - it('should not be interactive if positional prompt words are provided with other flags', async () => { + it('should be interactive if positional prompt words are provided with other flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); }); - it('should not be interactive if positional prompt words are provided with multiple flags', async () => { + it('should be interactive if positional prompt words are provided with multiple flags', async () => { process.stdin.isTTY = true; process.argv = [ 'node', @@ -1741,32 +1997,45 @@ describe('loadCliConfig interactive', () => { '--yolo', 'Hello world', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); // Verify the question is preserved for one-shot execution - expect(argv.prompt).toBe('Hello world'); - expect(argv.promptInteractive).toBeUndefined(); + expect(argv.prompt).toBeUndefined(); + expect(argv.promptInteractive).toBe('Hello world'); }); - it('should not be interactive if positional prompt words are provided with extensions flag', async () => { + it('should be interactive if positional prompt words are provided with extensions flag', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-e', 'none', 'hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello'); + expect(argv.promptInteractive).toBe('hello'); expect(argv.extensions).toEqual(['none']); }); it('should handle multiple positional words correctly', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'hello world how are you']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello world how are you'); - expect(argv.prompt).toBe('hello world how are you'); + expect(argv.promptInteractive).toBe('hello world how are you'); }); it('should handle multiple positional words with flags', async () => { @@ -1783,18 +2052,27 @@ describe('loadCliConfig interactive', () => { 'sort', 'array', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('write a function to sort array'); + expect(argv.promptInteractive).toBe('write a function to sort array'); expect(argv.model).toBe('gemini-2.5-pro'); }); it('should handle empty positional arguments', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); @@ -1812,18 +2090,27 @@ describe('loadCliConfig interactive', () => { 'are', 'you', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); - expect(config.isInteractive()).toBe(false); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.isInteractive()).toBe(true); expect(argv.query).toBe('hello world how are you'); + expect(argv.promptInteractive).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); }); it('should be interactive if no positional prompt words are provided with flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); }); @@ -1851,43 +2138,67 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1895,20 +2206,77 @@ describe('loadCliConfig approval mode', () => { // Note: This test documents the intended behavior, but in practice the validation // prevents both flags from being used together process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; - const config = await loadCliConfig({}, 'test-session', argv); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); + it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + tools: { + // @ts-expect-error: testing invalid value + approvalMode: 'yolo', + }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: false, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({}); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { @@ -1920,32 +2288,109 @@ describe('loadCliConfig approval mode', () => { it('should override --approval-mode=yolo to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); + + describe('Persistent approvalMode setting', () => { + it('should use approvalMode from settings when no CLI flags are set', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + tools: { approvalMode: 'auto_edit' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe( + ServerConfig.ApprovalMode.AUTO_EDIT, + ); + }); + + it('should prioritize --approval-mode flag over settings', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const settings = createTestMergedSettings({ + tools: { approvalMode: 'default' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe( + ServerConfig.ApprovalMode.AUTO_EDIT, + ); + }); + + it('should prioritize --yolo flag over settings', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const settings = createTestMergedSettings({ + tools: { approvalMode: 'auto_edit' }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should respect plan mode from settings when experimental.plan is enabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + tools: { approvalMode: 'plan' }, + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + tools: { approvalMode: 'plan' }, + experimental: { plan: false }, + }); + const argv = await parseArguments(settings); + await expect( + loadCliConfig(settings, 'test-session', argv), + ).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + }); }); describe('loadCliConfig fileFiltering', () => { @@ -1974,13 +2419,13 @@ describe('loadCliConfig fileFiltering', () => { value: boolean; }> = [ { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: true, }, { - property: 'disableFuzzySearch', - getter: (c) => c.getFileFilteringDisableFuzzySearch(), + property: 'enableFuzzySearch', + getter: (c) => c.getFileFilteringEnableFuzzySearch(), value: false, }, { @@ -2018,11 +2463,11 @@ describe('loadCliConfig fileFiltering', () => { it.each(testCases)( 'should pass $property from settings to config when $value', async ({ property, getter, value }) => { - const settings: Settings = { + const settings = createTestMergedSettings({ context: { fileFiltering: { [property]: value }, }, - }; + }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); @@ -2041,16 +2486,20 @@ describe('Output format', () => { it('should default to TEXT', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); it('should use the format from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2059,9 +2508,9 @@ describe('Output format', () => { it('should prioritize the format from argv', async () => { process.argv = ['node', 'script.js', '--output-format', 'json']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2070,8 +2519,12 @@ describe('Output format', () => { it('should accept stream-json as a valid output format', async () => { process.argv = ['node', 'script.js', '--output-format', 'stream-json']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); @@ -2089,7 +2542,7 @@ describe('Output format', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( @@ -2131,7 +2584,7 @@ describe('parseArguments with positional prompt', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -2148,7 +2601,7 @@ describe('parseArguments with positional prompt', () => { it('should correctly parse a positional prompt to query field', async () => { process.argv = ['node', 'script.js', 'positional', 'prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.query).toBe('positional prompt'); // Since no explicit prompt flags are set and query doesn't start with @, should map to prompt (one-shot) expect(argv.prompt).toBe('positional prompt'); @@ -2161,13 +2614,13 @@ describe('parseArguments with positional prompt', () => { // This test verifies that the positional 'query' argument is properly configured // with the description: "Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive." process.argv = ['node', 'script.js', 'test', 'query']; - const argv = await yargsInstance.parseArguments({} as Settings); + const argv = await yargsInstance.parseArguments(createTestMergedSettings()); expect(argv.query).toBe('test query'); }); it('should correctly parse a prompt from the --prompt flag', async () => { process.argv = ['node', 'script.js', '--prompt', 'test prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.prompt).toBe('test prompt'); }); }); @@ -2183,8 +2636,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { enabled: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2192,10 +2647,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -2203,10 +2658,10 @@ describe('Telemetry configuration via environment variables', () => { it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.GCP }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); @@ -2217,10 +2672,10 @@ describe('Telemetry configuration via environment variables', () => { vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { otlpEndpoint: 'http://settings.com' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); @@ -2228,8 +2683,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { otlpProtocol: 'grpc' }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -2237,8 +2694,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { logPrompts: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { logPrompts: true }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -2246,10 +2705,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { outfile: '/settings/telemetry.log' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); @@ -2257,8 +2716,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { useCollector: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { useCollector: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); @@ -2266,8 +2727,8 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { enabled: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2275,10 +2736,10 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); @@ -2286,17 +2747,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { enabled: true } }, + createTestMergedSettings({ telemetry: { enabled: true } }), 'test-session', argv, ); @@ -2306,17 +2771,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { logPrompts: true } }, + createTestMergedSettings({ telemetry: { logPrompts: true } }), 'test-session', argv, ); @@ -2338,11 +2807,15 @@ describe('PolicyEngine nonInteractive wiring', () => { vi.restoreAllMocks(); }); - it('should set nonInteractive to true in one-shot mode', async () => { + it('should set nonInteractive to true when -p flag is used', async () => { process.stdin.isTTY = true; - process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + process.argv = ['node', 'script.js', '-p', 'echo hello']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2353,8 +2826,12 @@ describe('PolicyEngine nonInteractive wiring', () => { it('should set nonInteractive to false in interactive mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2378,8 +2855,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; - const settings: Settings = { tools: { allowed: ['settings-tool'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { allowed: ['settings-tool'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2396,8 +2875,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { process.stdin.isTTY = false; // Non-interactive to trigger default excludes process.argv = ['node', 'script.js', '-p', 'test']; - const settings: Settings = { tools: { exclude: ['settings-exclude'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { exclude: ['settings-exclude'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2412,3 +2893,173 @@ describe('Policy Engine Integration in loadCliConfig', () => { ); }); }); + +describe('loadCliConfig disableYoloMode', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should allow auto_edit mode even if yolo mode is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode=auto_edit']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { disableYoloMode: true }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); + }); + + it('should throw if YOLO mode is attempted when disableYoloMode is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + security: { disableYoloMode: true }, + }); + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); +}); + +describe('loadCliConfig secureModeEnabled', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: undefined, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js', '--approval-mode=yolo']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should set disableYoloMode to true when secureModeEnabled is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + admin: { + secureModeEnabled: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.isYoloModeDisabled()).toBe(true); + }); +}); + +describe('loadCliConfig mcpEnabled', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const mcpSettings = { + mcp: { + serverCommand: 'mcp-server', + allowed: ['serverA'], + excluded: ['serverB'], + }, + mcpServers: { serverA: { url: 'http://a' } }, + }; + + it('should enable MCP by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(true); + expect(config.getMcpServerCommand()).toBe('mcp-server'); + expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); + expect(config.getAllowedMcpServers()).toEqual(['serverA']); + expect(config.getBlockedMcpServers()).toEqual(['serverB']); + }); + + it('should disable MCP when mcpEnabled is false', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...mcpSettings, + admin: { + mcp: { + enabled: false, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(false); + expect(config.getMcpServerCommand()).toBeUndefined(); + expect(config.getMcpServers()).toEqual({}); + expect(config.getAllowedMcpServers()).toEqual([]); + expect(config.getBlockedMcpServers()).toEqual([]); + }); + + it('should enable MCP when mcpEnabled is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + ...mcpSettings, + admin: { + mcp: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getMcpEnabled()).toBe(true); + expect(config.getMcpServerCommand()).toBe('mcp-server'); + expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } }); + expect(config.getAllowedMcpServers()).toEqual(['serverA']); + expect(config.getBlockedMcpServers()).toEqual(['serverB']); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8763f95dbf..0c5063faee 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; +import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; import { Config, @@ -35,18 +36,25 @@ import { type HookDefinition, type HookEventName, type OutputFormat, + coreEvents, + GEMINI_MODEL_ALIAS_AUTO, + getAdminErrorMessage, } from '@google/gemini-cli-core'; -import type { Settings } from './settings.js'; -import { saveModelChange, loadSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + saveModelChange, + loadSettings, +} from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; -import { appEvents } from '../utils/events.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig } from './policy.js'; import { ExtensionManager } from './extension-manager.js'; +import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; @@ -77,28 +85,34 @@ export interface CliArgs { outputFormat: string | undefined; fakeResponses: string | undefined; recordResponses: string | undefined; + startupMessages?: string[]; + rawOutput: boolean | undefined; + acceptRawOutputRisk: boolean | undefined; + isCommand: boolean | undefined; } -export async function parseArguments(settings: Settings): Promise { +export async function parseArguments( + settings: MergedSettings, +): Promise { const rawArgv = hideBin(process.argv); + const startupMessages: string[] = []; const yargsInstance = yargs(rawArgv) .locale('en') .scriptName('gemini') .usage( - 'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + 'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.', ) - .option('debug', { alias: 'd', type: 'boolean', - description: 'Run in debug mode?', + description: 'Run in debug mode (open debug console with F12)', default: false, }) .command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) => yargsInstance .positional('query', { description: - 'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.', + 'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.', }) .option('model', { alias: 'm', @@ -110,7 +124,8 @@ export async function parseArguments(settings: Settings): Promise { alias: 'p', type: 'string', nargs: 1, - description: 'Prompt. Appended to input on stdin (if any).', + description: + 'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).', }) .option('prompt-interactive', { alias: 'i', @@ -135,9 +150,9 @@ export async function parseArguments(settings: Settings): Promise { .option('approval-mode', { type: 'string', nargs: 1, - choices: ['default', 'auto_edit', 'yolo'], + choices: ['default', 'auto_edit', 'yolo', 'plan'], description: - 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)', + 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)', }) .option('experimental-acp', { type: 'boolean', @@ -241,10 +256,15 @@ export async function parseArguments(settings: Settings): Promise { description: 'Path to a file to record model responses for testing.', hidden: true, }) - .deprecateOption( - 'prompt', - 'Use the positional prompt instead. This flag will be removed in a future version.', - ), + .option('raw-output', { + type: 'boolean', + description: + 'Disable sanitization of model output (e.g. allow ANSI escape sequences). WARNING: This can be a security risk if the model output is untrusted.', + }) + .option('accept-raw-output-risk', { + type: 'boolean', + description: 'Suppress the security warning when using --raw-output.', + }), ) // Register MCP subcommands .command(mcpCommand) @@ -281,12 +301,15 @@ export async function parseArguments(settings: Settings): Promise { return true; }); - if (settings?.experimental?.extensionManagement ?? true) { + if (settings.experimental?.extensionManagement) { yargsInstance.command(extensionsCommand); } + if (settings.skills?.enabled ?? true) { + yargsInstance.command(skillsCommand); + } // Register hooks command if hooks are enabled - if (settings?.tools?.enableHooks) { + if (settings.hooksConfig.enabled) { yargsInstance.command(hooksCommand); } @@ -323,11 +346,12 @@ export async function parseArguments(settings: Settings): Promise { ? queryArg.join(' ') : queryArg; - // Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands) + // -p/--prompt forces non-interactive mode; positional args default to interactive in TTY if (q && !result['prompt']) { - const hasExplicitInteractive = - result['promptInteractive'] === '' || !!result['promptInteractive']; - if (hasExplicitInteractive) { + if (process.stdin.isTTY) { + startupMessages.push( + 'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.', + ); result['promptInteractive'] = q; } else { result['prompt'] = q; @@ -336,6 +360,7 @@ export async function parseArguments(settings: Settings): Promise { // Keep CliArgs.query as a string for downstream typing (result as Record)['query'] = q || undefined; + (result as Record)['startupMessages'] = startupMessages; // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument @@ -389,7 +414,7 @@ export interface LoadCliConfigOptions { } export async function loadCliConfig( - settings: Settings, + settings: MergedSettings, sessionId: string, argv: CliArgs, options: LoadCliConfigOptions = {}, @@ -408,7 +433,7 @@ export async function loadCliConfig( const ideMode = settings.ide?.enabled ?? false; const folderTrust = settings.security?.folderTrust?.enabled ?? false; - const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true; + const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false; // Set the context filename in the server's memoryTool module BEFORE loading memory // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed @@ -443,7 +468,8 @@ export async function loadCliConfig( requestSetting: promptForSetting, workspaceDir: cwd, enabledExtensionOverrides: argv.extensions, - eventEmitter: appEvents as EventEmitter, + eventEmitter: coreEvents as EventEmitter, + clientVersion: await getVersion(), }); await extensionManager.loadExtensions(); @@ -457,7 +483,9 @@ export async function loadCliConfig( // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const result = await loadServerHierarchicalMemory( cwd, - [], + settings.context?.loadMemoryFromIncludeDirectories || false + ? includeDirectories + : [], debugMode, fileService, extensionManager, @@ -475,38 +503,57 @@ export async function loadCliConfig( // Determine approval mode with backward compatibility let approvalMode: ApprovalMode; - if (argv.approvalMode) { - // New --approval-mode flag takes precedence - switch (argv.approvalMode) { + const rawApprovalMode = + argv.approvalMode || + (argv.yolo ? 'yolo' : undefined) || + ((settings.tools?.approvalMode as string) !== 'yolo' + ? settings.tools.approvalMode + : undefined); + + if (rawApprovalMode) { + switch (rawApprovalMode) { case 'yolo': approvalMode = ApprovalMode.YOLO; break; case 'auto_edit': approvalMode = ApprovalMode.AUTO_EDIT; break; + case 'plan': + if (!(settings.experimental?.plan ?? false)) { + throw new Error( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + } + approvalMode = ApprovalMode.PLAN; + break; case 'default': approvalMode = ApprovalMode.DEFAULT; break; default: throw new Error( - `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`, + `Invalid approval mode: ${rawApprovalMode}. Valid values are: yolo, auto_edit, plan, default`, ); } } else { - // Fallback to legacy --yolo flag behavior - approvalMode = - argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; + approvalMode = ApprovalMode.DEFAULT; } // Override approval mode if disableYoloMode is set. - if (settings.security?.disableYoloMode) { + if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) { if (approvalMode === ApprovalMode.YOLO) { - debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.'); + if (settings.admin?.secureModeEnabled) { + debugLogger.error( + 'YOLO mode is disabled by "secureModeEnabled" setting.', + ); + } else { + debugLogger.error( + 'YOLO mode is disabled by the "disableYolo" setting.', + ); + } throw new FatalConfigError( - 'Cannot start in YOLO mode when it is disabled by settings', + getAdminErrorMessage('YOLO mode', undefined /* config */), ); } - approvalMode = ApprovalMode.DEFAULT; } else if (approvalMode === ApprovalMode.YOLO) { debugLogger.warn( 'YOLO mode is enabled. All tool calls will be automatically approved.', @@ -536,12 +583,12 @@ export async function loadCliConfig( throw err; } - // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) - const hasQuery = !!argv.query; + // -p/--prompt forces non-interactive (headless) mode + // -i/--prompt-interactive forces interactive mode with an initial prompt const interactive = !!argv.promptInteractive || !!argv.experimentalAcp || - (process.stdin.isTTY && !hasQuery && !argv.prompt); + (process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedToolsSet = new Set(allowedTools); @@ -563,6 +610,11 @@ export async function loadCliConfig( ); switch (approvalMode) { + case ApprovalMode.PLAN: + // In plan non-interactive mode, all tools that require approval are excluded. + // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode. + extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); + break; case ApprovalMode.DEFAULT: // In default non-interactive mode, all tools that require approval are excluded. extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); @@ -580,10 +632,7 @@ export async function loadCliConfig( } } - const excludeTools = mergeExcludeTools( - settings, - extraExcludes.length > 0 ? extraExcludes : undefined, - ); + const excludeTools = mergeExcludeTools(settings, extraExcludes); // Create a settings object that includes CLI overrides for policy generation const effectiveSettings: Settings = { @@ -608,12 +657,13 @@ export async function loadCliConfig( const defaultModel = settings.general?.previewFeatures ? PREVIEW_GEMINI_MODEL_AUTO : DEFAULT_GEMINI_MODEL_AUTO; - const resolvedModel: string = - argv.model || - process.env['GEMINI_MODEL'] || - settings.model?.name || - defaultModel; + const specifiedModel = + argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; + const resolvedModel = + specifiedModel === GEMINI_MODEL_ALIAS_AUTO + ? defaultModel + : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = argv.screenReader !== undefined @@ -622,8 +672,19 @@ export async function loadCliConfig( const ptyInfo = await getPty(); + const mcpEnabled = settings.admin?.mcp?.enabled ?? true; + const extensionsEnabled = settings.admin?.extensions?.enabled ?? true; + const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true; + + // Create MCP enablement manager and callbacks + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpEnablementCallbacks = mcpEnabled + ? mcpEnablementManager.getEnablementCallbacks() + : undefined; + return new Config({ sessionId, + clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: cwd, @@ -640,12 +701,21 @@ export async function loadCliConfig( excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, - mcpServerCommand: settings.mcp?.serverCommand, - mcpServers: settings.mcpServers, - allowedMcpServers: argv.allowedMcpServerNames ?? settings.mcp?.allowed, - blockedMcpServers: argv.allowedMcpServerNames - ? undefined - : settings.mcp?.excluded, + mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined, + mcpServers: mcpEnabled ? settings.mcpServers : {}, + mcpEnablementCallbacks, + mcpEnabled, + extensionsEnabled, + agents: settings.agents, + adminSkillsEnabled, + allowedMcpServers: mcpEnabled + ? (argv.allowedMcpServerNames ?? settings.mcp?.allowed) + : undefined, + blockedMcpServers: mcpEnabled + ? argv.allowedMcpServerNames + ? undefined + : settings.mcp?.excluded + : undefined, blockedEnvironmentVariables: settings.security?.environmentVariableRedaction?.blocked, enableEnvironmentVariableRedaction: @@ -654,14 +724,15 @@ export async function loadCliConfig( geminiMdFileCount: fileCount, geminiMdFilePaths: filePaths, approvalMode, - disableYoloMode: settings.security?.disableYoloMode, + disableYoloMode: + settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, showMemoryUsage: settings.ui?.showMemoryUsage || false, accessibility: { ...settings.ui?.accessibility, screenReader, }, telemetry: telemetrySettings, - usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, + usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled, fileFiltering, checkpointing: settings.general?.checkpointing?.enabled, proxy: @@ -673,7 +744,7 @@ export async function loadCliConfig( fileDiscoveryService: fileService, bugCommand: settings.advanced?.bugCommand, model: resolvedModel, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, + maxSessionTurns: settings.model?.maxSessionTurns, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, listSessions: argv.listSessions || false, @@ -682,7 +753,10 @@ export async function loadCliConfig( extensionLoader: extensionManager, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, - skillsSupport: settings.experimental?.skills, + plan: settings.experimental?.plan, + enableEventDrivenScheduler: + settings.experimental?.enableEventDrivenScheduler, + skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, noBrowser: !!process.env['NO_BROWSER'], @@ -692,46 +766,54 @@ export async function loadCliConfig( folderTrust, interactive, trustedFolder, + useBackgroundColor: settings.ui?.useBackgroundColor, useRipgrep: settings.tools?.useRipgrep, - enableInteractiveShell: - settings.tools?.shell?.enableInteractiveShell ?? true, + enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, enableShellOutputEfficiency: settings.tools?.shell?.enableShellOutputEfficiency ?? true, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, - enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, + enablePromptCompletion: settings.general?.enablePromptCompletion, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, - eventEmitter: appEvents, + eventEmitter: coreEvents, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, - codebaseInvestigatorSettings: - settings.experimental?.codebaseInvestigatorSettings, - introspectionAgentSettings: - settings.experimental?.introspectionAgentSettings, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, - retryFetchErrors: settings.general?.retryFetchErrors ?? false, + retryFetchErrors: settings.general?.retryFetchErrors, ptyInfo: ptyInfo?.name, + disableLLMCorrection: settings.tools?.disableLLMCorrection, + rawOutput: argv.rawOutput, + acceptRawOutputRisk: argv.acceptRawOutputRisk, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust - enableHooks: settings.tools?.enableHooks ?? false, + enableHooks: settings.hooksConfig.enabled, + enableHooksUI: settings.hooksConfig.enabled, hooks: settings.hooks || {}, + disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onReload: async () => { + const refreshedSettings = loadSettings(cwd); + return { + disabledSkills: refreshedSettings.merged.skills.disabled, + agents: refreshedSettings.merged.agents, + }; + }, }); } function mergeExcludeTools( - settings: Settings, - extraExcludes?: string[] | undefined, + settings: MergedSettings, + extraExcludes: string[] = [], ): string[] { const allExcludeTools = new Set([ - ...(settings.tools?.exclude || []), - ...(extraExcludes || []), + ...(settings.tools.exclude || []), + ...extraExcludes, ]); - return [...allExcludeTools]; + return Array.from(allExcludeTools); } diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts new file mode 100644 index 0000000000..19ef150d22 --- /dev/null +++ b/packages/cli/src/config/extension-manager-agents.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const core = await importOriginal(); + return { + ...core, + homedir: mockHomedir, + loadAgentsFromDirectory: core.loadAgentsFromDirectory, + loadSkillsFromDir: core.loadSkillsFromDir, + }; +}); + +describe('ExtensionManager agents loading', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should load agents from an extension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-good'); + createExtension({ + extensionsDir: sourceDir, + name: 'good-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'good-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'good-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'test-agent.md'), + '---\nname: test-agent\nkind: local\ndescription: test desc\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('good-agents-ext'); + expect(extension.agents).toBeDefined(); + expect(extension.agents).toHaveLength(1); + expect(extension.agents![0].name).toBe('test-agent'); + expect(debugLogger.warn).not.toHaveBeenCalled(); + }); + + it('should log errors but continue if an agent fails to load', async () => { + const sourceDir = path.join(tempDir, 'source-ext-bad'); + createExtension({ + extensionsDir: sourceDir, + name: 'bad-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'bad-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'bad-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Invalid agent (missing description) + fs.writeFileSync( + path.join(agentsDir, 'bad-agent.md'), + '---\nname: bad-agent\nkind: local\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('bad-agents-ext'); + expect(extension.agents).toEqual([]); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Error loading agent from bad-agents-ext'), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager-hydration.test.ts b/packages/cli/src/config/extension-manager-hydration.test.ts new file mode 100644 index 0000000000..def5fed42c --- /dev/null +++ b/packages/cli/src/config/extension-manager-hydration.test.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + // Use actual implementations for loading skills and agents to test hydration + loadAgentsFromDirectory: actual.loadAgentsFromDirectory, + loadSkillsFromDir: actual.loadSkillsFromDir, + }; +}); + +describe('ExtensionManager hydration', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should hydrate skill body with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-skill'); + const extensionName = 'skill-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'API Key', + envVar: 'MY_API_KEY', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create skill with variable + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + `--- +name: my-skill +description: test +--- +Use key: \${MY_API_KEY} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MY_API_KEY') return 'secret-123'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.skills).toHaveLength(1); + expect(extension.skills![0].body).toContain('Use key: secret-123'); + }); + + it('should hydrate agent system prompt with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-agent'); + const extensionName = 'agent-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Model Name', + description: 'Model', + envVar: 'MODEL_NAME', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create agent with variable + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'my-agent.md'), + `--- +name: my-agent +description: test +--- +System using model: \${MODEL_NAME} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MODEL_NAME') return 'gemini-pro'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.agents).toHaveLength(1); + const agent = extension.agents![0]; + if (agent.kind === 'local') { + expect(agent.promptConfig.systemPrompt).toContain( + 'System using model: gemini-pro', + ); + } else { + throw new Error('Expected local agent'); + } + }); + + it('should hydrate hooks with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-hooks'); + const extensionName = 'hooks-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Hook Command', + description: 'Cmd', + envVar: 'HOOK_CMD', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const hooksDir = path.join(extensionPath, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: 'echo $HOOK_CMD', + }, + ], + }, + ], + }, + }), + ); + + // Enable hooks in settings + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + hooksConfig: { enabled: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'HOOK_CMD') return 'hello-world'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.hooks).toBeDefined(); + expect(extension.hooks?.BeforeTool).toHaveLength(1); + expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe( + 'hello-world', + ); + }); + + it('should pick up new settings after restartExtension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-restart'); + const extensionName = 'restart-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Value', + description: 'Val', + envVar: 'MY_VALUE', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + '---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}', + ); + + await extensionManager.loadExtensions(); + + // Initial setting + extensionManager.setRequestSetting(async () => 'first'); + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + expect(extension.skills![0].body).toContain('Value is: first'); + + const { updateSetting, ExtensionSettingScope } = await import( + './extensions/extensionSettings.js' + ); + const extensionConfig = + await extensionManager.loadExtensionConfig(extensionPath); + + const mockRequestSetting = vi.fn().mockResolvedValue('second'); + await updateSetting( + extensionConfig, + extension.id, + 'MY_VALUE', + mockRequestSetting, + ExtensionSettingScope.USER, + process.cwd(), + ); + + await extensionManager.restartExtension(extension); + + const reloadedExtension = extensionManager + .getExtensions() + .find((e) => e.name === extensionName)!; + expect(reloadedExtension.skills![0].body).toContain('Value is: second'); + }); +}); diff --git a/packages/cli/src/config/extension-manager-scope.test.ts b/packages/cli/src/config/extension-manager-scope.test.ts new file mode 100644 index 0000000000..5079075366 --- /dev/null +++ b/packages/cli/src/config/extension-manager-scope.test.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { createTestMergedSettings } from './settings.js'; +import { + loadAgentsFromDirectory, + loadSkillsFromDir, +} from '@google/gemini-cli-core'; + +let currentTempHome = ''; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => currentTempHome, + debugLogger: { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + loadAgentsFromDirectory: vi.fn().mockImplementation(async () => ({ + agents: [], + errors: [], + })), + loadSkillsFromDir: vi.fn().mockImplementation(async () => []), + }; +}); + +describe('ExtensionManager Settings Scope', () => { + const extensionName = 'test-extension'; + let tempWorkspace: string; + let extensionsDir: string; + let extensionDir: string; + + beforeEach(async () => { + vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ + agents: [], + errors: [], + }); + vi.mocked(loadSkillsFromDir).mockResolvedValue([]); + currentTempHome = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + tempWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + extensionsDir = path.join(currentTempHome, '.gemini', 'extensions'); + extensionDir = path.join(extensionsDir, extensionName); + + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create gemini-extension.json + const extensionConfig = { + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Test Setting', + envVar: 'TEST_SETTING', + description: 'A test setting', + }, + ], + }; + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify(extensionConfig), + ); + + // Create install metadata + const installMetadata = { + source: extensionDir, + type: 'local', + }; + fs.writeFileSync( + path.join(extensionDir, 'install-metadata.json'), + JSON.stringify(installMetadata), + ); + }); + + afterEach(() => { + // Clean up files if needed, or rely on temp dir cleanup + vi.clearAllMocks(); + }); + + it('should prioritize workspace settings over user settings and report correct scope', async () => { + // 1. Set User Setting + const userSettingsPath = path.join(extensionDir, '.env'); + fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); + + // 2. Set Workspace Setting + const workspaceSettingsPath = path.join(tempWorkspace, '.env'); + fs.writeFileSync(workspaceSettingsPath, 'TEST_SETTING=workspace-value'); + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBe('workspace-value'); + expect(setting?.scope).toBe('workspace'); + expect(setting?.source).toBe(workspaceSettingsPath); + + // Verify output string contains (Workspace - ) + const output = extensionManager.toOutputString(extension!); + expect(output).toContain( + `Test Setting: workspace-value (Workspace - ${workspaceSettingsPath})`, + ); + }); + + it('should fallback to user settings if workspace setting is missing', async () => { + // 1. Set User Setting + const userSettingsPath = path.join(extensionDir, '.env'); + fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value'); + + // 2. No Workspace Setting + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBe('user-value'); + expect(setting?.scope).toBe('user'); + expect(setting?.source?.endsWith(path.join(extensionName, '.env'))).toBe( + true, + ); + + // Verify output string contains (User - ) + const output = extensionManager.toOutputString(extension!); + expect(output).toContain( + `Test Setting: user-value (User - ${userSettingsPath})`, + ); + }); + + it('should report unset if neither is present', async () => { + // No settings files + + const extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspace, + requestConsent: async () => true, + requestSetting: async () => '', + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + }); + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName); + + expect(extension).toBeDefined(); + + // Verify resolved settings + const setting = extension?.resolvedSettings?.find( + (s) => s.envVar === 'TEST_SETTING', + ); + expect(setting).toBeDefined(); + expect(setting?.value).toBe('[not set]'); + expect(setting?.scope).toBeUndefined(); + + // Verify output string does not contain scope + const output = extensionManager.toOutputString(extension!); + expect(output).toContain('Test Setting: [not set]'); + expect(output).not.toContain('Test Setting: [not set] (User)'); + expect(output).not.toContain('Test Setting: [not set] (Workspace)'); + }); +}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index 495336e4a8..a76d88482d 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -4,115 +4,165 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; -import { loadSettings } from './settings.js'; +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; -import { coreEvents } from '@google/gemini-cli-core'; -const mockHomedir = vi.hoisted(() => vi.fn()); +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); -vi.mock('os', async (importOriginal) => { - const mockedOs = await importOriginal(); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); return { - ...mockedOs, + ...actual, homedir: mockHomedir, }; }); +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + loadAgentsFromDirectory: vi + .fn() + .mockImplementation(async () => ({ agents: [], errors: [] })), + loadSkillsFromDir: ( + await importOriginal() + ).loadSkillsFromDir, + }; +}); + describe('ExtensionManager skills validation', () => { - let tempHomeDir: string; - let tempWorkspaceDir: string; - let userExtensionsDir: string; let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'), - ); - tempWorkspaceDir = fs.mkdtempSync( - path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'), - ); - userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); - fs.mkdirSync(userExtensionsDir, { recursive: true }); + vi.clearAllMocks(); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); - mockHomedir.mockReturnValue(tempHomeDir); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ - workspaceDir: tempWorkspaceDir, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), requestConsent: vi.fn().mockResolvedValue(true), - requestSetting: vi.fn().mockResolvedValue(''), - settings: loadSettings(tempWorkspaceDir).merged, + requestSetting: vi.fn(), + workspaceDir: tempDir, }); - vi.spyOn(coreEvents, 'emitFeedback'); }); afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.restoreAllMocks(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } }); it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, + // Create a source extension + const sourceDir = path.join(tempDir, 'source-ext'); + createExtension({ + extensionsDir: sourceDir, // createExtension appends name name: 'skills-ext', version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'skills-ext'), + }, }); + const extensionPath = path.join(sourceDir, 'skills-ext'); - const skillsDir = path.join(sourceExtDir, 'skills'); + // Add invalid skills content + const skillsDir = path.join(extensionPath, 'skills'); fs.mkdirSync(skillsDir); fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); await extensionManager.loadExtensions(); - const extension = await extensionManager.installOrUpdateExtension({ - source: sourceExtDir, + + await extensionManager.installOrUpdateExtension({ type: 'local', + source: extensionPath, }); - expect(extension.name).toBe('skills-ext'); - expect(coreEvents.emitFeedback).toHaveBeenCalledWith( - 'warning', + expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); }); it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => { - const extDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'load-skills-ext', + // 1. Create a source extension + const sourceDir = path.join(tempDir, 'source-ext-load'); + createExtension({ + extensionsDir: sourceDir, + name: 'skills-ext-load', version: '1.0.0', }); + const sourceExtPath = path.join(sourceDir, 'skills-ext-load'); - const skillsDir = path.join(extDir, 'skills'); + // Add invalid skills content + const skillsDir = path.join(sourceExtPath, 'skills'); fs.mkdirSync(skillsDir); fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); + // 2. Install it to ensure correct disk state await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + type: 'local', + source: sourceExtPath, + }); - expect(coreEvents.emitFeedback).toHaveBeenCalledWith( - 'warning', + // Clear the spy + vi.mocked(debugLogger.debug).mockClear(); + + // 3. Create a fresh ExtensionManager to force loading from disk + const newExtensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + + // 4. Load extensions + await newExtensionManager.loadExtensions(); + + expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); - expect(coreEvents.emitFeedback).toHaveBeenCalledWith( - 'warning', - expect.stringContaining( - 'The directory is not empty but no valid skills were discovered', - ), - ); }); it('should succeed if skills are correctly loaded', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, + const sourceDir = path.join(tempDir, 'source-ext-good'); + createExtension({ + extensionsDir: sourceDir, name: 'good-skills-ext', version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'good-skills-ext'), + }, }); + const extensionPath = path.join(sourceDir, 'good-skills-ext'); - const skillsDir = path.join(sourceExtDir, 'skills'); + const skillsDir = path.join(extensionPath, 'skills'); const skillSubdir = path.join(skillsDir, 'test-skill'); fs.mkdirSync(skillSubdir, { recursive: true }); fs.writeFileSync( @@ -121,17 +171,14 @@ describe('ExtensionManager skills validation', () => { ); await extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ - source: sourceExtDir, type: 'local', + source: extensionPath, }); - expect(extension.skills).toHaveLength(1); - expect(extension.skills![0].name).toBe('test-skill'); - // It might be called for other reasons during startup, but shouldn't be called for our skills loading success - // Actually, it shouldn't be called with our warning message - expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith( - 'warning', + expect(extension.name).toBe('good-skills-ext'); + expect(debugLogger.debug).not.toHaveBeenCalledWith( expect.stringContaining('Failed to load skills from'), ); }); diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts new file mode 100644 index 0000000000..f48daeaee0 --- /dev/null +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + beforeAll, + afterAll, + beforeEach, + describe, + expect, + it, + vi, + afterEach, +} from 'vitest'; +import { createExtension } from '../test-utils/createExtension.js'; +import { ExtensionManager } from './extension-manager.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { GEMINI_DIR, type Config } from '@google/gemini-cli-core'; +import { createTestMergedSettings, SettingScope } from './settings.js'; + +describe('ExtensionManager theme loading', () => { + let extensionManager: ExtensionManager; + let userExtensionsDir: string; + let tempHomeDir: string; + + beforeAll(async () => { + tempHomeDir = await fs.promises.mkdtemp( + path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'), + ); + }); + + afterAll(async () => { + if (tempHomeDir) { + await fs.promises.rm(tempHomeDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + process.env['GEMINI_CLI_HOME'] = tempHomeDir; + userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); + // Ensure userExtensionsDir is clean for each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + experimental: { extensionConfig: true }, + security: { blockGitExtensions: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + }), + requestConsent: async () => true, + requestSetting: async () => '', + workspaceDir: tempHomeDir, + enabledExtensionOverrides: [], + }); + vi.clearAllMocks(); + themeManager.clearExtensionThemes(); + themeManager.loadCustomThemes({}); + themeManager.setActiveTheme(DEFAULT_THEME.name); + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_HOME']; + }); + + it('should register themes from an extension when started', async () => { + const registerSpy = vi.spyOn(themeManager, 'registerExtensionThemes'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-theme-extension', + themes: [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + const mockConfig = { + getEnableExtensionReloading: () => false, + getMcpClientManager: () => ({ + startExtension: vi.fn().mockResolvedValue(undefined), + }), + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileExclusions: () => ({ + isIgnored: () => false, + }), + getGeminiMdFilePaths: () => [], + getMcpServers: () => ({}), + getAllowedMcpServers: () => [], + getSanitizationConfig: () => ({ + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + showColor: false, + pager: 'cat', + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + getToolRegistry: () => ({ + getTools: () => [], + }), + getProxy: () => undefined, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + } as unknown as Config; + + await extensionManager.start(mockConfig); + + expect(registerSpy).toHaveBeenCalledWith('my-theme-extension', [ + { + name: 'My-Awesome-Theme', + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ]); + }); + + it('should revert to default theme when extension is stopped', async () => { + const extensionName = 'my-theme-extension'; + const themeName = 'My-Awesome-Theme'; + const namespacedThemeName = `${themeName} (${extensionName})`; + + createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + themes: [ + { + name: themeName, + type: 'custom', + text: { + primary: '#FF00FF', + }, + }, + ], + }); + + await extensionManager.loadExtensions(); + + const mockConfig = { + getWorkingDir: () => tempHomeDir, + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), + getDebugMode: () => false, + getFileService: () => ({ + findFiles: async () => [], + }), + getExtensionLoader: () => ({ + getExtensions: () => [], + }), + isTrustedFolder: () => true, + getImportFormat: () => 'tree', + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getDiscoveryMaxDirs: () => 200, + getMcpClientManager: () => ({ + getMcpInstructions: () => '', + startExtension: vi.fn().mockResolvedValue(undefined), + stopExtension: vi.fn().mockResolvedValue(undefined), + }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), + setGeminiMdFilePaths: vi.fn(), + getEnableExtensionReloading: () => true, + getGeminiClient: () => ({ + isInitialized: () => false, + updateSystemInstruction: vi.fn(), + setTools: vi.fn(), + }), + getHookSystem: () => undefined, + getProxy: () => undefined, + getAgentRegistry: () => ({ + reload: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as Config; + + await extensionManager.start(mockConfig); + + // Set the active theme to the one from the extension + themeManager.setActiveTheme(namespacedThemeName); + expect(themeManager.getActiveTheme().name).toBe(namespacedThemeName); + + // Stop the extension + await extensionManager.disableExtension(extensionName, SettingScope.User); + + // Check that the active theme has reverted to the default + expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 3dcd71dac4..9e19109eda 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -6,11 +6,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type Settings, SettingScope } from './settings.js'; +import { type MergedSettings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { @@ -39,6 +38,8 @@ import { logExtensionUninstall, logExtensionUpdateEvent, loadSkillsFromDir, + loadAgentsFromDirectory, + homedir, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, @@ -46,6 +47,7 @@ import { type HookDefinition, type HookEventName, type ResolvedExtensionSetting, + coreEvents, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; @@ -55,21 +57,28 @@ import { INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, type JsonObject, + type VariableContext, } from './extensions/variables.js'; import { getEnvContents, + getEnvFilePath, maybePromptForSettings, + getMissingSettings, type ExtensionSetting, + getScopedEnvContents, + ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; +import { themeManager } from '../ui/themes/theme-manager.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; - settings: Settings; + settings: MergedSettings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; eventEmitter?: EventEmitter; + clientVersion?: string; } /** @@ -79,7 +88,7 @@ interface ExtensionManagerParams { */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; - private settings: Settings; + private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) @@ -99,6 +108,7 @@ export class ExtensionManager extends ExtensionLoader { telemetry: options.settings.telemetry, interactive: false, sessionId: randomUUID(), + clientVersion: options.clientVersion ?? 'unknown', targetDir: options.workspaceDir, cwd: options.workspaceDir, model: '', @@ -134,14 +144,35 @@ export class ExtensionManager extends ExtensionLoader { previousExtensionConfig?: ExtensionConfig, ): Promise { 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 + this.settings.security.blockGitExtensions ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', ); } + const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; @@ -227,25 +258,6 @@ Would you like to attempt to install via "git clone" instead?`, try { newExtensionConfig = await this.loadExtensionConfig(localSourcePath); - if (isUpdate && installMetadata.autoUpdate) { - const oldSettings = new Set( - previousExtensionConfig.settings?.map((s) => s.name) || [], - ); - const newSettings = new Set( - newExtensionConfig.settings?.map((s) => s.name) || [], - ); - - const settingsAreEqual = - oldSettings.size === newSettings.size && - [...oldSettings].every((value) => newSettings.has(value)); - - if (!settingsAreEqual && installMetadata.autoUpdate) { - throw new Error( - `Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`, - ); - } - } - const newExtensionName = newExtensionConfig.name; const previous = this.getExtensions().find( (installed) => installed.name === newExtensionName, @@ -293,12 +305,13 @@ Would you like to attempt to install via "git clone" instead?`, previousSettings = await getEnvContents( previousExtensionConfig, extensionId, + this.workspaceDir, ); await this.uninstallExtension(newExtensionName, isUpdate); } await fs.promises.mkdir(destinationPath, { recursive: true }); - if (this.requestSetting) { + if (this.requestSetting && this.settings.experimental.extensionConfig) { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, @@ -316,6 +329,23 @@ Would you like to attempt to install via "git clone" instead?`, } } + const missingSettings = this.settings.experimental.extensionConfig + ? await getMissingSettings( + newExtensionConfig, + extensionId, + this.workspaceDir, + ) + : []; + if (missingSettings.length > 0) { + const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings + .map((s) => s.name) + .join( + ', ', + )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; + debugLogger.warn(message); + coreEvents.emitFeedback('warning', message); + } + if ( installMetadata.type === 'local' || installMetadata.type === 'git' || @@ -460,6 +490,20 @@ Would you like to attempt to install via "git clone" instead?`, ); } + protected override async startExtension(extension: GeminiCLIExtension) { + await super.startExtension(extension); + if (extension.themes) { + themeManager.registerExtensionThemes(extension.name, extension.themes); + } + } + + protected override async stopExtension(extension: GeminiCLIExtension) { + await super.stopExtension(extension); + if (extension.themes) { + themeManager.unregisterExtensionThemes(extension.name, extension.themes); + } + } + /** * Loads all installed extensions, should only be called once. */ @@ -467,6 +511,12 @@ Would you like to attempt to install via "git clone" instead?`, if (this.loadedExtensions) { throw new Error('Extensions already loaded, only load extensions once.'); } + + if (this.settings.admin.extensions.enabled === false) { + this.loadedExtensions = []; + return this.loadedExtensions; + } + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); this.loadedExtensions = []; if (!fs.existsSync(extensionsDir)) { @@ -493,10 +543,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 + this.settings.security.blockGitExtensions ) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, + ); return null; } @@ -514,16 +593,58 @@ Would you like to attempt to install via "git clone" instead?`, ); } - const customEnv = await getEnvContents( - config, - getExtensionId(config, installMetadata), - ); + const extensionId = getExtensionId(config, installMetadata); + + let userSettings: Record = {}; + let workspaceSettings: Record = {}; + + if (this.settings.experimental.extensionConfig) { + userSettings = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + ); + if (isWorkspaceTrusted(this.settings).isTrusted) { + workspaceSettings = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + this.workspaceDir, + ); + } + } + + const customEnv = { ...userSettings, ...workspaceSettings }; config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; - if (config.settings) { + if (config.settings && this.settings.experimental.extensionConfig) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; + let scope: 'user' | 'workspace' | undefined; + let source: string | undefined; + + // Note: strict check for undefined, as empty string is a valid value + if (workspaceSettings[setting.envVar] !== undefined) { + scope = 'workspace'; + if (setting.sensitive) { + source = 'Keychain'; + } else { + source = getEnvFilePath( + config.name, + ExtensionSettingScope.WORKSPACE, + this.workspaceDir, + ); + } + } else if (userSettings[setting.envVar] !== undefined) { + scope = 'user'; + if (setting.sensitive) { + source = 'Keychain'; + } else { + source = getEnvFilePath(config.name, ExtensionSettingScope.USER); + } + } + resolvedSettings.push({ name: setting.name, envVar: setting.envVar, @@ -534,17 +655,23 @@ Would you like to attempt to install via "git clone" instead?`, ? '***' : value, sensitive: setting.sensitive ?? false, + scope, + source, }); } } if (config.mcpServers) { - config.mcpServers = Object.fromEntries( - Object.entries(config.mcpServers).map(([key, value]) => [ - key, - filterMcpConfig(value), - ]), - ); + if (this.settings.admin.mcp.enabled === false) { + config.mcpServers = undefined; + } else { + config.mcpServers = Object.fromEntries( + Object.entries(config.mcpServers).map(([key, value]) => [ + key, + filterMcpConfig(value), + ]), + ); + } } const contextFiles = getContextFileNames(config) @@ -553,17 +680,67 @@ Would you like to attempt to install via "git clone" instead?`, ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); + const hydrationContext: VariableContext = { + extensionPath: effectiveExtensionPath, + workspacePath: this.workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + ...customEnv, + }; + let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; - if (this.settings.tools?.enableHooks) { - hooks = await this.loadExtensionHooks(effectiveExtensionPath, { - extensionPath: effectiveExtensionPath, - workspacePath: this.workspaceDir, - }); + if (this.settings.hooksConfig.enabled) { + hooks = await this.loadExtensionHooks( + effectiveExtensionPath, + hydrationContext, + ); } - const skills = await loadSkillsFromDir( + // Hydrate hooks with extension settings as environment variables + if (hooks && config.settings) { + const hookEnv: Record = {}; + for (const setting of config.settings) { + const value = customEnv[setting.envVar]; + if (value !== undefined) { + hookEnv[setting.envVar] = value; + } + } + + if (Object.keys(hookEnv).length > 0) { + for (const eventName of Object.keys(hooks)) { + const eventHooks = hooks[eventName as HookEventName]; + if (eventHooks) { + for (const definition of eventHooks) { + for (const hook of definition.hooks) { + // Merge existing env with new env vars, giving extension settings precedence. + hook.env = { ...hook.env, ...hookEnv }; + } + } + } + } + } + } + + let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); + skills = skills.map((skill) => + recursivelyHydrateStrings(skill, hydrationContext), + ); + + const agentLoadResult = await loadAgentsFromDirectory( + path.join(effectiveExtensionPath, 'agents'), + ); + agentLoadResult.agents = agentLoadResult.agents.map((agent) => + recursivelyHydrateStrings(agent, hydrationContext), + ); + + // Log errors but don't fail the entire extension load + for (const error of agentLoadResult.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, + ); + } const extension: GeminiCLIExtension = { name: config.name, @@ -582,6 +759,8 @@ Would you like to attempt to install via "git clone" instead?`, settings: config.settings, resolvedSettings, skills, + agents: agentLoadResult.agents, + themes: config.themes, }; this.loadedExtensions = [...this.loadedExtensions, extension]; @@ -597,6 +776,14 @@ Would you like to attempt to install via "git clone" instead?`, } } + override async restartExtension( + extension: GeminiCLIExtension, + ): Promise { + const extensionDir = extension.path; + await this.unloadExtension(extension); + await this.loadExtension(extensionDir); + } + /** * Removes `extension` from the list of extensions and stops it if * appropriate. @@ -646,7 +833,7 @@ Would you like to attempt to install via "git clone" instead?`, private async loadExtensionHooks( extensionDir: string, - context: { extensionPath: string; workspacePath: string }, + context: VariableContext, ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); @@ -694,7 +881,7 @@ Would you like to attempt to install via "git clone" instead?`, toOutputString(extension: GeminiCLIExtension): string { const userEnabled = this.extensionEnablementManager.isEnabled( extension.name, - os.homedir(), + homedir(), ); const workspaceEnabled = this.extensionEnablementManager.isEnabled( extension.name, @@ -746,7 +933,15 @@ Would you like to attempt to install via "git clone" instead?`, if (resolvedSettings && resolvedSettings.length > 0) { output += `\n Settings:`; resolvedSettings.forEach((setting) => { - output += `\n ${setting.name}: ${setting.value}`; + let scope = ''; + if (setting.scope) { + scope = setting.scope === 'workspace' ? '(Workspace' : '(User'; + if (setting.source) { + scope += ` - ${setting.source}`; + } + scope += ')'; + } + output += `\n ${setting.name}: ${setting.value} ${scope}`; }); } return output; @@ -768,7 +963,7 @@ Would you like to attempt to install via "git clone" instead?`, if (scope !== SettingScope.Session) { const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.disable(name, true, scopePath); } await logExtensionDisable( @@ -803,7 +998,7 @@ Would you like to attempt to install via "git clone" instead?`, if (scope !== SettingScope.Session) { const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.enable(name, true, scopePath); } await logExtensionEnable( diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index b8aa336b5d..0148fc7729 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -23,8 +23,14 @@ import { ExtensionDisableEvent, ExtensionEnableEvent, KeychainTokenStorage, + loadAgentsFromDirectory, + loadSkillsFromDir, } from '@google/gemini-cli-core'; -import { loadSettings, SettingScope } from './settings.js'; +import { + loadSettings, + createTestMergedSettings, + SettingScope, +} from './settings.js'; import { isWorkspaceTrusted, resetTrustedFoldersForTesting, @@ -105,6 +111,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { logExtensionUninstall: mockLogExtensionUninstall, logExtensionUpdateEvent: mockLogExtensionUpdateEvent, logExtensionDisable: mockLogExtensionDisable, + homedir: mockHomedir, ExtensionEnableEvent: vi.fn(), ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), @@ -116,6 +123,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { listSecrets: vi.fn(), isAvailable: vi.fn().mockResolvedValue(true), })), + loadAgentsFromDirectory: vi + .fn() + .mockImplementation(async () => ({ agents: [], errors: [] })), + loadSkillsFromDir: vi.fn().mockImplementation(async () => []), }; }); @@ -170,6 +181,11 @@ describe('extension tests', () => { ( KeychainTokenStorage as unknown as ReturnType ).mockImplementation(() => mockKeychainStorage); + vi.mocked(loadAgentsFromDirectory).mockResolvedValue({ + agents: [], + errors: [], + }); + vi.mocked(loadSkillsFromDir).mockResolvedValue([]); tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); @@ -188,11 +204,13 @@ describe('extension tests', () => { source: undefined, }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + const settings = loadSettings(tempWorkspaceDir).merged; + settings.experimental.extensionConfig = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - settings: loadSettings(tempWorkspaceDir).merged, + settings, }); resetTrustedFoldersForTesting(); }); @@ -604,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', @@ -614,11 +633,9 @@ describe('extension tests', () => { }, }); - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -629,6 +646,143 @@ 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 () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.extensions.enabled = false; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toEqual([]); + }); + + it('should not load mcpServers if admin.mcp.enabled is false', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { command: 'echo', args: ['hello'] }, + }, + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.mcp.enabled = false; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers).toBeUndefined(); + }); + + it('should load mcpServers if admin.mcp.enabled is true', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { command: 'echo', args: ['hello'] }, + }, + }); + const loadedSettings = loadSettings(tempWorkspaceDir).merged; + loadedSettings.admin.mcp.enabled = true; + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: loadedSettings, + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers).toEqual({ + 'test-server': { command: 'echo', args: ['hello'] }, + }); }); describe('id generation', () => { @@ -729,6 +883,7 @@ describe('extension tests', () => { fs.mkdirSync(hooksDir); const hooksConfig = { + enabled: false, hooks: { BeforeTool: [ { @@ -750,8 +905,7 @@ describe('extension tests', () => { ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.tools) settings.tools = {}; - settings.tools.enableHooks = true; + settings.hooksConfig.enabled = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, @@ -771,7 +925,7 @@ describe('extension tests', () => { ); }); - it('should not load hooks if enableHooks is false', async () => { + it('should not load hooks if hooks.enabled is false', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'hook-extension-disabled', @@ -782,12 +936,11 @@ describe('extension tests', () => { fs.mkdirSync(hooksDir); fs.writeFileSync( path.join(hooksDir, 'hooks.json'), - JSON.stringify({ hooks: { BeforeTool: [] } }), + JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }), ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.tools) settings.tools = {}; - settings.tools.enableHooks = false; + settings.hooksConfig.enabled = false; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, @@ -1011,11 +1164,9 @@ describe('extension tests', () => { it('should not install a github extension if blockGitExtensions is set', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -1033,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, @@ -1211,10 +1386,11 @@ describe('extension tests', () => { expect(mockRequestConsent).toHaveBeenCalledWith( `Installing extension "my-local-extension". -${INSTALL_WARNING_MESSAGE} This extension will run the following MCP servers: * test-server (local): node dobadthing \\u001b[12D\\u001b[K server.js - * test-server-2 (remote): https://google.com`, + * test-server-2 (remote): https://google.com + +${INSTALL_WARNING_MESSAGE}`, ); }); @@ -1447,7 +1623,7 @@ This extension will run the following MCP servers: expect(envContent).toContain('NEW_SETTING=new-setting-value'); }); - it('should fail auto-update if settings have changed', async () => { + it('should auto-update if settings have changed', async () => { // 1. Install initial version with autoUpdate: true const oldSourceExtDir = createExtension({ extensionsDir: tempHomeDir, @@ -1469,7 +1645,7 @@ This extension will run the following MCP servers: }); // 2. Create new version with different settings - const newSourceExtDir = createExtension({ + const extensionDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-auto-update-ext', version: '1.1.0', @@ -1488,14 +1664,17 @@ This extension will run the following MCP servers: ); // 3. Attempt to update and assert it fails - await expect( - extensionManager.installOrUpdateExtension( - { source: newSourceExtDir, type: 'local', autoUpdate: true }, - previousExtensionConfig, - ), - ).rejects.toThrow( - 'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.', + const updatedExtension = await extensionManager.installOrUpdateExtension( + { + source: extensionDir, + type: 'local', + autoUpdate: true, + }, + previousExtensionConfig, ); + + expect(updatedExtension.version).toBe('1.1.0'); + expect(extensionManager.getExtensions()[0].version).toBe('1.1.0'); }); it('should throw an error for invalid extension names', async () => { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index bafaba59a8..b6256fc83b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -7,6 +7,7 @@ import type { MCPServerConfig, ExtensionInstallMetadata, + CustomTheme, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -27,6 +28,11 @@ export interface ExtensionConfig { contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; + /** + * Custom themes contributed by this extension. + * These themes will be registered when the extension is activated. + */ + themes?: CustomTheme[]; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 72a0b79fb6..4180a72b16 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -27,12 +27,26 @@ const mockReadline = vi.hoisted(() => ({ }), })); +const mockReaddir = vi.hoisted(() => vi.fn()); +const originalReaddir = vi.hoisted(() => ({ + current: null as typeof fs.readdir | null, +})); + // Mocking readline for non-interactive prompts vi.mock('node:readline', () => ({ default: mockReadline, createInterface: mockReadline.createInterface, })); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + originalReaddir.current = actual.readdir; + return { + ...actual, + readdir: mockReaddir, + }; +}); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +63,10 @@ describe('consent', () => { beforeEach(async () => { vi.clearAllMocks(); + if (originalReaddir.current) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockReaddir.mockImplementation(originalReaddir.current as any); + } tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-')); }); @@ -173,12 +191,13 @@ describe('consent', () => { const expectedConsentString = [ 'Installing extension "test-ext".', - INSTALL_WARNING_MESSAGE, 'This extension will run the following MCP servers:', ' * server1 (local): npm start', ' * server2 (remote): https://remote.com', 'This extension will append info to your gemini.md context using my-context.md', 'This extension will exclude the following core tools: tool1,tool2', + '', + INSTALL_WARNING_MESSAGE, ].join('\n'); expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); @@ -306,7 +325,6 @@ describe('consent', () => { const expectedConsentString = [ 'Installing extension "test-ext".', - INSTALL_WARNING_MESSAGE, 'This extension will run the following MCP servers:', ' * server1 (local): npm start', ' * server2 (remote): https://remote.com', @@ -314,13 +332,17 @@ describe('consent', () => { 'This extension will exclude the following core tools: tool1,tool2', '', chalk.bold('Agent Skills:'), - SKILLS_WARNING_MESSAGE, - 'This extension will install the following agent skills:', + '\nThis extension will install the following agent skills:\n', ` * ${chalk.bold('skill1')}: desc1`, - ` (Location: ${skill1.location}) (2 items in directory)`, - ` * ${chalk.bold('skill2')}: desc2`, - ` (Location: ${skill2.location}) (1 items in directory)`, + chalk.dim(` (Source: ${skill1.location}) (2 items in directory)`), '', + ` * ${chalk.bold('skill2')}: desc2`, + chalk.dim(` (Source: ${skill2.location}) (1 items in directory)`), + '', + '', + INSTALL_WARNING_MESSAGE, + '', + SKILLS_WARNING_MESSAGE, ].join('\n'); expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); @@ -328,7 +350,7 @@ describe('consent', () => { it('should show a warning if the skill directory cannot be read', async () => { const lockedDir = path.join(tempDir, 'locked'); - await fs.mkdir(lockedDir, { recursive: true, mode: 0o000 }); + await fs.mkdir(lockedDir, { recursive: true }); const skill: SkillDefinition = { name: 'locked-skill', @@ -337,27 +359,62 @@ describe('consent', () => { body: 'body', }; - const requestConsent = vi.fn().mockResolvedValue(true); - try { - await maybeRequestConsentOrFail( - baseConfig, - requestConsent, - false, - undefined, - false, - [skill], - ); + // Mock readdir to simulate a permission error. + // We do this instead of using fs.mkdir(..., { mode: 0o000 }) because + // directory permissions work differently on Windows and 0o000 doesn't + // effectively block access there, leading to test failures in Windows CI. + mockReaddir.mockRejectedValueOnce( + new Error('EACCES: permission denied, scandir'), + ); - expect(requestConsent).toHaveBeenCalledWith( - expect.stringContaining( - ` (Location: ${skill.location}) ${chalk.red('โš ๏ธ (Could not count items in directory)')}`, - ), - ); - } finally { - // Restore permissions so cleanup works - await fs.chmod(lockedDir, 0o700); - } + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + undefined, + false, + [skill], + ); + + expect(requestConsent).toHaveBeenCalledWith( + expect.stringContaining( + ` (Source: ${skill.location}) ${chalk.red('โš ๏ธ (Could not count items in directory)')}`, + ), + ); }); }); }); + + describe('skillsConsentString', () => { + it('should generate a consent string for skills', async () => { + const skill1Dir = path.join(tempDir, 'skill1'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); + + const skill1: SkillDefinition = { + name: 'skill1', + description: 'desc1', + location: path.join(skill1Dir, 'SKILL.md'), + body: 'body1', + }; + + const { skillsConsentString } = await import('./consent.js'); + const consentString = await skillsConsentString( + [skill1], + 'https://example.com/repo.git', + '/mock/target/dir', + ); + + expect(consentString).toContain( + 'Installing agent skill(s) from "https://example.com/repo.git".', + ); + expect(consentString).toContain('Install Destination: /mock/target/dir'); + expect(consentString).toContain('\n' + SKILLS_WARNING_MESSAGE); + expect(consentString).toContain(` * ${chalk.bold('skill1')}: desc1`); + expect(consentString).toContain( + chalk.dim(`(Source: ${skill1.location}) (1 items in directory)`), + ); + }); + }); }); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 47391fd9e6..27b8e9a904 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -21,6 +21,27 @@ export const SKILLS_WARNING_MESSAGE = chalk.yellow( "Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.", ); +/** + * Builds a consent string for installing agent skills. + */ +export async function skillsConsentString( + skills: SkillDefinition[], + source: string, + targetDir?: string, +): Promise { + const output: string[] = []; + output.push(`Installing agent skill(s) from "${source}".`); + output.push('\nThe following agent skill(s) will be installed:\n'); + output.push(...(await renderSkillsList(skills))); + + if (targetDir) { + output.push(`Install Destination: ${targetDir}`); + } + output.push('\n' + SKILLS_WARNING_MESSAGE); + + return output.join('\n'); +} + /** * Requests consent from the user to perform an action, by reading a Y/n * character from stdin. @@ -120,7 +141,6 @@ async function extensionConsentString( const output: string[] = []; const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); output.push(`Installing extension "${sanitizedConfig.name}".`); - output.push(INSTALL_WARNING_MESSAGE); if (mcpServerEntries.length) { output.push('This extension will run the following MCP servers:'); @@ -149,23 +169,37 @@ async function extensionConsentString( } if (skills.length > 0) { output.push(`\n${chalk.bold('Agent Skills:')}`); - output.push(SKILLS_WARNING_MESSAGE); - output.push('This extension will install the following agent skills:'); - for (const skill of skills) { - output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); - const skillDir = path.dirname(skill.location); - let fileCountStr = ''; - try { - const skillDirItems = await fs.readdir(skillDir); - fileCountStr = ` (${skillDirItems.length} items in directory)`; - } catch { - fileCountStr = ` ${chalk.red('โš ๏ธ (Could not count items in directory)')}`; - } - output.push(` (Location: ${skill.location})${fileCountStr}`); + output.push('\nThis extension will install the following agent skills:\n'); + output.push(...(await renderSkillsList(skills))); + } + + output.push('\n' + INSTALL_WARNING_MESSAGE); + if (skills.length > 0) { + output.push('\n' + SKILLS_WARNING_MESSAGE); + } + + return output.join('\n'); +} + +/** + * Shared logic for formatting a list of agent skills for a consent prompt. + */ +async function renderSkillsList(skills: SkillDefinition[]): Promise { + const output: string[] = []; + for (const skill of skills) { + output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); + const skillDir = path.dirname(skill.location); + let fileCountStr = ''; + try { + const skillDirItems = await fs.readdir(skillDir); + fileCountStr = ` (${skillDirItems.length} items in directory)`; + } catch { + fileCountStr = ` ${chalk.red('โš ๏ธ (Could not count items in directory)')}`; } + output.push(chalk.dim(` (Source: ${skill.location})${fileCountStr}`)); output.push(''); } - return output.join('\n'); + return output; } /** diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index 39b1eafe40..09ed586b82 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -398,6 +398,35 @@ describe('extensionSettings', () => { expect(actualContent).toBe('VAR1="a value with spaces"\n'); }); + it('should not set sensitive settings if the value is empty during initial setup', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 's1', + description: 'd1', + envVar: 'SENSITIVE_VAR', + sensitive: true, + }, + ], + }; + mockRequestSetting.mockResolvedValue(''); + + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); + }); + it('should not attempt to clear secrets if keychain is unavailable', async () => { // Arrange const mockIsAvailable = vi.fn().mockResolvedValue(false); @@ -529,6 +558,7 @@ describe('extensionSettings', () => { config, extensionId, ExtensionSettingScope.USER, + tempWorkspaceDir, ); expect(contents).toEqual({ @@ -552,6 +582,7 @@ describe('extensionSettings', () => { config, extensionId, ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, ); expect(contents).toEqual({ @@ -596,7 +627,11 @@ describe('extensionSettings', () => { ); await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); - const contents = await getEnvContents(config, extensionId); + const contents = await getEnvContents( + config, + extensionId, + tempWorkspaceDir, + ); expect(contents).toEqual({ VAR1: 'workspace-value1', @@ -636,6 +671,7 @@ describe('extensionSettings', () => { 'VAR1', mockRequestSetting, ExtensionSettingScope.USER, + tempWorkspaceDir, ); const expectedEnvPath = path.join(extensionDir, '.env'); @@ -652,6 +688,7 @@ describe('extensionSettings', () => { 'VAR1', mockRequestSetting, ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, ); const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); @@ -668,6 +705,7 @@ describe('extensionSettings', () => { 'VAR2', mockRequestSetting, ExtensionSettingScope.USER, + tempWorkspaceDir, ); const userKeychain = new KeychainTokenStorage( @@ -685,6 +723,7 @@ describe('extensionSettings', () => { 'VAR2', mockRequestSetting, ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, ); const workspaceKeychain = new KeychainTokenStorage( @@ -710,6 +749,7 @@ describe('extensionSettings', () => { 'VAR1', mockRequestSetting, ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, ); // Read the .env file after update @@ -727,5 +767,42 @@ describe('extensionSettings', () => { const lines = actualContent.split('\n').filter((line) => line.length > 0); expect(lines).toHaveLength(3); // Should only have the three variables }); + + it('should delete a sensitive setting if the new value is empty', async () => { + mockRequestSetting.mockResolvedValue(''); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('VAR2')).toBeNull(); + }); + + it('should not throw if deleting a non-existent sensitive setting with empty value', async () => { + mockRequestSetting.mockResolvedValue(''); + // Ensure it doesn't exist first + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.deleteSecret('VAR2'); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + // Should complete without error + }); }); }); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index eb953d19c5..4ba7d34b35 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -33,20 +33,28 @@ const getKeychainStorageName = ( extensionName: string, extensionId: string, scope: ExtensionSettingScope, + workspaceDir?: string, ): string => { const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`; if (scope === ExtensionSettingScope.WORKSPACE) { - return `${base} ${process.cwd()}`; + if (!workspaceDir) { + throw new Error('Workspace directory is required for workspace scope'); + } + return `${base} ${workspaceDir}`; } return base; }; -const getEnvFilePath = ( +export const getEnvFilePath = ( extensionName: string, scope: ExtensionSettingScope, + workspaceDir?: string, ): string => { if (scope === ExtensionSettingScope.WORKSPACE) { - return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME); + if (!workspaceDir) { + throw new Error('Workspace directory is required for workspace scope'); + } + return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME); } return new ExtensionStorage(extensionName).getEnvFilePath(); }; @@ -104,7 +112,7 @@ export async function maybePromptForSettings( const nonSensitiveSettings: Record = {}; for (const setting of settings) { const value = allSettings[setting.envVar]; - if (value === undefined) { + if (value === undefined || value === '') { continue; } if (setting.sensitive) { @@ -143,12 +151,13 @@ export async function getScopedEnvContents( extensionConfig: ExtensionConfig, extensionId: string, scope: ExtensionSettingScope, + workspaceDir?: string, ): Promise> { const { name: extensionName } = extensionConfig; const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionName, extensionId, scope), + getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); - const envFilePath = getEnvFilePath(extensionName, scope); + const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); let customEnv: Record = {}; if (fsSync.existsSync(envFilePath)) { const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); @@ -171,6 +180,7 @@ export async function getScopedEnvContents( export async function getEnvContents( extensionConfig: ExtensionConfig, extensionId: string, + workspaceDir: string, ): Promise> { if (!extensionConfig.settings || extensionConfig.settings.length === 0) { return Promise.resolve({}); @@ -185,6 +195,7 @@ export async function getEnvContents( extensionConfig, extensionId, ExtensionSettingScope.WORKSPACE, + workspaceDir, ); return { ...userSettings, ...workspaceSettings }; @@ -196,6 +207,7 @@ export async function updateSetting( settingKey: string, requestSetting: (setting: ExtensionSetting) => Promise, scope: ExtensionSettingScope, + workspaceDir: string, ): Promise { const { name: extensionName, settings } = extensionConfig; if (!settings || settings.length === 0) { @@ -214,17 +226,25 @@ export async function updateSetting( const newValue = await requestSetting(settingToUpdate); const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionName, extensionId, scope), + getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); if (settingToUpdate.sensitive) { - await keychain.setSecret(settingToUpdate.envVar, newValue); + if (newValue) { + await keychain.setSecret(settingToUpdate.envVar, newValue); + } else { + try { + await keychain.deleteSecret(settingToUpdate.envVar); + } catch { + // Ignore if secret does not exist + } + } return; } // For non-sensitive settings, we need to read the existing .env file, // update the value, and write it back, preserving any other values. - const envFilePath = getEnvFilePath(extensionName, scope); + const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); let envContent = ''; if (fsSync.existsSync(envFilePath)) { envContent = await fs.readFile(envFilePath, 'utf-8'); @@ -298,3 +318,29 @@ async function clearSettings( } return; } + +export async function getMissingSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + workspaceDir: string, +): Promise { + const { settings } = extensionConfig; + if (!settings || settings.length === 0) { + return []; + } + + const existingSettings = await getEnvContents( + extensionConfig, + extensionId, + workspaceDir, + ); + const missingSettings: ExtensionSetting[] = []; + + for (const setting of settings) { + if (existingSettings[setting.envVar] === undefined) { + missingSettings.push(setting); + } + } + + return missingSettings; +} diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts new file mode 100644 index 0000000000..43b19d1228 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -0,0 +1,308 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import { getMissingSettings } from './extensionSettings.js'; +import type { ExtensionConfig } from '../extension.js'; +import { ExtensionStorage } from './storage.js'; +import { + KeychainTokenStorage, + debugLogger, + type ExtensionInstallMetadata, + type GeminiCLIExtension, + coreEvents, +} from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; +import { ExtensionManager } from '../extension-manager.js'; +import { createTestMergedSettings } from '../settings.js'; + +vi.mock('node:fs', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + KeychainTokenStorage: vi.fn(), + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + }, + coreEvents: { + emitFeedback: vi.fn(), // Mock emitFeedback + on: vi.fn(), + off: vi.fn(), + }, + }; +}); + +// Mock os.homedir because ExtensionStorage uses it +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +describe('extensionUpdates', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let extensionDir: string; + let mockKeychainData: Record>; + + beforeEach(() => { + vi.clearAllMocks(); + mockKeychainData = {}; + + // Mock Keychain + vi.mocked(KeychainTokenStorage).mockImplementation( + (serviceName: string) => { + if (!mockKeychainData[serviceName]) { + mockKeychainData[serviceName] = {}; + } + const keychainData = mockKeychainData[serviceName]; + return { + getSecret: vi + .fn() + .mockImplementation( + async (key: string) => keychainData[key] || null, + ), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + } as unknown as KeychainTokenStorage; + }, + ); + + // Setup Temp Dirs + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); + + // Mock ExtensionStorage to rely on our temp extension dir + vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( + extensionDir, + ); + // Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic. + // getEnvFilePath in extensionSettings.ts: + // if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there) + // if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env' + + fs.mkdirSync(extensionDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('getMissingSettings', () => { + it('should return empty list if all settings are present', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, + ], + }; + const extensionId = '12345'; + + // Setup User Env + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + fs.writeFileSync(userEnvPath, 'VAR1=val1'); + + // Setup Keychain + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext ${extensionId}`, + ); + await userKeychain.setSecret('VAR2', 'val2'); + + const missing = await getMissingSettings( + config, + extensionId, + tempWorkspaceDir, + ); + expect(missing).toEqual([]); + }); + + it('should identify missing non-sensitive settings', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const extensionId = '12345'; + + const missing = await getMissingSettings( + config, + extensionId, + tempWorkspaceDir, + ); + expect(missing).toHaveLength(1); + expect(missing[0].name).toBe('s1'); + }); + + it('should identify missing sensitive settings', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, + ], + }; + const extensionId = '12345'; + + const missing = await getMissingSettings( + config, + extensionId, + tempWorkspaceDir, + ); + expect(missing).toHaveLength(1); + expect(missing[0].name).toBe('s2'); + }); + + it('should respect settings present in workspace', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const extensionId = '12345'; + + // Setup Workspace Env + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + fs.writeFileSync(workspaceEnvPath, 'VAR1=val1'); + + const missing = await getMissingSettings( + config, + extensionId, + tempWorkspaceDir, + ); + expect(missing).toEqual([]); + }); + }); + + describe('ExtensionManager integration', () => { + it('should warn about missing settings after update', async () => { + // Mock ExtensionManager methods to avoid FS/Network usage + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.1.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + + const installMetadata: ExtensionInstallMetadata = { + source: extensionDir, + type: 'local', + autoUpdate: true, + }; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, // Simulate non-interactive + }); + + // Mock methods called by installOrUpdateExtension + vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); + vi.spyOn(manager, 'getExtensions').mockReturnValue([ + { + name: 'test-ext', + version: '1.0.0', + installMetadata, + path: extensionDir, + // Mocks for other required props + contextFiles: [], + mcpServers: {}, + hooks: undefined, + isActive: true, + id: 'test-id', + settings: [], + resolvedSettings: [], + skills: [], + } as unknown as GeminiCLIExtension, + ]); + vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(manager as any, 'loadExtension').mockResolvedValue( + {} as unknown as GeminiCLIExtension, + ); + vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined); + + // Mock fs.promises for the operations inside installOrUpdateExtension + vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); + vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined); + vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks + try { + await manager.installOrUpdateExtension(installMetadata, previousConfig); + } catch (_) { + // Ignore errors from copyExtension or others, we just want to verify the warning + } + + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Extension "test-ext" has missing settings: s1', + ), + ); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'Please run "gemini extensions config test-ext [setting-name]"', + ), + ); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/storage.ts b/packages/cli/src/config/extensions/storage.ts index 8682e578f6..c1cb147e24 100644 --- a/packages/cli/src/config/extensions/storage.ts +++ b/packages/cli/src/config/extensions/storage.ts @@ -11,7 +11,7 @@ import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, } from './variables.js'; -import { Storage } from '@google/gemini-cli-core'; +import { Storage, homedir } from '@google/gemini-cli-core'; export class ExtensionStorage { private readonly extensionName: string; @@ -36,7 +36,7 @@ export class ExtensionStorage { } static getUserExtensionsDir(): string { - return new Storage(os.homedir()).getExtensionsDir(); + return new Storage(homedir()).getExtensionsDir(); } static async createTmpDir(): Promise { diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 78506a9738..2ac28b2021 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -24,7 +24,7 @@ export type JsonValue = | JsonArray; export type VariableContext = { - [key in keyof typeof VARIABLE_SCHEMA]?: string; + [key: string]: string | undefined; }; export function validateVariables( @@ -33,7 +33,7 @@ export function validateVariables( ) { for (const key in schema) { const definition = schema[key]; - if (definition.required && !variables[key as keyof VariableContext]) { + if (definition.required && !variables[key]) { throw new Error(`Missing required variable: ${key}`); } } @@ -43,30 +43,33 @@ export function hydrateString(str: string, context: VariableContext): string { validateVariables(context, VARIABLE_SCHEMA); const regex = /\${(.*?)}/g; return str.replace(regex, (match, key) => - context[key as keyof VariableContext] == null - ? match - : (context[key as keyof VariableContext] as string), + context[key] == null ? match : context[key], ); } -export function recursivelyHydrateStrings( - obj: JsonValue, +export function recursivelyHydrateStrings( + obj: T, values: VariableContext, -): JsonValue { +): T { if (typeof obj === 'string') { - return hydrateString(obj, values); + return hydrateString(obj, values) as unknown as T; } if (Array.isArray(obj)) { - return obj.map((item) => recursivelyHydrateStrings(item, values)); + return obj.map((item) => + recursivelyHydrateStrings(item, values), + ) as unknown as T; } if (typeof obj === 'object' && obj !== null) { - const newObj: JsonObject = {}; + const newObj: Record = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { - newObj[key] = recursivelyHydrateStrings(obj[key], values); + newObj[key] = recursivelyHydrateStrings( + (obj as Record)[key], + values, + ); } } - return newObj; + return newObj as T; } return obj; } diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts index 2a4debd483..c2abc32d27 100644 --- a/packages/cli/src/config/keyBindings.test.ts +++ b/packages/cli/src/config/keyBindings.test.ts @@ -28,25 +28,22 @@ describe('keyBindings config', () => { it('should have valid key binding structures', () => { for (const [_, bindings] of Object.entries(defaultKeyBindings)) { for (const binding of bindings) { - // Each binding should have either key or sequence, but not both - const hasKey = binding.key !== undefined; - const hasSequence = binding.sequence !== undefined; - - expect(hasKey || hasSequence).toBe(true); - expect(hasKey && hasSequence).toBe(false); + // Each binding must have a key name + expect(typeof binding.key).toBe('string'); + expect(binding.key.length).toBeGreaterThan(0); // Modifier properties should be boolean or undefined - if (binding.ctrl !== undefined) { - expect(typeof binding.ctrl).toBe('boolean'); - } if (binding.shift !== undefined) { expect(typeof binding.shift).toBe('boolean'); } - if (binding.command !== undefined) { - expect(typeof binding.command).toBe('boolean'); + if (binding.alt !== undefined) { + expect(typeof binding.alt).toBe('boolean'); } - if (binding.paste !== undefined) { - expect(typeof binding.paste).toBe('boolean'); + if (binding.ctrl !== undefined) { + expect(typeof binding.ctrl).toBe('boolean'); + } + if (binding.cmd !== undefined) { + expect(typeof binding.cmd).toBe('boolean'); } } } @@ -79,9 +76,27 @@ describe('keyBindings config', () => { expect(dialogNavDown).toContainEqual({ key: 'down', shift: false }); expect(dialogNavDown).toContainEqual({ key: 'j', shift: false }); - // Verify physical home/end keys - expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' }); - expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' }); + // Verify physical home/end keys for cursor movement + expect(defaultKeyBindings[Command.HOME]).toContainEqual({ + key: 'home', + ctrl: false, + shift: false, + }); + expect(defaultKeyBindings[Command.END]).toContainEqual({ + key: 'end', + ctrl: false, + shift: false, + }); + + // Verify physical home/end keys for scrolling + expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({ + key: 'home', + ctrl: true, + }); + expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({ + key: 'end', + ctrl: true, + }); }); }); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index b5a20b90e3..9b6a903a4b 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -8,73 +8,93 @@ * Command enum for all available keyboard shortcuts */ export enum Command { - // Basic bindings - RETURN = 'return', - ESCAPE = 'escape', + // Basic Controls + RETURN = 'basic.confirm', + ESCAPE = 'basic.cancel', + QUIT = 'basic.quit', + EXIT = 'basic.exit', - // Cursor movement - HOME = 'home', - END = 'end', + // Cursor Movement + HOME = 'cursor.home', + END = 'cursor.end', + MOVE_UP = 'cursor.up', + MOVE_DOWN = 'cursor.down', + MOVE_LEFT = 'cursor.left', + MOVE_RIGHT = 'cursor.right', + MOVE_WORD_LEFT = 'cursor.wordLeft', + MOVE_WORD_RIGHT = 'cursor.wordRight', - // Text deletion - KILL_LINE_RIGHT = 'killLineRight', - KILL_LINE_LEFT = 'killLineLeft', - CLEAR_INPUT = 'clearInput', - DELETE_WORD_BACKWARD = 'deleteWordBackward', - - // Screen control - CLEAR_SCREEN = 'clearScreen', + // Editing + KILL_LINE_RIGHT = 'edit.deleteRightAll', + KILL_LINE_LEFT = 'edit.deleteLeftAll', + CLEAR_INPUT = 'edit.clear', + DELETE_WORD_BACKWARD = 'edit.deleteWordLeft', + DELETE_WORD_FORWARD = 'edit.deleteWordRight', + DELETE_CHAR_LEFT = 'edit.deleteLeft', + DELETE_CHAR_RIGHT = 'edit.deleteRight', + UNDO = 'edit.undo', + REDO = 'edit.redo', // Scrolling - SCROLL_UP = 'scrollUp', - SCROLL_DOWN = 'scrollDown', - SCROLL_HOME = 'scrollHome', - SCROLL_END = 'scrollEnd', - PAGE_UP = 'pageUp', - PAGE_DOWN = 'pageDown', + SCROLL_UP = 'scroll.up', + SCROLL_DOWN = 'scroll.down', + SCROLL_HOME = 'scroll.home', + SCROLL_END = 'scroll.end', + PAGE_UP = 'scroll.pageUp', + PAGE_DOWN = 'scroll.pageDown', - // History navigation - HISTORY_UP = 'historyUp', - HISTORY_DOWN = 'historyDown', - NAVIGATION_UP = 'navigationUp', - NAVIGATION_DOWN = 'navigationDown', + // History & Search + HISTORY_UP = 'history.previous', + HISTORY_DOWN = 'history.next', + REVERSE_SEARCH = 'history.search.start', + SUBMIT_REVERSE_SEARCH = 'history.search.submit', + ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept', + REWIND = 'history.rewind', - // Dialog navigation - DIALOG_NAVIGATION_UP = 'dialogNavigationUp', - DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown', + // Navigation + NAVIGATION_UP = 'nav.up', + NAVIGATION_DOWN = 'nav.down', + DIALOG_NAVIGATION_UP = 'nav.dialog.up', + DIALOG_NAVIGATION_DOWN = 'nav.dialog.down', + DIALOG_NEXT = 'nav.dialog.next', + DIALOG_PREV = 'nav.dialog.previous', - // Auto-completion - ACCEPT_SUGGESTION = 'acceptSuggestion', - COMPLETION_UP = 'completionUp', - COMPLETION_DOWN = 'completionDown', + // Suggestions & Completions + ACCEPT_SUGGESTION = 'suggest.accept', + COMPLETION_UP = 'suggest.focusPrevious', + COMPLETION_DOWN = 'suggest.focusNext', + EXPAND_SUGGESTION = 'suggest.expand', + COLLAPSE_SUGGESTION = 'suggest.collapse', - // Text input - SUBMIT = 'submit', - NEWLINE = 'newline', + // Text Input + SUBMIT = 'input.submit', + NEWLINE = 'input.newline', + OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', + PASTE_CLIPBOARD = 'input.paste', - // External tools - OPEN_EXTERNAL_EDITOR = 'openExternalEditor', - PASTE_CLIPBOARD = 'pasteClipboard', + 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 level bindings - SHOW_ERROR_DETAILS = 'showErrorDetails', - SHOW_FULL_TODOS = 'showFullTodos', - TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', - TOGGLE_MARKDOWN = 'toggleMarkdown', - TOGGLE_COPY_MODE = 'toggleCopyMode', - QUIT = 'quit', - EXIT = 'exit', - SHOW_MORE_LINES = 'showMoreLines', - - // Shell commands - REVERSE_SEARCH = 'reverseSearch', - SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', - ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', - TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', - - // Suggestion expansion - EXPAND_SUGGESTION = 'expandSuggestion', - COLLAPSE_SUGGESTION = 'collapseSuggestion', + // App Controls + SHOW_ERROR_DETAILS = 'app.showErrorDetails', + SHOW_FULL_TODOS = 'app.showFullTodos', + SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail', + TOGGLE_MARKDOWN = 'app.toggleMarkdown', + TOGGLE_COPY_MODE = 'app.toggleCopyMode', + TOGGLE_YOLO = 'app.toggleYolo', + CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', + SHOW_MORE_LINES = 'app.showMoreLines', + FOCUS_SHELL_INPUT = 'app.focusShellInput', + UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', + CLEAR_SCREEN = 'app.clearScreen', + RESTART_APP = 'app.restart', + SUSPEND_APP = 'app.suspend', } /** @@ -82,17 +102,15 @@ export enum Command { */ export interface KeyBinding { /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ - key?: string; - /** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */ - sequence?: string; - /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - ctrl?: boolean; + key: string; /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ shift?: boolean; - /** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - command?: boolean; - /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ - paste?: boolean; + /** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + alt?: boolean; + /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + ctrl?: boolean; + /** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + cmd?: boolean; } /** @@ -107,42 +125,95 @@ export type KeyBindingConfig = { * Matches the original hard-coded logic exactly */ export const defaultKeyBindings: KeyBindingConfig = { - // Basic bindings + // Basic Controls [Command.RETURN]: [{ key: 'return' }], [Command.ESCAPE]: [{ key: 'escape' }], + [Command.QUIT]: [{ key: 'c', ctrl: true }], + [Command.EXIT]: [{ key: 'd', ctrl: true }], - // Cursor movement - [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }], - [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }], + // Cursor Movement + [Command.HOME]: [ + { key: 'a', ctrl: true }, + { key: 'home', shift: false, ctrl: false }, + ], + [Command.END]: [ + { key: 'e', ctrl: true }, + { key: 'end', shift: false, ctrl: false }, + ], + [Command.MOVE_UP]: [ + { key: 'up', shift: false, alt: false, ctrl: false, cmd: false }, + ], + [Command.MOVE_DOWN]: [ + { key: 'down', shift: false, alt: false, ctrl: false, cmd: false }, + ], + [Command.MOVE_LEFT]: [ + { key: 'left', shift: false, alt: false, ctrl: false, cmd: false }, + ], + [Command.MOVE_RIGHT]: [ + { key: 'right', shift: false, alt: false, ctrl: false, cmd: false }, + { key: 'f', ctrl: true }, + ], + [Command.MOVE_WORD_LEFT]: [ + { key: 'left', ctrl: true }, + { key: 'left', alt: true }, + { key: 'b', alt: true }, + ], + [Command.MOVE_WORD_RIGHT]: [ + { key: 'right', ctrl: true }, + { key: 'right', alt: true }, + { key: 'f', alt: true }, + ], - // Text deletion + // Editing [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], - // Added command (meta/alt/option) for mac compatibility [Command.DELETE_WORD_BACKWARD]: [ { key: 'backspace', ctrl: true }, - { key: 'backspace', command: true }, + { key: 'backspace', alt: true }, + { key: 'w', ctrl: true }, + ], + [Command.DELETE_WORD_FORWARD]: [ + { key: 'delete', ctrl: true }, + { key: 'delete', alt: true }, + ], + [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], + [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], + [Command.UNDO]: [ + { key: 'z', cmd: true, shift: false }, + { key: 'z', alt: true, shift: false }, + ], + [Command.REDO]: [ + { key: 'z', ctrl: true, shift: true }, + { key: 'z', cmd: true, shift: true }, + { key: 'z', alt: true, shift: true }, ], - - // Screen control - [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], // Scrolling [Command.SCROLL_UP]: [{ key: 'up', shift: true }], [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }], - [Command.SCROLL_HOME]: [{ key: 'home' }], - [Command.SCROLL_END]: [{ key: 'end' }], + [Command.SCROLL_HOME]: [ + { key: 'home', ctrl: true }, + { key: 'home', shift: true }, + ], + [Command.SCROLL_END]: [ + { key: 'end', ctrl: true }, + { key: 'end', shift: true }, + ], [Command.PAGE_UP]: [{ key: 'pageup' }], [Command.PAGE_DOWN]: [{ key: 'pagedown' }], - // History navigation - [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], - [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], + // History & Search + [Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }], + [Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }], + [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], + [Command.REWIND]: [{ key: 'double escape' }], + [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], + + // Navigation [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }], [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }], - - // Dialog navigation // Navigation shortcuts appropriate for dialogs where we do not need to accept // text input. [Command.DIALOG_NAVIGATION_UP]: [ @@ -153,70 +224,74 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'down', shift: false }, { key: 'j', shift: false }, ], + [Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }], + [Command.DIALOG_PREV]: [{ key: 'tab', shift: true }], - // Auto-completion + // Suggestions & Completions [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], - // Completion navigation (arrow or Ctrl+P/N) [Command.COMPLETION_UP]: [ { key: 'up', shift: false }, - { key: 'p', ctrl: true, shift: false }, + { key: 'p', shift: false, ctrl: true }, ], [Command.COMPLETION_DOWN]: [ { key: 'down', shift: false }, - { key: 'n', ctrl: true, shift: false }, + { key: 'n', shift: false, ctrl: true }, ], + [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], + [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], - // Text input + // Text Input // Must also exclude shift to allow shift+enter for newline [Command.SUBMIT]: [ { key: 'return', - ctrl: false, - command: false, - paste: false, shift: false, + alt: false, + ctrl: false, + cmd: false, }, ], - // Split into multiple data-driven bindings - // Now also includes shift+enter for multi-line input [Command.NEWLINE]: [ { key: 'return', ctrl: true }, - { key: 'return', command: true }, - { key: 'return', paste: true }, + { key: 'return', cmd: true }, + { key: 'return', alt: true }, { key: 'return', shift: true }, { key: 'j', ctrl: true }, ], - - // External tools - [Command.OPEN_EXTERNAL_EDITOR]: [ - { key: 'x', ctrl: true }, - { sequence: '\x18', ctrl: true }, - ], + [Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }], [Command.PASTE_CLIPBOARD]: [ { key: 'v', ctrl: true }, - { key: 'v', command: true }, + { key: 'v', cmd: true }, + { key: 'v', alt: true }, ], - // App level bindings + // App Controls [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], - [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], + [Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], + [Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }], [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], - [Command.QUIT]: [{ key: 'c', ctrl: true }], - [Command.EXIT]: [{ key: 'd', ctrl: true }], - [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], - - // Shell commands - [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste - [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], - [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], - - // Suggestion expansion - [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], - [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], + [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 }, + ], + [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], + [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], + [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + [Command.RESTART_APP]: [{ key: 'r' }], + [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], }; interface CommandCategory { @@ -230,11 +305,20 @@ interface CommandCategory { export const commandCategories: readonly CommandCategory[] = [ { title: 'Basic Controls', - commands: [Command.RETURN, Command.ESCAPE], + commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT], }, { title: 'Cursor Movement', - commands: [Command.HOME, Command.END], + commands: [ + Command.HOME, + Command.END, + Command.MOVE_UP, + Command.MOVE_DOWN, + Command.MOVE_LEFT, + Command.MOVE_RIGHT, + Command.MOVE_WORD_LEFT, + Command.MOVE_WORD_RIGHT, + ], }, { title: 'Editing', @@ -243,12 +327,13 @@ export const commandCategories: readonly CommandCategory[] = [ Command.KILL_LINE_LEFT, Command.CLEAR_INPUT, Command.DELETE_WORD_BACKWARD, + Command.DELETE_WORD_FORWARD, + Command.DELETE_CHAR_LEFT, + Command.DELETE_CHAR_RIGHT, + Command.UNDO, + Command.REDO, ], }, - { - title: 'Screen Control', - commands: [Command.CLEAR_SCREEN], - }, { title: 'Scrolling', commands: [ @@ -268,6 +353,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.REVERSE_SEARCH, Command.SUBMIT_REVERSE_SEARCH, Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, + Command.REWIND, ], }, { @@ -277,6 +363,8 @@ export const commandCategories: readonly CommandCategory[] = [ Command.NAVIGATION_DOWN, Command.DIALOG_NAVIGATION_UP, Command.DIALOG_NAVIGATION_DOWN, + Command.DIALOG_NEXT, + Command.DIALOG_PREV, ], }, { @@ -291,79 +379,134 @@ export const commandCategories: readonly CommandCategory[] = [ }, { title: 'Text Input', - commands: [Command.SUBMIT, Command.NEWLINE], - }, - { - title: 'External Tools', - commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD], + commands: [ + Command.SUBMIT, + Command.NEWLINE, + Command.OPEN_EXTERNAL_EDITOR, + Command.PASTE_CLIPBOARD, + ], }, { title: 'App Controls', commands: [ Command.SHOW_ERROR_DETAILS, Command.SHOW_FULL_TODOS, - Command.TOGGLE_IDE_CONTEXT_DETAIL, + Command.SHOW_IDE_CONTEXT_DETAIL, Command.TOGGLE_MARKDOWN, Command.TOGGLE_COPY_MODE, + Command.TOGGLE_YOLO, + Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, - Command.TOGGLE_SHELL_INPUT_FOCUS, + 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, + Command.RESTART_APP, + Command.SUSPEND_APP, ], }, - { - title: 'Session Control', - commands: [Command.QUIT, Command.EXIT], - }, ]; /** * Human-readable descriptions for each command, used in docs/tooling. */ export const commandDescriptions: Readonly> = { + // Basic Controls [Command.RETURN]: 'Confirm the current selection or choice.', [Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.', + [Command.QUIT]: + 'Cancel the current request or quit the CLI when input is empty.', + [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', + + // Cursor Movement [Command.HOME]: 'Move the cursor to the start of the line.', [Command.END]: 'Move the cursor to the end of the line.', + [Command.MOVE_UP]: 'Move the cursor up one line.', + [Command.MOVE_DOWN]: 'Move the cursor down one line.', + [Command.MOVE_LEFT]: 'Move the cursor one character to the left.', + [Command.MOVE_RIGHT]: 'Move the cursor one character to the right.', + [Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.', + [Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.', + + // Editing [Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.', [Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.', [Command.CLEAR_INPUT]: 'Clear all text in the input field.', [Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.', - [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + [Command.DELETE_WORD_FORWARD]: 'Delete the next word.', + [Command.DELETE_CHAR_LEFT]: 'Delete the character to the left.', + [Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.', + [Command.UNDO]: 'Undo the most recent text edit.', + [Command.REDO]: 'Redo the most recent undone text edit.', + + // Scrolling [Command.SCROLL_UP]: 'Scroll content up.', [Command.SCROLL_DOWN]: 'Scroll content down.', [Command.SCROLL_HOME]: 'Scroll to the top.', [Command.SCROLL_END]: 'Scroll to the bottom.', [Command.PAGE_UP]: 'Scroll up by one page.', [Command.PAGE_DOWN]: 'Scroll down by one page.', + + // History & Search [Command.HISTORY_UP]: 'Show the previous entry in history.', [Command.HISTORY_DOWN]: 'Show the next entry in history.', + [Command.REVERSE_SEARCH]: 'Start reverse search through history.', + [Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.', + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: + 'Accept a suggestion while reverse searching.', + [Command.REWIND]: 'Browse and rewind previous interactions.', + + // Navigation [Command.NAVIGATION_UP]: 'Move selection up in lists.', [Command.NAVIGATION_DOWN]: 'Move selection down in lists.', [Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.', [Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.', + [Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.', + [Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.', + + // Suggestions & Completions [Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.', [Command.COMPLETION_UP]: 'Move to the previous completion option.', [Command.COMPLETION_DOWN]: 'Move to the next completion option.', + [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', + [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', + + // Text Input [Command.SUBMIT]: 'Submit the current prompt.', [Command.NEWLINE]: 'Insert a newline without submitting.', [Command.OPEN_EXTERNAL_EDITOR]: 'Open the current prompt in an external editor.', [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', + + // App Controls [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', [Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.', - [Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.', + [Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.', [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', - [Command.TOGGLE_COPY_MODE]: - 'Toggle copy mode when the terminal is using the alternate buffer.', - [Command.QUIT]: 'Cancel the current request or quit the CLI.', - [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', + [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', + [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', + [Command.CYCLE_APPROVAL_MODE]: + '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.', - [Command.REVERSE_SEARCH]: 'Start reverse search through history.', - [Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.', - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: - 'Accept a suggestion while reverse searching.', - [Command.TOGGLE_SHELL_INPUT_FOCUS]: - 'Toggle focus between the shell and Gemini input.', - [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', - [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', + '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.', + [Command.RESTART_APP]: 'Restart the application.', + [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', }; diff --git a/packages/cli/src/config/mcp/index.ts b/packages/cli/src/config/mcp/index.ts new file mode 100644 index 0000000000..555f52071e --- /dev/null +++ b/packages/cli/src/config/mcp/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type McpServerEnablementState, + type McpServerEnablementConfig, + type McpServerDisplayState, + type EnablementCallbacks, + type ServerLoadResult, +} from './mcpServerEnablement.js'; diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts new file mode 100644 index 0000000000..8b41324790 --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + ...actual.Storage, + getGlobalGeminiDir: () => '/virtual-home/.gemini', + }, + }; +}); + +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type EnablementCallbacks, +} from './mcpServerEnablement.js'; + +let inMemoryFs: Record = {}; + +function createMockEnablement( + sessionDisabled: boolean, + fileEnabled: boolean, +): EnablementCallbacks { + return { + isSessionDisabled: () => sessionDisabled, + isFileEnabled: () => Promise.resolve(fileEnabled), + }; +} + +function setupFsMocks(): void { + vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { + const content = inMemoryFs[filePath.toString()]; + if (content === undefined) { + const error = new Error(`ENOENT: ${filePath}`); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + } + return content; + }); + vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => { + inMemoryFs[filePath.toString()] = data.toString(); + }); + vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined); +} + +describe('McpServerEnablementManager', () => { + let manager: McpServerEnablementManager; + + beforeEach(() => { + inMemoryFs = {}; + setupFsMocks(); + McpServerEnablementManager.resetInstance(); + manager = McpServerEnablementManager.getInstance(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + McpServerEnablementManager.resetInstance(); + }); + + it('should enable/disable servers with persistence', async () => { + expect(await manager.isFileEnabled('server')).toBe(true); + await manager.disable('server'); + expect(await manager.isFileEnabled('server')).toBe(false); + await manager.enable('server'); + expect(await manager.isFileEnabled('server')).toBe(true); + }); + + it('should handle session disable separately', async () => { + manager.disableForSession('server'); + expect(manager.isSessionDisabled('server')).toBe(true); + expect(await manager.isFileEnabled('server')).toBe(true); + expect(await manager.isEffectivelyEnabled('server')).toBe(false); + manager.clearSessionDisable('server'); + expect(await manager.isEffectivelyEnabled('server')).toBe(true); + }); + + it('should be case-insensitive', async () => { + await manager.disable('PlayWright'); + expect(await manager.isFileEnabled('playwright')).toBe(false); + }); + + it('should return correct display state', async () => { + await manager.disable('file-disabled'); + manager.disableForSession('session-disabled'); + + expect(await manager.getDisplayState('enabled')).toEqual({ + enabled: true, + isSessionDisabled: false, + isPersistentDisabled: false, + }); + expect( + (await manager.getDisplayState('file-disabled')).isPersistentDisabled, + ).toBe(true); + expect( + (await manager.getDisplayState('session-disabled')).isSessionDisabled, + ).toBe(true); + }); + + it('should share session state across getInstance calls', () => { + const instance1 = McpServerEnablementManager.getInstance(); + const instance2 = McpServerEnablementManager.getInstance(); + + instance1.disableForSession('test-server'); + + expect(instance2.isSessionDisabled('test-server')).toBe(true); + expect(instance1).toBe(instance2); + }); +}); + +describe('canLoadServer', () => { + it('blocks when admin has disabled MCP', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: false }); + expect(result.blockType).toBe('admin'); + }); + + it('blocks when server is not in allowlist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['other'], + }); + expect(result.blockType).toBe('allowlist'); + }); + + it('blocks when server is in excludelist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + excludedList: ['s'], + }); + expect(result.blockType).toBe('excludelist'); + }); + + it('blocks when server is session-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(true, true), + }); + expect(result.blockType).toBe('session'); + }); + + it('blocks when server is file-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(false, false), + }); + expect(result.blockType).toBe('enablement'); + }); + + it('allows when admin MCP is enabled and no restrictions', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: true }); + expect(result.allowed).toBe(true); + }); + + it('allows when server passes all checks', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['s'], + enablement: createMockEnablement(false, true), + }); + expect(result.allowed).toBe(true); + }); +}); + +describe('helper functions', () => { + it('normalizeServerId lowercases and trims', () => { + expect(normalizeServerId(' PlayWright ')).toBe('playwright'); + }); + + it('isInSettingsList supports ext: backward compat', () => { + expect(isInSettingsList('playwright', ['playwright']).found).toBe(true); + expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true); + expect( + isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning, + ).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.ts b/packages/cli/src/config/mcp/mcpServerEnablement.ts new file mode 100644 index 0000000000..a510dd6697 --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.ts @@ -0,0 +1,386 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Storage, coreEvents } from '@google/gemini-cli-core'; + +/** + * Stored in JSON file - represents persistent enablement state. + */ +export interface McpServerEnablementState { + enabled: boolean; +} + +/** + * File config format - map of server ID to enablement state. + */ +export interface McpServerEnablementConfig { + [serverId: string]: McpServerEnablementState; +} + +/** + * For UI display - combines file and session state. + */ +export interface McpServerDisplayState { + /** Effective state (considering session override) */ + enabled: boolean; + /** True if disabled via --session flag */ + isSessionDisabled: boolean; + /** True if disabled in file */ + isPersistentDisabled: boolean; +} + +/** + * Callback types for enablement checks (passed from CLI to core). + */ +export interface EnablementCallbacks { + isSessionDisabled: (serverId: string) => boolean; + isFileEnabled: (serverId: string) => Promise; +} + +/** + * Result of canLoadServer check. + */ +export interface ServerLoadResult { + allowed: boolean; + reason?: string; + blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement'; +} + +/** + * Normalize a server ID to canonical lowercase form. + */ +export function normalizeServerId(serverId: string): string { + return serverId.toLowerCase().trim(); +} + +/** + * Check if a server ID is in a settings list (with backward compatibility). + * Handles case-insensitive matching and plain name fallback for ext: servers. + */ +export function isInSettingsList( + serverId: string, + list: string[], +): { found: boolean; deprecationWarning?: string } { + const normalizedId = normalizeServerId(serverId); + const normalizedList = list.map(normalizeServerId); + + // Exact canonical match + if (normalizedList.includes(normalizedId)) { + return { found: true }; + } + + // Backward compat: for ext: servers, check if plain name matches + if (normalizedId.startsWith('ext:')) { + const plainName = normalizedId.split(':').pop(); + if (plainName && normalizedList.includes(plainName)) { + return { + found: true, + deprecationWarning: + `Settings reference '${plainName}' matches extension server '${serverId}'. ` + + `Update your settings to use the full identifier '${serverId}' instead.`, + }; + } + } + + return { found: false }; +} + +/** + * Single source of truth for whether a server can be loaded. + * Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers. + * + * Uses callbacks instead of direct enablementManager reference to keep + * packages/core independent of packages/cli. + */ +export async function canLoadServer( + serverId: string, + config: { + adminMcpEnabled: boolean; + allowedList?: string[]; + excludedList?: string[]; + enablement?: EnablementCallbacks; + }, +): Promise { + const normalizedId = normalizeServerId(serverId); + + // 1. Admin kill switch + if (!config.adminMcpEnabled) { + return { + allowed: false, + reason: + 'MCP servers are disabled by administrator. Check admin settings or contact your admin.', + blockType: 'admin', + }; + } + + // 2. Allowlist check + if (config.allowedList && config.allowedList.length > 0) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.allowedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (!found) { + return { + allowed: false, + reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`, + blockType: 'allowlist', + }; + } + } + + // 3. Excludelist check + if (config.excludedList) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.excludedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (found) { + return { + allowed: false, + reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`, + blockType: 'excludelist', + }; + } + } + + // 4. Session disable check (before file-based enablement) + if (config.enablement?.isSessionDisabled(normalizedId)) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`, + blockType: 'session', + }; + } + + // 5. File-based enablement check + if ( + config.enablement && + !(await config.enablement.isFileEnabled(normalizedId)) + ) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`, + blockType: 'enablement', + }; + } + + return { allowed: true }; +} + +const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json'; + +/** + * McpServerEnablementManager + * + * Manages the enabled/disabled state of MCP servers. + * Uses a simplified format compared to ExtensionEnablementManager. + * Supports both persistent (file) and session-only (in-memory) states. + * + * NOTE: Use getInstance() to get the singleton instance. This ensures + * session state (sessionDisabled Set) is shared across all code paths. + */ +export class McpServerEnablementManager { + private static instance: McpServerEnablementManager | null = null; + + private readonly configFilePath: string; + private readonly configDir: string; + private readonly sessionDisabled = new Set(); + + /** + * Get the singleton instance. + */ + static getInstance(): McpServerEnablementManager { + if (!McpServerEnablementManager.instance) { + McpServerEnablementManager.instance = new McpServerEnablementManager(); + } + return McpServerEnablementManager.instance; + } + + /** + * Reset the singleton instance (for testing only). + */ + static resetInstance(): void { + McpServerEnablementManager.instance = null; + } + + constructor() { + this.configDir = Storage.getGlobalGeminiDir(); + this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME); + } + + /** + * Check if server is enabled in FILE (persistent config only). + * Does NOT include session state. + */ + async isFileEnabled(serverName: string): Promise { + const config = await this.readConfig(); + const state = config[normalizeServerId(serverName)]; + return state?.enabled ?? true; + } + + /** + * Check if server is session-disabled. + */ + isSessionDisabled(serverName: string): boolean { + return this.sessionDisabled.has(normalizeServerId(serverName)); + } + + /** + * Check effective enabled state (combines file + session). + * Convenience method; canLoadServer() uses separate callbacks for granular blockType. + */ + async isEffectivelyEnabled(serverName: string): Promise { + if (this.isSessionDisabled(serverName)) { + return false; + } + return this.isFileEnabled(serverName); + } + + /** + * Enable a server persistently. + * Removes the server from config file (defaults to enabled). + */ + async enable(serverName: string): Promise { + const normalizedName = normalizeServerId(serverName); + const config = await this.readConfig(); + + if (normalizedName in config) { + delete config[normalizedName]; + await this.writeConfig(config); + } + } + + /** + * Disable a server persistently. + * Adds server to config file with enabled: false. + */ + async disable(serverName: string): Promise { + const config = await this.readConfig(); + config[normalizeServerId(serverName)] = { enabled: false }; + await this.writeConfig(config); + } + + /** + * Disable a server for current session only (in-memory). + */ + disableForSession(serverName: string): void { + this.sessionDisabled.add(normalizeServerId(serverName)); + } + + /** + * Clear session disable for a server. + */ + clearSessionDisable(serverName: string): void { + this.sessionDisabled.delete(normalizeServerId(serverName)); + } + + /** + * Get display state for a specific server (for UI). + */ + async getDisplayState(serverName: string): Promise { + const isSessionDisabled = this.isSessionDisabled(serverName); + const isPersistentDisabled = !(await this.isFileEnabled(serverName)); + + return { + enabled: !isSessionDisabled && !isPersistentDisabled, + isSessionDisabled, + isPersistentDisabled, + }; + } + + /** + * Get all display states (for UI listing). + */ + async getAllDisplayStates( + serverIds: string[], + ): Promise> { + const result: Record = {}; + for (const serverId of serverIds) { + result[normalizeServerId(serverId)] = + await this.getDisplayState(serverId); + } + return result; + } + + /** + * Get enablement callbacks for passing to core. + */ + getEnablementCallbacks(): EnablementCallbacks { + return { + isSessionDisabled: (id) => this.isSessionDisabled(id), + isFileEnabled: (id) => this.isFileEnabled(id), + }; + } + + /** + * Auto-enable any disabled MCP servers by name. + * Returns server names that were actually re-enabled. + */ + async autoEnableServers(serverNames: string[]): Promise { + const enabledServers: string[] = []; + + for (const serverName of serverNames) { + const normalizedName = normalizeServerId(serverName); + const state = await this.getDisplayState(normalizedName); + + let wasDisabled = false; + if (state.isPersistentDisabled) { + await this.enable(normalizedName); + wasDisabled = true; + } + if (state.isSessionDisabled) { + this.clearSessionDisable(normalizedName); + wasDisabled = true; + } + + if (wasDisabled) { + enabledServers.push(serverName); + } + } + + return enabledServers; + } + + /** + * Read config from file asynchronously. + */ + private async readConfig(): Promise { + try { + const content = await fs.readFile(this.configFilePath, 'utf-8'); + return JSON.parse(content) as McpServerEnablementConfig; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + coreEvents.emitFeedback( + 'error', + 'Failed to read MCP server enablement config.', + error, + ); + return {}; + } + } + + /** + * Write config to file asynchronously. + */ + private async writeConfig(config: McpServerEnablementConfig): Promise { + await fs.mkdir(this.configDir, { recursive: true }); + await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2)); + } +} diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index e9a94836cf..f4cc35dd8a 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -287,6 +287,154 @@ describe('Policy Engine Integration Tests', () => { ).toBe(PolicyDecision.ASK_USER); }); + it('should handle Plan mode correctly', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Read and search tools should be allowed + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'google_web_search' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'list_directory' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // Other tools should be denied via catch all + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + + // Unknown tools should be denied via catch-all + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should allow write_file to plans directory in Plan mode', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Valid plan file path (64-char hex hash, .md extension, safe filename) + const validPlanPath = + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: validPlanPath } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Valid plan with underscore in filename + const validPlanPath2 = + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: validPlanPath2 } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should deny write_file outside plans directory in Plan mode', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Write to workspace (not plans dir) should be denied + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: '/project/src/file.ts' } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + + // Write to plans dir but wrong extension should be denied + const wrongExtPath = + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: wrongExtPath } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + + // Path traversal attempt should be denied (filename contains /) + const traversalPath = + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: traversalPath } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + + // Invalid hash length should be denied + const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: shortHashPath } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should deny write_file to subdirectories in Plan mode', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Write to subdirectory should be denied + const subdirPath = + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md'; + expect( + ( + await engine.check( + { name: 'write_file', args: { file_path: subdirPath } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + }); + it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { tools: { diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 1b38909f3b..14080dc30b 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -25,11 +25,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('command-exists', () => ({ - default: { - sync: vi.fn(), - }, -})); +vi.mock('command-exists', () => { + const sync = vi.fn(); + return { + sync, + default: { + sync, + }, + }; +}); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +53,8 @@ describe('loadSandboxConfig', () => { beforeEach(() => { vi.resetAllMocks(); process.env = { ...originalEnv }; + delete process.env['SANDBOX']; + delete process.env['GEMINI_SANDBOX']; mockedGetPackageJson.mockResolvedValue({ config: { sandboxImageUri: 'default/image' }, }); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 1559cbe78c..15cc99ebd6 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -35,8 +35,16 @@ vi.mock('./trustedFolders.js', () => ({ .mockReturnValue({ isTrusted: true, source: 'file' }), })); +vi.mock('./settingsSchema.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSettingsSchema: vi.fn(actual.getSettingsSchema), + }; +}); + // NOW import everything else, including the (now effectively re-exported) settings.js -import path, * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH +import * as path from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH import { describe, it, @@ -57,22 +65,26 @@ import { USER_SETTINGS_PATH, // This IS the mocked path. getSystemSettingsPath, getSystemDefaultsPath, - migrateSettingsToV1, - needsMigration, type Settings, + saveSettings, + type SettingsFile, + getDefaultsFromSchema, loadEnvironment, migrateDeprecatedSettings, SettingScope, - saveSettings, - type SettingsFile, + LoadedSettings, } from './settings.js'; import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core'; -import { ExtensionManager } from './extension-manager.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { + getSettingsSchema, + MergeStrategy, + type SettingsSchema, +} from './settingsSchema.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; // Use the (mocked) GEMINI_DIR for consistency -const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join( +const MOCK_WORKSPACE_SETTINGS_PATH = path.join( MOCK_WORKSPACE_DIR, GEMINI_DIR, 'settings.json', @@ -100,6 +112,7 @@ vi.mock('./extension.js'); const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), + emitSettingsChanged: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -149,14 +162,6 @@ describe('Settings Loading and Merging', () => { }); describe('loadSettings', () => { - it('should load empty settings if no files exist', () => { - const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.system.settings).toEqual({}); - expect(settings.user.settings).toEqual({}); - expect(settings.workspace.settings).toEqual({}); - expect(settings.merged).toEqual({}); - }); - it.each([ { scope: 'system', @@ -201,7 +206,7 @@ describe('Settings Loading and Merging', () => { expect( settings[scope as 'system' | 'user' | 'workspace'].settings, ).toEqual(content); - expect(settings.merged).toEqual(content); + expect(settings.merged).toMatchObject(content); }, ); @@ -265,7 +270,7 @@ describe('Settings Loading and Merging', () => { expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.workspace.settings).toEqual(workspaceSettingsContent); - expect(settings.merged).toEqual({ + expect(settings.merged).toMatchObject({ ui: { theme: 'system-theme', }, @@ -283,170 +288,30 @@ describe('Settings Loading and Merging', () => { }); }); - it('should correctly migrate a complex legacy (v1) settings file', () => { - (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, - ); - const legacySettingsContent = { - theme: 'legacy-dark', - vimMode: true, - contextFileName: 'LEGACY_CONTEXT.md', - model: 'gemini-2.5-pro', - mcpServers: { - 'legacy-server-1': { - command: 'npm', - args: ['run', 'start:server1'], - description: 'Legacy Server 1', - }, - 'legacy-server-2': { - command: 'node', - args: ['server2.js'], - description: 'Legacy Server 2', - }, - }, - allowMCPServers: ['legacy-server-1'], - someUnrecognizedSetting: 'should-be-preserved', - }; - - (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) - return JSON.stringify(legacySettingsContent); - return '{}'; - }, - ); - - const settings = loadSettings(MOCK_WORKSPACE_DIR); - - expect(settings.merged).toEqual({ - ui: { - theme: 'legacy-dark', - }, - general: { - vimMode: true, - }, - context: { - fileName: 'LEGACY_CONTEXT.md', - }, - model: { - name: 'gemini-2.5-pro', - }, - mcpServers: { - 'legacy-server-1': { - command: 'npm', - args: ['run', 'start:server1'], - description: 'Legacy Server 1', - }, - 'legacy-server-2': { - command: 'node', - args: ['server2.js'], - description: 'Legacy Server 2', - }, - }, - mcp: { - allowed: ['legacy-server-1'], - }, - someUnrecognizedSetting: 'should-be-preserved', - }); - }); - - it('should rewrite allowedTools to tools.allowed during migration', () => { - (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, - ); - const legacySettingsContent = { - allowedTools: ['fs', 'shell'], - }; - (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) - return JSON.stringify(legacySettingsContent); - return '{}'; - }, - ); - - const settings = loadSettings(MOCK_WORKSPACE_DIR); - - expect(settings.merged.tools?.allowed).toEqual(['fs', 'shell']); - expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined(); - }); - - it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => { - (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, - ); - const mixedSettingsContent = { - // V1 setting (migrates to ui.accessibility.screenReader = true) - accessibility: { - screenReader: true, - }, - // V2 setting (explicitly set to false) - ui: { - accessibility: { - screenReader: false, - }, - }, - }; - - (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) - return JSON.stringify(mixedSettingsContent); - return '{}'; - }, - ); - - const settings = loadSettings(MOCK_WORKSPACE_DIR); - - // We expect the V2 setting (false) to win, NOT the migrated V1 setting (true) - expect(settings.merged.ui?.accessibility?.screenReader).toBe(false); - }); - - it('should correctly merge and migrate legacy array properties from multiple scopes', () => { - (mockFsExistsSync as Mock).mockReturnValue(true); - const legacyUserSettings = { - includeDirectories: ['/user/dir'], - excludeTools: ['user-tool'], - excludedProjectEnvVars: ['USER_VAR'], - }; - const legacyWorkspaceSettings = { - includeDirectories: ['/workspace/dir'], - excludeTools: ['workspace-tool'], - excludedProjectEnvVars: ['WORKSPACE_VAR', 'USER_VAR'], - }; - - (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) - return JSON.stringify(legacyUserSettings); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) - return JSON.stringify(legacyWorkspaceSettings); - return '{}'; - }, - ); - - const settings = loadSettings(MOCK_WORKSPACE_DIR); - - // Verify includeDirectories are concatenated - expect(settings.merged.context?.includeDirectories).toEqual([ - '/user/dir', - '/workspace/dir', - ]); - - // Verify excludeTools are concatenated and de-duped - expect(settings.merged.tools?.exclude).toEqual([ - 'user-tool', - 'workspace-tool', - ]); - - // Verify excludedProjectEnvVars are concatenated and de-duped - expect(settings.merged.advanced?.excludedEnvVars).toEqual( - expect.arrayContaining(['USER_VAR', 'WORKSPACE_VAR']), - ); - expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2); - }); - it('should merge all settings files with the correct precedence', () => { + // Mock schema to test defaults application + const mockSchema = { + ui: { type: 'object', default: {}, properties: {} }, + tools: { type: 'object', default: {}, properties: {} }, + context: { + type: 'object', + default: {}, + properties: { + discoveryMaxDirs: { type: 'number', default: 200 }, + includeDirectories: { + type: 'array', + default: [], + mergeStrategy: MergeStrategy.CONCAT, + }, + }, + }, + mcpServers: { type: 'object', default: {} }, + }; + + (getSettingsSchema as Mock).mockReturnValue( + mockSchema as unknown as SettingsSchema, + ); + (mockFsExistsSync as Mock).mockReturnValue(true); const systemDefaultsContent = { ui: { @@ -510,7 +375,7 @@ describe('Settings Loading and Merging', () => { expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.merged).toEqual({ context: { - fileName: 'WORKSPACE_CONTEXT.md', + discoveryMaxDirs: 200, includeDirectories: [ '/system/defaults/dir', '/user/dir1', @@ -518,14 +383,12 @@ describe('Settings Loading and Merging', () => { '/workspace/dir', '/system/dir', ], + fileName: 'WORKSPACE_CONTEXT.md', }, + mcpServers: {}, + ui: { theme: 'system-theme' }, + tools: { sandbox: false }, telemetry: false, - tools: { - sandbox: false, - }, - ui: { - theme: 'system-theme', - }, }); }); @@ -660,7 +523,7 @@ describe('Settings Loading and Merging', () => { }, expected: { key: 'advanced.excludedEnvVars', - value: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'], + value: ['DEBUG', 'DEBUG_MODE', 'NODE_ENV', 'CUSTOM_VAR'], }, }, { @@ -671,7 +534,7 @@ describe('Settings Loading and Merging', () => { }, expected: { key: 'advanced.excludedEnvVars', - value: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + value: ['DEBUG', 'DEBUG_MODE', 'WORKSPACE_DEBUG', 'WORKSPACE_VAR'], }, }, ])( @@ -734,6 +597,7 @@ describe('Settings Loading and Merging', () => { ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', + 'DEBUG_MODE', 'NODE_ENV', 'USER_VAR', 'WORKSPACE_DEBUG', @@ -814,8 +678,8 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.telemetry).toBeUndefined(); - expect(settings.merged.ui).toBeUndefined(); - expect(settings.merged.mcpServers).toBeUndefined(); + expect(settings.merged.ui).toBeDefined(); + expect(settings.merged.mcpServers).toEqual({}); }); it('should merge MCP servers correctly, with workspace taking precedence', () => { @@ -941,7 +805,7 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist (fs.readFileSync as Mock).mockReturnValue('{}'); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.mcpServers).toBeUndefined(); + expect(settings.merged.mcpServers).toEqual({}); }); it('should merge MCP servers from system, user, and workspace with system taking precedence', () => { @@ -1075,10 +939,10 @@ describe('Settings Loading and Merging', () => { expected: 0.8, }, { - description: 'should be undefined if not in any settings file', + description: 'should be default if not in any settings file', userContent: {}, workspaceContent: {}, - expected: undefined, + expected: 0.5, }, ])('$description', ({ userContent, workspaceContent, expected }) => { (mockFsExistsSync as Mock).mockReturnValue(true); @@ -1590,7 +1454,7 @@ describe('Settings Loading and Merging', () => { ); expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH); expect(settings.system.settings).toEqual(systemSettingsContent); - expect(settings.merged).toEqual({ + expect(settings.merged).toMatchObject({ ...systemSettingsContent, }); }); @@ -1692,8 +1556,9 @@ describe('Settings Loading and Merging', () => { 'DEBUG', ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ - 'NODE_ENV', 'DEBUG', + 'DEBUG_MODE', + 'NODE_ENV', ]); }); @@ -1732,6 +1597,7 @@ describe('Settings Loading and Merging', () => { ]); expect(settings.merged.advanced?.excludedEnvVars).toEqual([ 'DEBUG', + 'DEBUG_MODE', 'NODE_ENV', 'USER_VAR', 'WORKSPACE_DEBUG', @@ -1802,339 +1668,6 @@ describe('Settings Loading and Merging', () => { }); }); - describe('migrateSettingsToV1', () => { - it('should handle an empty object', () => { - const v2Settings = {}; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({}); - }); - - it('should migrate a simple v2 settings object to v1', () => { - const v2Settings = { - general: { - preferredEditor: 'vscode', - vimMode: true, - }, - ui: { - theme: 'dark', - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }); - }); - - it('should handle nested properties correctly', () => { - const v2Settings = { - security: { - folderTrust: { - enabled: true, - }, - auth: { - selectedType: 'oauth', - }, - }, - advanced: { - autoConfigureMemory: true, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - folderTrust: true, - selectedAuthType: 'oauth', - autoConfigureMaxOldSpaceSize: true, - }); - }); - - it('should preserve mcpServers at the top level', () => { - const v2Settings = { - general: { - preferredEditor: 'vscode', - }, - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - preferredEditor: 'vscode', - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - }); - }); - - it('should carry over unrecognized top-level properties', () => { - const v2Settings = { - general: { - vimMode: false, - }, - unrecognized: 'value', - another: { - nested: true, - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - vimMode: false, - unrecognized: 'value', - another: { - nested: true, - }, - }); - }); - - it('should handle a complex object with mixed properties', () => { - const v2Settings = { - general: { - disableAutoUpdate: true, - }, - ui: { - hideBanner: true, - customThemes: { - myTheme: {}, - }, - }, - model: { - name: 'gemini-pro', - }, - mcpServers: { - 'server-1': { - command: 'node server.js', - }, - }, - unrecognized: { - should: 'be-preserved', - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({ - disableAutoUpdate: true, - hideBanner: true, - customThemes: { - myTheme: {}, - }, - model: 'gemini-pro', - mcpServers: { - 'server-1': { - command: 'node server.js', - }, - }, - unrecognized: { - should: 'be-preserved', - }, - }); - }); - - it('should not migrate a v1 settings object', () => { - const v1Settings = { - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }; - const migratedSettings = migrateSettingsToV1(v1Settings); - expect(migratedSettings).toEqual({ - preferredEditor: 'vscode', - vimMode: true, - theme: 'dark', - }); - }); - - it('should migrate a full v2 settings object to v1', () => { - const v2Settings: TestSettings = { - general: { - preferredEditor: 'code', - vimMode: true, - }, - ui: { - theme: 'dark', - }, - privacy: { - usageStatisticsEnabled: false, - }, - model: { - name: 'gemini-2.5-pro', - }, - context: { - fileName: 'CONTEXT.md', - includeDirectories: ['/src'], - }, - tools: { - sandbox: true, - exclude: ['toolA'], - }, - mcp: { - allowed: ['server1'], - }, - security: { - folderTrust: { - enabled: true, - }, - }, - advanced: { - dnsResolutionOrder: 'ipv4first', - excludedEnvVars: ['SECRET'], - }, - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - unrecognizedTopLevel: { - value: 'should be preserved', - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - preferredEditor: 'code', - vimMode: true, - theme: 'dark', - usageStatisticsEnabled: false, - model: 'gemini-2.5-pro', - contextFileName: 'CONTEXT.md', - includeDirectories: ['/src'], - sandbox: true, - excludeTools: ['toolA'], - allowMCPServers: ['server1'], - folderTrust: true, - dnsResolutionOrder: 'ipv4first', - excludedProjectEnvVars: ['SECRET'], - mcpServers: { - 'my-server': { - command: 'npm start', - }, - }, - unrecognizedTopLevel: { - value: 'should be preserved', - }, - }); - }); - - it('should handle partial v2 settings', () => { - const v2Settings: TestSettings = { - general: { - vimMode: false, - }, - ui: {}, - model: { - name: 'gemini-2.5-pro', - }, - unrecognized: 'value', - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: false, - model: 'gemini-2.5-pro', - unrecognized: 'value', - }); - }); - - it('should handle settings with different data types', () => { - const v2Settings: TestSettings = { - general: { - vimMode: false, - }, - model: { - maxSessionTurns: -1, - }, - context: { - includeDirectories: [], - }, - security: { - folderTrust: { - enabled: undefined, - }, - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: false, - maxSessionTurns: -1, - includeDirectories: [], - security: { - folderTrust: { - enabled: undefined, - }, - }, - }); - }); - - it('should preserve unrecognized top-level keys', () => { - const v2Settings: TestSettings = { - general: { - vimMode: true, - }, - customTopLevel: { - a: 1, - b: [2], - }, - anotherOne: 'hello', - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - vimMode: true, - customTopLevel: { - a: 1, - b: [2], - }, - anotherOne: 'hello', - }); - }); - - it('should handle an empty v2 settings object', () => { - const v2Settings = {}; - const v1Settings = migrateSettingsToV1(v2Settings); - expect(v1Settings).toEqual({}); - }); - - it('should correctly handle mcpServers at the top level', () => { - const v2Settings: TestSettings = { - mcpServers: { - serverA: { command: 'a' }, - }, - mcp: { - allowed: ['serverA'], - }, - }; - - const v1Settings = migrateSettingsToV1(v2Settings); - - expect(v1Settings).toEqual({ - mcpServers: { - serverA: { command: 'a' }, - }, - allowMCPServers: ['serverA'], - }); - }); - - it('should correctly migrate customWittyPhrases', () => { - const v2Settings: Partial = { - ui: { - customWittyPhrases: ['test phrase'], - }, - }; - const v1Settings = migrateSettingsToV1(v2Settings as Settings); - expect(v1Settings).toEqual({ - customWittyPhrases: ['test phrase'], - }); - }); - }); - describe('loadEnvironment', () => { function setup({ isFolderTrustEnabled = true, @@ -2188,73 +1721,6 @@ describe('Settings Loading and Merging', () => { }); }); - describe('needsMigration', () => { - it('should return false for an empty object', () => { - expect(needsMigration({})).toBe(false); - }); - - it('should return false for settings that are already in V2 format', () => { - const v2Settings: Partial = { - ui: { - theme: 'dark', - }, - tools: { - sandbox: true, - }, - }; - expect(needsMigration(v2Settings)).toBe(false); - }); - - it('should return true for settings with a V1 key that needs to be moved', () => { - const v1Settings = { - theme: 'dark', // v1 key - }; - expect(needsMigration(v1Settings)).toBe(true); - }); - - it('should return true for settings with a mix of V1 and V2 keys', () => { - const mixedSettings = { - theme: 'dark', // v1 key - tools: { - sandbox: true, // v2 key - }, - }; - expect(needsMigration(mixedSettings)).toBe(true); - }); - - it('should return false for settings with only V1 keys that are the same in V2', () => { - const v1Settings = { - mcpServers: {}, - telemetry: {}, - extensions: [], - }; - expect(needsMigration(v1Settings)).toBe(false); - }); - - it('should return true for settings with a mix of V1 keys that are the same in V2 and V1 keys that need moving', () => { - const v1Settings = { - mcpServers: {}, // same in v2 - theme: 'dark', // needs moving - }; - expect(needsMigration(v1Settings)).toBe(true); - }); - - it('should return false for settings with unrecognized keys', () => { - const settings = { - someUnrecognizedKey: 'value', - }; - expect(needsMigration(settings)).toBe(false); - }); - - it('should return false for settings with v2 keys and unrecognized keys', () => { - const settings = { - ui: { theme: 'dark' }, - someUnrecognizedKey: 'value', - }; - expect(needsMigration(settings)).toBe(false); - }); - }); - describe('migrateDeprecatedSettings', () => { let mockFsExistsSync: Mock; let mockFsReadFileSync: Mock; @@ -2275,79 +1741,6 @@ describe('Settings Loading and Merging', () => { vi.restoreAllMocks(); }); - it('should migrate disabled extensions from user and workspace settings', () => { - const userSettingsContent = { - extensions: { - disabled: ['user-ext-1', 'shared-ext'], - }, - }; - const workspaceSettingsContent = { - extensions: { - disabled: ['workspace-ext-1', 'shared-ext'], - }, - }; - - mockFsReadFileSync.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 loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); - const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); - const extensionManager = new ExtensionManager({ - settings: loadedSettings.merged, - workspaceDir: MOCK_WORKSPACE_DIR, - requestConsent: vi.fn(), - requestSetting: vi.fn(), - }); - const mockDisableExtension = vi.spyOn( - extensionManager, - 'disableExtension', - ); - mockDisableExtension.mockImplementation(async () => {}); - - migrateDeprecatedSettings(loadedSettings, extensionManager); - - // Check user settings migration - expect(mockDisableExtension).toHaveBeenCalledWith( - 'user-ext-1', - SettingScope.User, - ); - expect(mockDisableExtension).toHaveBeenCalledWith( - 'shared-ext', - SettingScope.User, - ); - - // Check workspace settings migration - expect(mockDisableExtension).toHaveBeenCalledWith( - 'workspace-ext-1', - SettingScope.Workspace, - ); - expect(mockDisableExtension).toHaveBeenCalledWith( - 'shared-ext', - SettingScope.Workspace, - ); - - // Check that setValue was called to remove the deprecated setting - expect(setValueSpy).toHaveBeenCalledWith( - SettingScope.User, - 'extensions', - { - disabled: undefined, - }, - ); - expect(setValueSpy).toHaveBeenCalledWith( - SettingScope.Workspace, - 'extensions', - { - disabled: undefined, - }, - ); - }); - it('should not do anything if there are no deprecated settings', () => { const userSettingsContent = { extensions: { @@ -2358,33 +1751,317 @@ describe('Settings Loading and Merging', () => { someOtherSetting: 'value', }; - mockFsReadFileSync.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 loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); - const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); - const extensionManager = new ExtensionManager({ - settings: loadedSettings.merged, - workspaceDir: MOCK_WORKSPACE_DIR, - requestConsent: vi.fn(), - requestSetting: vi.fn(), - }); - const mockDisableExtension = vi.spyOn( - extensionManager, - 'disableExtension', + (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 '{}'; + }, ); - mockDisableExtension.mockImplementation(async () => {}); - migrateDeprecatedSettings(loadedSettings, extensionManager); + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + setValueSpy.mockClear(); + + migrateDeprecatedSettings(loadedSettings, true); - expect(mockDisableExtension).not.toHaveBeenCalled(); expect(setValueSpy).not.toHaveBeenCalled(); }); + + it('should migrate general.disableAutoUpdate to general.enableAutoUpdate with inverted value', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + // Should set new value to false (inverted from true) + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdate: false }), + ); + }); + + it('should migrate all 4 inverted boolean settings', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: false, + disableUpdateNag: true, + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + }, + }, + ui: { + accessibility: { + disableLoadingPhrases: true, + }, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue'); + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + migrateDeprecatedSettings(loadedSettings, true); + + // Check that general settings were migrated with inverted values + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdate: true }), + ); + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'general', + expect.objectContaining({ enableAutoUpdateNotification: false }), + ); + + // Check context.fileFiltering was migrated + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'context', + expect.objectContaining({ + fileFiltering: expect.objectContaining({ enableFuzzySearch: true }), + }), + ); + + // Check ui.accessibility was migrated + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui', + expect.objectContaining({ + accessibility: expect.objectContaining({ + enableLoadingPhrases: false, + }), + }), + ); + }); + + it('should prioritize new settings over deprecated ones and respect removeDeprecated flag', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + enableAutoUpdate: true, // Trust this (true) over disableAutoUpdate (true -> false) + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + enableFuzzySearch: false, // Trust this (false) over disableFuzzySearch (false -> true) + }, + }, + }; + + const loadedSettings = new LoadedSettings( + { + path: getSystemSettingsPath(), + settings: {}, + originalSettings: {}, + }, + { + path: getSystemDefaultsPath(), + settings: {}, + originalSettings: {}, + }, + { + path: USER_SETTINGS_PATH, + settings: userSettingsContent as unknown as Settings, + originalSettings: userSettingsContent as unknown as Settings, + }, + { + path: MOCK_WORKSPACE_SETTINGS_PATH, + settings: {}, + originalSettings: {}, + }, + true, + ); + + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + // 1. removeDeprecated = false (default) + migrateDeprecatedSettings(loadedSettings); + + // Should still have old settings + expect( + loadedSettings.forScope(SettingScope.User).settings.general, + ).toHaveProperty('disableAutoUpdate'); + expect( + ( + loadedSettings.forScope(SettingScope.User).settings.context as { + fileFiltering: { disableFuzzySearch: boolean }; + } + ).fileFiltering, + ).toHaveProperty('disableFuzzySearch'); + + // 2. removeDeprecated = true + migrateDeprecatedSettings(loadedSettings, true); + + // Should remove disableAutoUpdate and trust enableAutoUpdate: true + expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'general', { + enableAutoUpdate: true, + }); + + // Should remove disableFuzzySearch and trust enableFuzzySearch: false + expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'context', { + fileFiltering: { enableFuzzySearch: false }, + }); + }); + + it('should trigger migration automatically during loadSettings', () => { + mockFsExistsSync.mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + general: { + disableAutoUpdate: true, + }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify it was migrated in the merged settings + expect(settings.merged.general?.enableAutoUpdate).toBe(false); + + // Verify it was saved back to disk (via setValue calling updateSettingsFilePreservingFormat) + expect(updateSettingsFilePreservingFormat).toHaveBeenCalledWith( + USER_SETTINGS_PATH, + expect.objectContaining({ + general: expect.objectContaining({ enableAutoUpdate: false }), + }), + ); + }); + + it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => { + const systemSettingsContent = { + general: { + disableUpdateNag: true, + }, + }; + const systemDefaultsContent = { + general: { + disableUpdateNag: false, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + if (p === getSystemDefaultsPath()) { + return JSON.stringify(systemDefaultsContent); + } + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify system settings were migrated + expect(settings.system.settings.general).toHaveProperty( + 'enableAutoUpdateNotification', + ); + expect( + (settings.system.settings.general as Record)[ + 'enableAutoUpdateNotification' + ], + ).toBe(false); + + // Verify system defaults settings were migrated + expect(settings.systemDefaults.settings.general).toHaveProperty( + 'enableAutoUpdateNotification', + ); + expect( + (settings.systemDefaults.settings.general as Record)[ + 'enableAutoUpdateNotification' + ], + ).toBe(true); + + // Merged should also reflect it (system overrides defaults, but both are migrated) + expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false); + }); + + it('should migrate experimental agent settings to agents overrides', () => { + const userSettingsContent = { + experimental: { + codebaseInvestigatorSettings: { + enabled: true, + maxNumTurns: 15, + maxTimeMinutes: 5, + thinkingBudget: 16384, + model: 'gemini-1.5-pro', + }, + cliHelpAgentSettings: { + enabled: false, + }, + }, + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify migration to agents.overrides + expect(settings.user.settings.agents?.overrides).toMatchObject({ + codebase_investigator: { + enabled: true, + runConfig: { + maxTurns: 15, + maxTimeMinutes: 5, + }, + modelConfig: { + model: 'gemini-1.5-pro', + generateContentConfig: { + thinkingConfig: { + thinkingBudget: 16384, + }, + }, + }, + }, + cli_help: { + enabled: false, + }, + }); + }); }); describe('saveSettings', () => { @@ -2444,4 +2121,320 @@ describe('Settings Loading and Merging', () => { ); }); }); + + describe('LoadedSettings and remote admin settings', () => { + it('should prioritize remote admin settings over file-based admin settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + // These should be ignored + secureModeEnabled: true, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + // A non-admin setting to ensure it's still processed + ui: { theme: 'system-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + + // 1. Verify that on initial load, file-based admin settings are ignored + // and schema defaults are used instead. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); // default: false + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // default: true + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); // non-admin setting should be loaded + + // 2. Now, set remote admin settings. + loadedSettings.setRemoteAdminSettings({ + strictModeDisabled: false, + mcpSetting: { mcpEnabled: false }, + cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } }, + }); + + // 3. Verify that remote admin settings take precedence. + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); + // non-admin setting should remain unchanged + expect(loadedSettings.merged.ui?.theme).toBe('system-theme'); + }); + + it('should set remote admin settings and recompute merged settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + admin: { + secureModeEnabled: false, + mcp: { enabled: false }, + extensions: { enabled: false }, + }, + ui: { theme: 'initial-theme' }, + }; + + (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); + expect(loadedSettings.merged.ui?.theme).toBe('initial-theme'); + + const newRemoteSettings = { + strictModeDisabled: false, + mcpSetting: { mcpEnabled: false }, + cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } }, + }; + + loadedSettings.setRemoteAdminSettings(newRemoteSettings); + + // Verify that remote admin settings are applied + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); + 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', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + ui: { theme: 'initial-theme' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) { + return JSON.stringify(systemSettingsContent); + } + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + // Should have default admin settings + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + + loadedSettings.setRemoteAdminSettings({}); // Set empty remote settings + + // Admin settings should revert to defaults because there are no remote overrides + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + 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({ + cliFeatureSetting: { + unmanagedCapabilitiesEnabled: true, + }, + }); + expect(loadedSettings.merged.admin.skills?.enabled).toBe(true); + + loadedSettings.setRemoteAdminSettings({ + cliFeatureSetting: { + unmanagedCapabilitiesEnabled: false, + }, + }); + 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); + + loadedSettings.setRemoteAdminSettings({}); + + // Should default to schema defaults (standard defaults) + expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false); + expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); + expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); + }); + }); + + describe('getDefaultsFromSchema', () => { + it('should extract defaults from a schema', () => { + const mockSchema = { + prop1: { + type: 'string', + default: 'default1', + label: 'Prop 1', + category: 'General', + requiresRestart: false, + }, + nested: { + type: 'object', + label: 'Nested', + category: 'General', + requiresRestart: false, + default: {}, + properties: { + prop2: { + type: 'number', + default: 42, + label: 'Prop 2', + category: 'General', + requiresRestart: false, + }, + }, + }, + }; + + const defaults = getDefaultsFromSchema(mockSchema as SettingsSchema); + expect(defaults).toEqual({ + prop1: 'default1', + nested: { + prop2: 42, + }, + }); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 31d5f70733..b2544650d3 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -6,16 +6,17 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir, platform } from 'node:os'; +import { platform } from 'node:os'; import * as dotenv from 'dotenv'; import process from 'node:process'; import { - debugLogger, FatalConfigError, GEMINI_DIR, getErrorMessage, Storage, coreEvents, + homedir, + type FetchAdminControlsResponse, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -23,21 +24,31 @@ import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, + type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; + +export { + type Settings, + type MergedSettings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +}; + import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; +import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; -import type { ExtensionManager } from './extension-manager.js'; import { validateSettings, formatValidationError, } from './settings-validation.js'; -import { SettingPaths } from './settingPaths.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; @@ -60,84 +71,10 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { return current?.mergeStrategy; } -export type { Settings, MemoryImportFormat }; - 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 MIGRATE_V2_OVERWRITE = true; - -const MIGRATION_MAP: Record = { - accessibility: 'ui.accessibility', - allowedTools: 'tools.allowed', - allowMCPServers: 'mcp.allowed', - autoAccept: 'tools.autoAccept', - autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory', - bugCommand: 'advanced.bugCommand', - chatCompression: 'model.compressionThreshold', - checkpointing: 'general.checkpointing', - coreTools: 'tools.core', - contextFileName: 'context.fileName', - customThemes: 'ui.customThemes', - customWittyPhrases: 'ui.customWittyPhrases', - debugKeystrokeLogging: 'general.debugKeystrokeLogging', - disableAutoUpdate: 'general.disableAutoUpdate', - disableUpdateNag: 'general.disableUpdateNag', - dnsResolutionOrder: 'advanced.dnsResolutionOrder', - enableHooks: 'tools.enableHooks', - enablePromptCompletion: 'general.enablePromptCompletion', - enforcedAuthType: 'security.auth.enforcedType', - excludeTools: 'tools.exclude', - excludeMCPServers: 'mcp.excluded', - excludedProjectEnvVars: 'advanced.excludedEnvVars', - extensionManagement: 'experimental.extensionManagement', - extensions: 'extensions', - fileFiltering: 'context.fileFiltering', - folderTrustFeature: 'security.folderTrust.featureEnabled', - folderTrust: 'security.folderTrust.enabled', - hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge', - hideWindowTitle: 'ui.hideWindowTitle', - showStatusInTitle: 'ui.showStatusInTitle', - hideTips: 'ui.hideTips', - hideBanner: 'ui.hideBanner', - hideFooter: 'ui.hideFooter', - hideCWD: 'ui.footer.hideCWD', - hideSandboxStatus: 'ui.footer.hideSandboxStatus', - hideModelInfo: 'ui.footer.hideModelInfo', - hideContextSummary: 'ui.hideContextSummary', - showMemoryUsage: 'ui.showMemoryUsage', - showLineNumbers: 'ui.showLineNumbers', - showCitations: 'ui.showCitations', - ideMode: 'ide.enabled', - includeDirectories: 'context.includeDirectories', - loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories', - maxSessionTurns: 'model.maxSessionTurns', - mcpServers: 'mcpServers', - mcpServerCommand: 'mcp.serverCommand', - memoryImportFormat: 'context.importFormat', - memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs', - model: 'model.name', - preferredEditor: SettingPaths.General.PreferredEditor, - retryFetchErrors: 'general.retryFetchErrors', - sandbox: 'tools.sandbox', - selectedAuthType: 'security.auth.selectedType', - enableInteractiveShell: 'tools.shell.enableInteractiveShell', - shellPager: 'tools.shell.pager', - shellShowColor: 'tools.shell.showColor', - shellInactivityTimeout: 'tools.shell.inactivityTimeout', - skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', - summarizeToolOutput: 'model.summarizeToolOutput', - telemetry: 'telemetry', - theme: 'ui.theme', - toolDiscoveryCommand: 'tools.discoveryCommand', - toolCallCommand: 'tools.callCommand', - usageStatisticsEnabled: 'privacy.usageStatisticsEnabled', - useExternalAuth: 'security.auth.useExternal', - useRipgrep: 'tools.useRipgrep', - vimMode: 'general.vimMode', -}; - export function getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; @@ -211,7 +148,7 @@ export interface SummarizeToolOutputSettings { } export interface AccessibilitySettings { - disableLoadingPhrases?: boolean; + enableLoadingPhrases?: boolean; screenReader?: boolean; } @@ -267,185 +204,63 @@ function setNestedProperty( current[lastKey] = value; } -export function needsMigration(settings: Record): boolean { - // A file needs migration if it contains any top-level key that is moved to a - // nested location in V2. - const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { - if (v1Key === v2Path || !(v1Key in settings)) { - return false; +export function getDefaultsFromSchema( + schema: SettingsSchema = getSettingsSchema(), +): Settings { + const defaults: Record = {}; + for (const key in schema) { + const definition = schema[key]; + if (definition.properties) { + defaults[key] = getDefaultsFromSchema(definition.properties); + } else if (definition.default !== undefined) { + defaults[key] = definition.default; } - // If a key exists that is a V1 key and a V2 container (like 'model'), - // we need to check the type. If it's an object, it's a V2 container and not - // a V1 key that needs migration. - if ( - KNOWN_V2_CONTAINERS.has(v1Key) && - typeof settings[v1Key] === 'object' && - settings[v1Key] !== null - ) { - return false; - } - return true; - }); - - return hasV1Keys; + } + return defaults as Settings; } -function migrateSettingsToV2( - flatSettings: Record, -): Record | null { - if (!needsMigration(flatSettings)) { - return null; - } - - const v2Settings: Record = {}; - const flatKeys = new Set(Object.keys(flatSettings)); - - for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { - if (flatKeys.has(oldKey)) { - // If the key exists and is a V2 container (like 'model'), and the value is an object, - // it is likely already migrated or partially migrated. We should not move it - // to the mapped V2 path (e.g. 'model' -> 'model.name'). - // Instead, let it fall through to the "Carry over" section to be merged. - if ( - KNOWN_V2_CONTAINERS.has(oldKey) && - typeof flatSettings[oldKey] === 'object' && - flatSettings[oldKey] !== null && - !Array.isArray(flatSettings[oldKey]) - ) { - continue; - } - - setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); - flatKeys.delete(oldKey); - } - } - - // Preserve mcpServers at the top level - if (flatSettings['mcpServers']) { - v2Settings['mcpServers'] = flatSettings['mcpServers']; - flatKeys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of flatKeys) { - const existingValue = v2Settings[remainingKey]; - const newValue = flatSettings[remainingKey]; - - if ( - typeof existingValue === 'object' && - existingValue !== null && - !Array.isArray(existingValue) && - typeof newValue === 'object' && - newValue !== null && - !Array.isArray(newValue) - ) { - const pathAwareGetStrategy = (path: string[]) => - getMergeStrategyForPath([remainingKey, ...path]); - v2Settings[remainingKey] = customDeepMerge( - pathAwareGetStrategy, - {}, - existingValue as MergeableObject, - newValue as MergeableObject, - ); - } else { - v2Settings[remainingKey] = newValue; - } - } - - return v2Settings; -} - -function getNestedProperty( - obj: Record, - path: string, -): unknown { - const keys = path.split('.'); - let current: unknown = obj; - for (const key of keys) { - if (typeof current !== 'object' || current === null || !(key in current)) { - return undefined; - } - current = (current as Record)[key]; - } - return current; -} - -const REVERSE_MIGRATION_MAP: Record = Object.fromEntries( - Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]), -); - -// Dynamically determine the top-level keys from the V2 settings structure. -const KNOWN_V2_CONTAINERS = new Set( - Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), -); - -export function migrateSettingsToV1( - v2Settings: Record, -): Record { - const v1Settings: Record = {}; - const v2Keys = new Set(Object.keys(v2Settings)); - - for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) { - const value = getNestedProperty(v2Settings, newPath); - if (value !== undefined) { - v1Settings[oldKey] = value; - v2Keys.delete(newPath.split('.')[0]); - } - } - - // Preserve mcpServers at the top level - if (v2Settings['mcpServers']) { - v1Settings['mcpServers'] = v2Settings['mcpServers']; - v2Keys.delete('mcpServers'); - } - - // Carry over any unrecognized keys - for (const remainingKey of v2Keys) { - const value = v2Settings[remainingKey]; - if (value === undefined) { - continue; - } - - // Don't carry over empty objects that were just containers for migrated settings. - if ( - KNOWN_V2_CONTAINERS.has(remainingKey) && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.keys(value).length === 0 - ) { - continue; - } - - v1Settings[remainingKey] = value; - } - - return v1Settings; -} - -function mergeSettings( +export function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, -): Settings { +): MergedSettings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); + const schemaDefaults = getDefaultsFromSchema(); // Settings are merged with the following precedence (last one wins for // single values): - // 1. System Defaults - // 2. User Settings - // 3. Workspace Settings - // 4. System Settings (as overrides) + // 1. Schema Defaults (Built-in) + // 2. System Defaults + // 3. User Settings + // 4. Workspace Settings + // 5. System Settings (as overrides) return customDeepMerge( getMergeStrategyForPath, - {}, // Start with an empty object + schemaDefaults, systemDefaults, user, safeWorkspace, system, - ) as Settings; + ) as MergedSettings; +} + +/** + * Creates a fully populated MergedSettings object for testing purposes. + * It merges the provided overrides with the default settings from the schema. + * + * @param overrides Partial settings to override the defaults. + * @returns A complete MergedSettings object. + */ +export function createTestMergedSettings( + overrides: Partial = {}, +): MergedSettings { + return customDeepMerge( + getMergeStrategyForPath, + getDefaultsFromSchema(), + overrides, + ) as MergedSettings; } export class LoadedSettings { @@ -455,7 +270,6 @@ export class LoadedSettings { user: SettingsFile, workspace: SettingsFile, isTrusted: boolean, - migratedInMemoryScopes: Set, errors: SettingsError[] = [], ) { this.system = system; @@ -463,7 +277,6 @@ export class LoadedSettings { this.user = user; this.workspace = workspace; this.isTrusted = isTrusted; - this.migratedInMemoryScopes = migratedInMemoryScopes; this.errors = errors; this._merged = this.computeMergedSettings(); } @@ -473,23 +286,40 @@ export class LoadedSettings { readonly user: SettingsFile; readonly workspace: SettingsFile; readonly isTrusted: boolean; - readonly migratedInMemoryScopes: Set; readonly errors: SettingsError[]; - private _merged: Settings; + private _merged: MergedSettings; + private _remoteAdminSettings: Partial | undefined; - get merged(): Settings { + get merged(): MergedSettings { return this._merged; } - private computeMergedSettings(): Settings { - return mergeSettings( + private computeMergedSettings(): MergedSettings { + const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); + + // Remote admin settings always take precedence and file-based admin settings + // are ignored. + const adminSettingSchema = getSettingsSchema().admin; + if (adminSettingSchema?.properties) { + const adminSchema = adminSettingSchema.properties; + const adminDefaults = getDefaultsFromSchema(adminSchema); + + // The final admin settings are the defaults overridden by remote settings. + // Any admin settings from files are ignored. + merged.admin = customDeepMerge( + (path: string[]) => getMergeStrategyForPath(['admin', ...path]), + adminDefaults, + this._remoteAdminSettings?.admin ?? {}, + ) as MergedSettings['admin']; + } + return merged; } forScope(scope: LoadableSettingScope): SettingsFile { @@ -513,6 +343,41 @@ export class LoadedSettings { setNestedProperty(settingsFile.originalSettings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); + coreEvents.emitSettingsChanged(); + } + + setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void { + const admin: Settings['admin'] = {}; + const { + secureModeEnabled, + strictModeDisabled, + mcpSetting, + cliFeatureSetting, + } = remoteSettings; + + if (Object.keys(remoteSettings).length === 0) { + this._remoteAdminSettings = { admin }; + this._merged = this.computeMergedSettings(); + 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.extensions = { + enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false, + }; + admin.skills = { + enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled ?? false, + }; + + this._remoteAdminSettings = { admin }; + this._merged = this.computeMergedSettings(); } } @@ -623,7 +488,6 @@ export function loadSettings( const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); - const migratedInMemoryScopes = new Set(); // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); @@ -644,10 +508,7 @@ export function loadSettings( workspaceDir, ).getWorkspaceSettingsPath(); - const loadAndMigrate = ( - filePath: string, - scope: SettingScope, - ): { settings: Settings; rawJson?: string } => { + const load = (filePath: string): { settings: Settings; rawJson?: string } => { try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); @@ -666,33 +527,9 @@ export function loadSettings( return { settings: {} }; } - let settingsObject = rawSettings as Record; - if (needsMigration(settingsObject)) { - const migratedSettings = migrateSettingsToV2(settingsObject); - if (migratedSettings) { - if (MIGRATE_V2_OVERWRITE) { - try { - fs.renameSync(filePath, `${filePath}.orig`); - fs.writeFileSync( - filePath, - JSON.stringify(migratedSettings, null, 2), - 'utf-8', - ); - } catch (e) { - coreEvents.emitFeedback( - 'error', - 'Failed to migrate settings file.', - e, - ); - } - } else { - migratedInMemoryScopes.add(scope); - } - settingsObject = migratedSettings; - } - } + const settingsObject = rawSettings as Record; - // Validate settings structure with Zod after migration + // Validate settings structure with Zod const validationResult = validateSettings(settingsObject); if (!validationResult.success && validationResult.error) { const errorMessage = formatValidationError( @@ -718,22 +555,16 @@ export function loadSettings( return { settings: {} }; }; - const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System); - const systemDefaultsResult = loadAndMigrate( - systemDefaultsPath, - SettingScope.SystemDefaults, - ); - const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User); + const systemResult = load(systemSettingsPath); + const systemDefaultsResult = load(systemDefaultsPath); + const userResult = load(USER_SETTINGS_PATH); let workspaceResult: { settings: Settings; rawJson?: string } = { settings: {} as Settings, rawJson: undefined, }; if (realWorkspaceDir !== realHomeDir) { - workspaceResult = loadAndMigrate( - workspaceSettingsPath, - SettingScope.Workspace, - ); + workspaceResult = load(workspaceSettingsPath); } const systemOriginalSettings = structuredClone(systemResult.settings); @@ -795,7 +626,7 @@ export function loadSettings( ); } - return new LoadedSettings( + const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, @@ -821,35 +652,183 @@ export function loadSettings( rawJson: workspaceResult.rawJson, }, isTrusted, - migratedInMemoryScopes, settingsErrors, ); + + // Automatically migrate deprecated settings when loading. + migrateDeprecatedSettings(loadedSettings); + + return loadedSettings; } +/** + * Migrates deprecated settings to their new counterparts. + * + * TODO: After a couple of weeks (around early Feb 2026), we should start removing + * the deprecated settings from the settings files by default. + * + * @returns true if any changes were made and need to be saved. + */ export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, - extensionManager: ExtensionManager, -): void { + removeDeprecated = false, +): boolean { + let anyModified = false; const processScope = (scope: LoadableSettingScope) => { const settings = loadedSettings.forScope(scope).settings; - if (settings.extensions?.disabled) { - debugLogger.log( - `Migrating deprecated extensions.disabled settings from ${scope} settings...`, - ); - for (const extension of settings.extensions.disabled ?? []) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - extensionManager.disableExtension(extension, scope); + + // Migrate inverted boolean settings (disableX -> enableX) + // These settings were renamed and their boolean logic inverted + const generalSettings = settings.general as + | Record + | undefined; + const uiSettings = settings.ui as Record | undefined; + const contextSettings = settings.context as + | Record + | undefined; + + // Migrate general settings (disableAutoUpdate, disableUpdateNag) + if (generalSettings) { + const newGeneral: Record = { ...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; + } } - const newExtensionsValue = { ...settings.extensions }; - newExtensionsValue.disabled = undefined; + 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; + } + } - loadedSettings.setValue(scope, 'extensions', newExtensionsValue); + if (modified) { + loadedSettings.setValue(scope, 'general', newGeneral); + anyModified = true; + } } + + // Migrate ui settings + if (uiSettings) { + const newUi: Record = { ...uiSettings }; + let modified = false; + + // Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases + const accessibilitySettings = newUi['accessibility'] as + | Record + | undefined; + if ( + accessibilitySettings && + typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean' + ) { + const newAccessibility: Record = { + ...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; + } + } + + // Migrate context settings + if (contextSettings) { + const newContext: Record = { ...contextSettings }; + let modified = false; + + // Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch + const fileFilteringSettings = newContext['fileFiltering'] as + | Record + | undefined; + if ( + fileFilteringSettings && + typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean' + ) { + const newFileFiltering: Record = { + ...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; + } + } + + // Migrate experimental agent settings + anyModified ||= migrateExperimentalSettings( + settings, + loadedSettings, + scope, + removeDeprecated, + ); }; processScope(SettingScope.User); processScope(SettingScope.Workspace); + processScope(SettingScope.System); + processScope(SettingScope.SystemDefaults); + + return anyModified; } export function saveSettings(settingsFile: SettingsFile): void { @@ -860,12 +839,7 @@ export function saveSettings(settingsFile: SettingsFile): void { fs.mkdirSync(dirPath, { recursive: true }); } - let settingsToSave = settingsFile.originalSettings; - if (!MIGRATE_V2_OVERWRITE) { - settingsToSave = migrateSettingsToV1( - settingsToSave as Record, - ) as Settings; - } + const settingsToSave = settingsFile.originalSettings; // Use the format-preserving update function updateSettingsFilePreservingFormat( @@ -895,3 +869,100 @@ export function saveModelChange( ); } } + +function migrateExperimentalSettings( + settings: Settings, + loadedSettings: LoadedSettings, + scope: LoadableSettingScope, + removeDeprecated: boolean, +): boolean { + const experimentalSettings = settings.experimental as + | Record + | undefined; + if (experimentalSettings) { + const agentsSettings = { + ...(settings.agents as Record | undefined), + }; + const agentsOverrides = { + ...((agentsSettings['overrides'] as Record) || {}), + }; + let modified = false; + + // Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator + if (experimentalSettings['codebaseInvestigatorSettings']) { + const old = experimentalSettings[ + 'codebaseInvestigatorSettings' + ] as Record; + const override = { + ...(agentsOverrides['codebase_investigator'] as + | Record + | undefined), + }; + + if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; + + const runConfig = { + ...(override['runConfig'] as Record | undefined), + }; + if (old['maxNumTurns'] !== undefined) + runConfig['maxTurns'] = old['maxNumTurns']; + if (old['maxTimeMinutes'] !== undefined) + runConfig['maxTimeMinutes'] = old['maxTimeMinutes']; + if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig; + + if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) { + const modelConfig = { + ...(override['modelConfig'] as Record | undefined), + }; + if (old['model'] !== undefined) modelConfig['model'] = old['model']; + if (old['thinkingBudget'] !== undefined) { + const generateContentConfig = { + ...(modelConfig['generateContentConfig'] as + | Record + | undefined), + }; + const thinkingConfig = { + ...(generateContentConfig['thinkingConfig'] as + | Record + | undefined), + }; + thinkingConfig['thinkingBudget'] = old['thinkingBudget']; + generateContentConfig['thinkingConfig'] = thinkingConfig; + modelConfig['generateContentConfig'] = generateContentConfig; + } + override['modelConfig'] = modelConfig; + } + + agentsOverrides['codebase_investigator'] = override; + modified = true; + } + + // Migrate cliHelpAgentSettings -> agents.overrides.cli_help + if (experimentalSettings['cliHelpAgentSettings']) { + const old = experimentalSettings['cliHelpAgentSettings'] as Record< + string, + unknown + >; + const override = { + ...(agentsOverrides['cli_help'] as Record | undefined), + }; + if (old['enabled'] !== undefined) override['enabled'] = old['enabled']; + agentsOverrides['cli_help'] = override; + modified = true; + } + + if (modified) { + agentsSettings['overrides'] = agentsOverrides; + loadedSettings.setValue(scope, 'agents', agentsSettings); + + if (removeDeprecated) { + const newExperimental = { ...experimentalSettings }; + delete newExperimental['codebaseInvestigatorSettings']; + delete newExperimental['cliHelpAgentSettings']; + loadedSettings.setValue(scope, 'experimental', newExperimental); + } + return true; + } + } + return false; +} diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 81f57feefe..6e55082edb 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -79,7 +79,7 @@ describe('SettingsSchema', () => { ).toBeDefined(); expect( getSettingsSchema().ui?.properties?.accessibility.properties - ?.disableLoadingPhrases.type, + ?.enableLoadingPhrases.type, ).toBe('boolean'); }); @@ -107,6 +107,14 @@ describe('SettingsSchema', () => { getSettingsSchema().context.properties.fileFiltering.properties ?.enableRecursiveFileSearch, ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths, + ).toBeDefined(); + expect( + getSettingsSchema().context.properties.fileFiltering.properties + ?.customIgnoreFilePaths.type, + ).toBe('array'); }); it('should have unique categories', () => { @@ -170,7 +178,7 @@ describe('SettingsSchema', () => { true, ); expect( - getSettingsSchema().general.properties.disableAutoUpdate.showInDialog, + getSettingsSchema().general.properties.enableAutoUpdate.showInDialog, ).toBe(true); expect( getSettingsSchema().ui.properties.hideWindowTitle.showInDialog, @@ -357,6 +365,53 @@ describe('SettingsSchema', () => { ); }); + it('should have skills setting enabled by default', () => { + const setting = getSettingsSchema().skills.properties.enabled; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Advanced'); + expect(setting.default).toBe(true); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(true); + expect(setting.description).toBe('Enable Agent Skills.'); + }); + + it('should have plan setting in schema', () => { + const setting = getSettingsSchema().experimental.properties.plan; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Experimental'); + expect(setting.default).toBe(false); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(true); + expect(setting.description).toBe( + 'Enable planning features (Plan Mode and tools).', + ); + }); + + it('should have enableEventDrivenScheduler setting in schema', () => { + const setting = + getSettingsSchema().experimental.properties.enableEventDrivenScheduler; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Experimental'); + expect(setting.default).toBe(true); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(false); + expect(setting.description).toBe( + 'Enables event-driven scheduler within the CLI session.', + ); + }); + + it('should have hooksConfig.notifications setting in schema', () => { + const setting = getSettingsSchema().hooksConfig?.properties.notifications; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Advanced'); + expect(setting.default).toBe(true); + expect(setting.showInDialog).toBe(true); + }); + it('should have name and description in hook definitions', () => { const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray']; expect(hookDef).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 567053744b..300338f991 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -9,19 +9,17 @@ // to regenerate the settings reference in `docs/get-started/configuration.md`. // -------------------------------------------------------------------------- -import type { - MCPServerConfig, - BugCommandSettings, - TelemetrySettings, - AuthType, -} from '@google/gemini-cli-core'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, - GEMINI_MODEL_ALIAS_AUTO, + type MCPServerConfig, + type BugCommandSettings, + type TelemetrySettings, + type AuthType, + type AgentOverride, + type CustomTheme, } from '@google/gemini-cli-core'; -import type { CustomTheme } from '../ui/themes/theme.js'; import type { SessionRetentionSettings } from './settings.js'; import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; @@ -108,6 +106,7 @@ export interface SettingDefinition { key?: string; properties?: SettingsSchema; showInDialog?: boolean; + ignoreInDocs?: boolean; mergeStrategy?: MergeStrategy; /** Enum type options */ options?: readonly SettingEnumOption[]; @@ -190,22 +189,22 @@ const SETTINGS_SCHEMA = { description: 'Enable Vim keybindings', showInDialog: true, }, - disableAutoUpdate: { + enableAutoUpdate: { type: 'boolean', - label: 'Disable Auto Update', + label: 'Enable Auto Update', category: 'General', requiresRestart: false, - default: false, - description: 'Disable automatic updates', + default: true, + description: 'Enable automatic updates.', showInDialog: true, }, - disableUpdateNag: { + enableAutoUpdateNotification: { type: 'boolean', - label: 'Disable Update Nag', + label: 'Enable Auto Update Notification', category: 'General', requiresRestart: false, - default: false, - description: 'Disable update notification prompts.', + default: true, + description: 'Enable update notification prompts.', showInDialog: false, }, checkpointing: { @@ -323,7 +322,7 @@ const SETTINGS_SCHEMA = { category: 'General', requiresRestart: false, default: 'text', - description: 'The format of the CLI output.', + description: 'The format of the CLI output. Can be `text` or `json`.', showInDialog: true, options: [ { value: 'text', label: 'Text' }, @@ -386,12 +385,32 @@ const SETTINGS_SCHEMA = { }, showStatusInTitle: { type: 'boolean', - label: 'Show Status in Title', + label: 'Show Thoughts in Title', category: 'UI', requiresRestart: false, default: false, description: - 'Show Gemini CLI status and thoughts in the terminal window title', + 'Show Gemini CLI model thoughts in the terminal window title during the working phase', + showInDialog: true, + }, + dynamicWindowTitle: { + type: 'boolean', + label: 'Dynamic Window Title', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Update the terminal window title with current status icons (Ready: โ—‡, Action Required: โœ‹, Working: โœฆ)', + showInDialog: true, + }, + showHomeDirectoryWarning: { + type: 'boolean', + label: 'Show Home Directory Warning', + category: 'UI', + requiresRestart: true, + default: true, + description: + 'Show a warning when running Gemini CLI in the home directory.', showInDialog: true, }, hideTips: { @@ -515,13 +534,14 @@ const SETTINGS_SCHEMA = { description: 'Show the model name in the chat for each model turn.', showInDialog: true, }, - useFullWidth: { + showUserIdentity: { type: 'boolean', - label: 'Use Full Width', + label: 'Show User Identity', category: 'UI', requiresRestart: false, default: true, - description: 'Use the entire width of the terminal for output.', + description: + "Show the logged-in user's identity (e.g. email) in the UI.", showInDialog: true, }, useAlternateBuffer: { @@ -534,6 +554,15 @@ const SETTINGS_SCHEMA = { 'Use an alternate screen buffer for the UI, preserving shell history.', showInDialog: true, }, + useBackgroundColor: { + type: 'boolean', + label: 'Use Background Color', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Whether to use background colors in the UI.', + showInDialog: true, + }, incrementalRendering: { type: 'boolean', label: 'Incremental Rendering', @@ -544,6 +573,15 @@ const SETTINGS_SCHEMA = { 'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.', showInDialog: true, }, + showSpinner: { + type: 'boolean', + label: 'Show Spinner', + category: 'UI', + requiresRestart: false, + default: true, + description: 'Show the spinner during operations.', + showInDialog: true, + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -566,13 +604,13 @@ const SETTINGS_SCHEMA = { description: 'Accessibility settings.', showInDialog: false, properties: { - disableLoadingPhrases: { + enableLoadingPhrases: { type: 'boolean', - label: 'Disable Loading Phrases', + label: 'Enable Loading Phrases', category: 'UI', requiresRestart: true, - default: false, - description: 'Disable loading phrases for accessibility', + default: true, + description: 'Enable loading phrases during operations.', showInDialog: true, }, screenReader: { @@ -605,7 +643,7 @@ const SETTINGS_SCHEMA = { category: 'IDE', requiresRestart: true, default: false, - description: 'Enable IDE integration mode', + description: 'Enable IDE integration mode.', showInDialog: true, }, hasSeenNudge: { @@ -775,6 +813,32 @@ const SETTINGS_SCHEMA = { }, }, + agents: { + type: 'object', + label: 'Agents', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings for subagents.', + showInDialog: false, + properties: { + overrides: { + type: 'object', + label: 'Agent Overrides', + category: 'Advanced', + requiresRestart: true, + default: {} as Record, + description: + 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'AgentOverride', + }, + }, + }, + }, + context: { type: 'object', label: 'Context', @@ -854,7 +918,7 @@ const SETTINGS_SCHEMA = { category: 'Context', requiresRestart: true, default: true, - description: 'Respect .gitignore files when searching', + description: 'Respect .gitignore files when searching.', showInDialog: true, }, respectGeminiIgnore: { @@ -863,7 +927,7 @@ const SETTINGS_SCHEMA = { category: 'Context', requiresRestart: true, default: true, - description: 'Respect .geminiignore files when searching', + description: 'Respect .geminiignore files when searching.', showInDialog: true, }, enableRecursiveFileSearch: { @@ -877,15 +941,27 @@ const SETTINGS_SCHEMA = { `, showInDialog: true, }, - disableFuzzySearch: { + enableFuzzySearch: { type: 'boolean', - label: 'Disable Fuzzy Search', + label: 'Enable Fuzzy Search', category: 'Context', requiresRestart: true, - default: false, - description: 'Disable fuzzy search when searching for files.', + default: true, + description: 'Enable fuzzy search when searching for files.', showInDialog: true, }, + customIgnoreFilePaths: { + type: 'array', + label: 'Custom Ignore File Paths', + category: 'Context', + requiresRestart: true, + default: [] as string[], + description: + 'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.', + showInDialog: true, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, }, }, }, @@ -986,6 +1062,24 @@ const SETTINGS_SCHEMA = { `, showInDialog: true, }, + approvalMode: { + type: 'enum', + label: 'Approval Mode', + category: 'Tools', + requiresRestart: false, + default: 'default', + description: oneLine` + 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. + `, + showInDialog: true, + options: [ + { value: 'default', label: 'Default' }, + { value: 'auto_edit', label: 'Auto Edit' }, + { value: 'plan', label: 'Plan' }, + ], + }, core: { type: 'array', label: 'Core Tools', @@ -1083,15 +1177,17 @@ const SETTINGS_SCHEMA = { description: 'The number of lines to keep when truncating tool output.', showInDialog: true, }, - enableHooks: { + disableLLMCorrection: { type: 'boolean', - label: 'Enable Hooks System', - category: 'Advanced', + label: 'Disable LLM Correction', + category: 'Tools', requiresRestart: true, - default: false, - description: - 'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.', - showInDialog: false, + default: true, + description: oneLine` + Disable LLM-based error correction for edit tools. + When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. + `, + showInDialog: true, }, }, }, @@ -1182,6 +1278,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', @@ -1365,6 +1472,24 @@ const SETTINGS_SCHEMA = { description: 'Enable extension management features.', showInDialog: false, }, + extensionConfig: { + type: 'boolean', + label: 'Extension Configuration', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable requesting and fetching of extension settings.', + showInDialog: false, + }, + enableEventDrivenScheduler: { + type: 'boolean', + label: 'Event Driven Scheduler', + category: 'Experimental', + requiresRestart: true, + default: true, + description: 'Enables event-driven scheduler within the CLI session.', + showInDialog: false, + }, extensionReloading: { type: 'boolean', label: 'Extension Reloading', @@ -1384,94 +1509,24 @@ const SETTINGS_SCHEMA = { description: 'Enable Just-In-Time (JIT) context loading.', showInDialog: false, }, - skills: { + useOSC52Paste: { type: 'boolean', - label: 'Agent Skills', + label: 'Use OSC 52 Paste', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).', + showInDialog: true, + }, + plan: { + type: 'boolean', + label: 'Plan', category: 'Experimental', requiresRestart: true, default: false, - description: 'Enable Agent Skills (experimental).', - showInDialog: false, - }, - codebaseInvestigatorSettings: { - type: 'object', - label: 'Codebase Investigator Settings', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Configuration for Codebase Investigator.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Codebase Investigator', - category: 'Experimental', - requiresRestart: true, - default: true, - description: 'Enable the Codebase Investigator agent.', - showInDialog: true, - }, - maxNumTurns: { - type: 'number', - label: 'Codebase Investigator Max Num Turns', - category: 'Experimental', - requiresRestart: true, - default: 10, - description: - 'Maximum number of turns for the Codebase Investigator agent.', - showInDialog: true, - }, - maxTimeMinutes: { - type: 'number', - label: 'Max Time (Minutes)', - category: 'Experimental', - requiresRestart: true, - default: 3, - description: - 'Maximum time for the Codebase Investigator agent (in minutes).', - showInDialog: false, - }, - thinkingBudget: { - type: 'number', - label: 'Thinking Budget', - category: 'Experimental', - requiresRestart: true, - default: 8192, - description: - 'The thinking budget for the Codebase Investigator agent.', - showInDialog: false, - }, - model: { - type: 'string', - label: 'Model', - category: 'Experimental', - requiresRestart: true, - default: GEMINI_MODEL_ALIAS_AUTO, - description: - 'The model to use for the Codebase Investigator agent.', - showInDialog: false, - }, - }, - }, - introspectionAgentSettings: { - type: 'object', - label: 'Introspection Agent Settings', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Configuration for Introspection Agent.', - showInDialog: false, - properties: { - enabled: { - type: 'boolean', - label: 'Enable Introspection Agent', - category: 'Experimental', - requiresRestart: true, - default: false, - description: 'Enable the Introspection Agent.', - showInDialog: true, - }, - }, + description: 'Enable planning features (Plan Mode and tools).', + showInDialog: true, }, }, }, @@ -1518,8 +1573,17 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {}, description: 'Settings for agent skills.', - showInDialog: true, + showInDialog: false, properties: { + enabled: { + type: 'boolean', + label: 'Enable Agent Skills', + category: 'Advanced', + requiresRestart: true, + default: true, + description: 'Enable Agent Skills.', + showInDialog: true, + }, disabled: { type: 'array', label: 'Disabled Skills', @@ -1534,9 +1598,9 @@ const SETTINGS_SCHEMA = { }, }, - hooks: { + hooksConfig: { type: 'object', - label: 'Hooks', + label: 'HooksConfig', category: 'Advanced', requiresRestart: false, default: {}, @@ -1544,6 +1608,16 @@ const SETTINGS_SCHEMA = { 'Hook configurations for intercepting and customizing agent behavior.', showInDialog: false, properties: { + enabled: { + type: 'boolean', + label: 'Enable Hooks', + category: 'Advanced', + requiresRestart: true, + default: true, + description: + 'Canonical toggle for the hooks system. When disabled, no hooks will be executed.', + showInDialog: true, + }, disabled: { type: 'array', label: 'Disabled Hooks', @@ -1559,6 +1633,27 @@ const SETTINGS_SCHEMA = { }, mergeStrategy: MergeStrategy.UNION, }, + notifications: { + type: 'boolean', + label: 'Hook Notifications', + category: 'Advanced', + requiresRestart: false, + default: true, + description: 'Show visual indicators when hooks are executing.', + showInDialog: true, + }, + }, + }, + + hooks: { + type: 'object', + label: 'Hook Events', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Event-specific hook configurations.', + showInDialog: false, + properties: { BeforeTool: { type: 'array', label: 'Before Tool Hooks', @@ -1699,6 +1794,96 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.CONCAT, }, }, + + admin: { + type: 'object', + label: 'Admin', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Settings configured remotely by enterprise admins.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + secureModeEnabled: { + type: 'boolean', + label: 'Secure Mode Enabled', + category: 'Admin', + requiresRestart: false, + default: false, + description: 'If true, disallows yolo mode from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + extensions: { + type: 'object', + label: 'Extensions Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Extensions-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'Extensions Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: + 'If false, disallows extensions from being installed or used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + }, + }, + mcp: { + type: 'object', + label: 'MCP Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'MCP-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'MCP Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: 'If false, disallows MCP servers from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + }, + }, + skills: { + type: 'object', + label: 'Skills Settings', + category: 'Admin', + requiresRestart: false, + default: {}, + description: 'Agent Skills-specific admin settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + properties: { + enabled: { + type: 'boolean', + label: 'Skills Enabled', + category: 'Admin', + requiresRestart: false, + default: true, + description: 'If false, disallows agent skills from being used.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + }, + }, + }, + }, + }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; @@ -1883,6 +2068,36 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + AgentOverride: { + type: 'object', + description: 'Override settings for a specific agent.', + additionalProperties: false, + properties: { + modelConfig: { + type: 'object', + additionalProperties: true, + }, + runConfig: { + type: 'object', + description: 'Run configuration for an agent.', + additionalProperties: false, + properties: { + maxTimeMinutes: { + type: 'number', + description: 'The maximum execution time for the agent in minutes.', + }, + maxTurns: { + type: 'number', + description: 'The maximum number of conversational turns.', + }, + }, + }, + enabled: { + type: 'boolean', + description: 'Whether to enable the agent.', + }, + }, + }, CustomTheme: { type: 'object', description: @@ -2047,4 +2262,17 @@ type InferSettings = { : T[K]['default']; }; +type InferMergedSettings = { + -readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema } + ? InferMergedSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + export type Settings = InferSettings; +export type MergedSettings = InferMergedSettings; diff --git a/packages/cli/src/config/settings_repro.test.ts b/packages/cli/src/config/settings_repro.test.ts index 404554ddbd..de4cc9ad8e 100644 --- a/packages/cli/src/config/settings_repro.test.ts +++ b/packages/cli/src/config/settings_repro.test.ts @@ -155,8 +155,12 @@ describe('Settings Repro', () => { experimental: { useModelRouter: false, enableSubagents: false, - codebaseInvestigatorSettings: { - enabled: true, + }, + agents: { + overrides: { + codebase_investigator: { + enabled: true, + }, }, }, ui: { diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts index 67212bf0bc..498f803dd9 100644 --- a/packages/cli/src/config/settings_validation_warning.test.ts +++ b/packages/cli/src/config/settings_validation_warning.test.ts @@ -27,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, coreEvents: mockCoreEvents, + homedir: () => '/mock/home/user', Storage: class extends actual.Storage { static override getGlobalSettingsPath = () => '/mock/home/user/.gemini/settings.json'; @@ -52,11 +53,15 @@ vi.mock('./trustedFolders.js', () => ({ }, })); -vi.mock('os', () => ({ - homedir: () => '/mock/home/user', - platform: () => 'linux', - totalmem: () => 16 * 1024 * 1024 * 1024, -})); +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/home/user', + platform: () => 'linux', + totalmem: () => 16 * 1024 * 1024 * 1024, + }; +}); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 436e300957..9bd4cef9f6 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -36,6 +36,15 @@ vi.mock('os', async (importOriginal) => { platform: vi.fn(() => 'linux'), }; }); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: () => '/mock/home/user', + }; +}); vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); return { diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 9a894c76cb..3057a7d3ec 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -6,13 +6,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import { FatalConfigError, getErrorMessage, isWithinRoot, ideContextStore, GEMINI_DIR, + homedir, } from '@google/gemini-cli-core'; import type { Settings } from './settings.js'; import stripJsonComments from 'strip-json-comments'; diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index 366e5c9137..c844ee6f93 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -6,18 +6,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { performInitialAuth } from './auth.js'; -import { type Config } from '@google/gemini-cli-core'; +import { + type Config, + ValidationRequiredError, + AuthType, +} from '@google/gemini-cli-core'; -vi.mock('@google/gemini-cli-core', () => ({ - AuthType: { - OAUTH: 'oauth', - }, - getErrorMessage: (e: unknown) => (e as Error).message, -})); - -const AuthType = { - OAUTH: 'oauth', -} as const; +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getErrorMessage: (e: unknown) => (e as Error).message, + }; +}); describe('auth', () => { let mockConfig: Config; @@ -37,10 +39,12 @@ describe('auth', () => { it('should return null on successful auth', async () => { const result = await performInitialAuth( mockConfig, - AuthType.OAUTH as unknown as Parameters[1], + AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toBeNull(); - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); }); it('should return error message on failed auth', async () => { @@ -48,9 +52,25 @@ describe('auth', () => { vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error); const result = await performInitialAuth( mockConfig, - AuthType.OAUTH as unknown as Parameters[1], + AuthType.LOGIN_WITH_GOOGLE, ); expect(result).toBe('Failed to login. Message: Auth failed'); - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return null if refreshAuth throws ValidationRequiredError', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue( + new ValidationRequiredError('Validation required'), + ); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toBeNull(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); }); }); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index f4f4963bc7..7b1e8c8277 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -8,6 +8,7 @@ import { type AuthType, type Config, getErrorMessage, + ValidationRequiredError, } from '@google/gemini-cli-core'; /** @@ -29,6 +30,11 @@ export async function performInitialAuth( // The console.log is intentionally left out here. // We can add a dedicated startup message later if needed. } catch (e) { + if (e instanceof ValidationRequiredError) { + // Don't treat validation required as a fatal auth error during startup. + // This allows the React UI to load and show the ValidationDialog. + return null; + } return `Failed to login. Message: ${getErrorMessage(e)}`; } diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 61a4b00422..57f1c41551 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -127,7 +127,7 @@ describe('initializer', () => { }); it('should handle undefined auth type', async () => { - mockSettings.merged.security!.auth!.selectedType = undefined; + mockSettings.merged.security.auth.selectedType = undefined; const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 0ba76a989f..e99efd90f6 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -39,13 +39,13 @@ export async function initializeApp( const authHandle = startupProfiler.start('authenticate'); const authError = await performInitialAuth( config, - settings.merged.security?.auth?.selectedType, + settings.merged.security.auth.selectedType, ); authHandle?.end(); const themeError = validateTheme(settings); const shouldOpenAuthDialog = - settings.merged.security?.auth?.selectedType === undefined || !!authError; + settings.merged.security.auth.selectedType === undefined || !!authError; logCliConfiguration( config, diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index fb57d2cde3..eb87a9ee10 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -46,7 +46,7 @@ describe('theme', () => { }); it('should return null if theme is undefined', () => { - mockSettings.merged.ui!.theme = undefined; + mockSettings.merged.ui.theme = undefined; const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).not.toHaveBeenCalled(); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index ed2805a5ab..f0f58fdbba 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js'; * @returns An error message if the theme is not found, otherwise null. */ export function validateTheme(settings: LoadedSettings): string | null { - const effectiveTheme = settings.merged.ui?.theme; + const effectiveTheme = settings.merged.ui.theme; if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { return `Theme "${effectiveTheme}" not found.`; } diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts new file mode 100644 index 0000000000..8b9fb87f7a --- /dev/null +++ b/packages/cli/src/deferred.test.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + runDeferredCommand, + defer, + setDeferredCommand, + type DeferredCommand, +} from './deferred.js'; +import { ExitCodes } from '@google/gemini-cli-core'; +import type { ArgumentsCamelCase, CommandModule } from 'yargs'; +import type { MergedSettings } from './config/settings.js'; +import type { MockInstance } from 'vitest'; + +const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ + mockRunExitCleanup: vi.fn(), + mockCoreEvents: { + emitFeedback: vi.fn(), + }, +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + coreEvents: mockCoreEvents, + }; +}); + +vi.mock('./utils/cleanup.js', () => ({ + runExitCleanup: mockRunExitCleanup, +})); + +let mockExit: MockInstance; + +describe('deferred', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + 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(mockCoreEvents.emitFeedback).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should execute the deferred command if enabled', async () => { + const mockHandler = vi.fn(); + setDeferredCommand({ + handler: mockHandler, + argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({ mcp: { enabled: true } }); + await runDeferredCommand(settings); + expect(mockHandler).toHaveBeenCalled(); + expect(mockRunExitCleanup).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + + it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({ mcp: { enabled: false } }); + await runDeferredCommand(settings); + + 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); + }); + + it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'extensions', + }); + + const settings = createMockSettings({ extensions: { enabled: false } }); + await runDeferredCommand(settings); + + 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); + }); + + it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'skills', + }); + + const settings = createMockSettings({ skills: { enabled: false } }); + await runDeferredCommand(settings); + + 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); + }); + + it('should execute if admin settings are undefined (default implicit enable)', async () => { + const mockHandler = vi.fn(); + setDeferredCommand({ + handler: mockHandler, + argv: {} as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({}); // No admin settings + await runDeferredCommand(settings); + + expect(mockHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + }); + + describe('defer', () => { + it('should wrap a command module and defer execution', async () => { + const originalHandler = vi.fn(); + const commandModule: CommandModule = { + command: 'test', + describe: 'test command', + handler: originalHandler, + }; + + const deferredModule = defer(commandModule); + expect(deferredModule.command).toBe(commandModule.command); + + // Execute the wrapper handler + const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase; + await deferredModule.handler(argv); + + // Should check that it set the deferred command, but didn't run original handler yet + expect(originalHandler).not.toHaveBeenCalled(); + + // Now manually run it to verify it captured correctly + await runDeferredCommand(createMockSettings()); + expect(originalHandler).toHaveBeenCalledWith(argv); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + + it('should use parentCommandName if provided', async () => { + const commandModule: CommandModule = { + command: 'subcommand', + describe: 'sub command', + handler: vi.fn(), + }; + + const deferredModule = defer(commandModule, 'parent'); + await deferredModule.handler({} as ArgumentsCamelCase); + + const deferredMcp = defer(commandModule, 'mcp'); + await deferredMcp.handler({} as ArgumentsCamelCase); + + const mcpSettings = createMockSettings({ mcp: { enabled: false } }); + await runDeferredCommand(mcpSettings); + + 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', + ); + }); + + it('should fallback to unknown if no parentCommandName is provided', async () => { + const mockHandler = vi.fn(); + const commandModule: CommandModule = { + command: ['foo', 'infoo'], + describe: 'foo command', + handler: mockHandler, + }; + + const deferredModule = defer(commandModule); + await deferredModule.handler({} as ArgumentsCamelCase); + + // Verify it runs even if all known commands are disabled, + // 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 }, + }); + + await runDeferredCommand(settings); + + expect(mockHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + }); +}); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts new file mode 100644 index 0000000000..309233ba45 --- /dev/null +++ b/packages/cli/src/deferred.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ArgumentsCamelCase, CommandModule } from 'yargs'; +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'; + +export interface DeferredCommand { + handler: (argv: ArgumentsCamelCase) => void | Promise; + argv: ArgumentsCamelCase; + commandName: string; +} + +let deferredCommand: DeferredCommand | undefined; + +export function setDeferredCommand(command: DeferredCommand) { + deferredCommand = command; +} + +export async function runDeferredCommand(settings: MergedSettings) { + if (!deferredCommand) { + return; + } + + const adminSettings = settings.admin; + const commandName = deferredCommand.commandName; + + if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('MCP', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + if ( + commandName === 'extensions' && + adminSettings?.extensions?.enabled === false + ) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Extensions', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + if (commandName === 'skills' && adminSettings?.skills?.enabled === false) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Agent skills', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + await deferredCommand.handler(deferredCommand.argv); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); +} + +/** + * Wraps a command's handler to defer its execution. + * It stores the handler and arguments in a singleton `deferredCommand` variable. + */ +export function defer( + commandModule: CommandModule, + parentCommandName?: string, +): CommandModule { + return { + ...commandModule, + handler: (argv: ArgumentsCamelCase) => { + setDeferredCommand({ + handler: commandModule.handler as ( + argv: ArgumentsCamelCase, + ) => void | Promise, + argv: argv as unknown as ArgumentsCamelCase, + commandName: parentCommandName || 'unknown', + }); + }, + }; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f98cd7c3c9..a5c615444f 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -23,8 +23,30 @@ import { import os from 'node:os'; import v8 from 'node:v8'; import { type CliArgs } from './config/config.js'; -import { type LoadedSettings } from './config/settings.js'; +import { + type LoadedSettings, + type Settings, + createTestMergedSettings, +} from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; + +function createMockSettings( + overrides: Record = {}, +): LoadedSettings { + const merged = createTestMergedSettings( + (overrides['merged'] as Partial) || {}, + ); + + return { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + errors: [], + ...overrides, + merged, + } as unknown as LoadedSettings; +} import { type Config, type ResumedSessionData, @@ -108,26 +130,19 @@ class MockProcessExitError extends Error { } // Mock dependencies -vi.mock('./config/settings.js', () => ({ - loadSettings: vi.fn().mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - }), - migrateDeprecatedSettings: vi.fn(), - SettingScope: { - User: 'user', - Workspace: 'workspace', - System: 'system', - SystemDefaults: 'system-defaults', - }, -})); +vi.mock('./config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn().mockImplementation(() => ({ + merged: actual.getDefaultsFromSchema(), + workspace: { settings: {} }, + errors: [], + })), + saveModelChange: vi.fn(), + getDefaultsFromSchema: actual.getDefaultsFromSchema, + }; +}); vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { @@ -142,6 +157,9 @@ vi.mock('./config/config.js', () => ({ getQuestion: vi.fn(() => ''), isInteractive: () => false, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -187,10 +205,14 @@ vi.mock('./config/sandboxConfig.js', () => ({ vi.mock('./ui/utils/mouse.js', () => ({ enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), - parseMouseEvent: vi.fn(), isIncompleteMouseSequence: vi.fn(), })); +const runNonInteractiveSpy = vi.hoisted(() => vi.fn()); +vi.mock('./nonInteractiveCli.js', () => ({ + runNonInteractive: runNonInteractiveSpy, +})); + describe('gemini.tsx main function', () => { let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; @@ -230,90 +252,6 @@ describe('gemini.tsx main function', () => { vi.restoreAllMocks(); }); - it('verifies that we dont load the config before relaunchAppInChildProcess', async () => { - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - const { relaunchAppInChildProcess } = await import('./utils/relaunch.js'); - const { loadCliConfig } = await import('./config/config.js'); - const { loadSettings } = await import('./config/settings.js'); - const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); - vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); - - const callOrder: string[] = []; - vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => { - callOrder.push('relaunch'); - }); - vi.mocked(loadCliConfig).mockImplementation(async () => { - callOrder.push('loadCliConfig'); - return { - isInteractive: () => false, - getQuestion: () => '', - getSandbox: () => false, - getDebugMode: () => false, - getListExtensions: () => false, - getListSessions: () => false, - getDeleteSession: () => undefined, - getMcpServers: () => ({}), - getMcpClientManager: vi.fn(), - initialize: vi.fn(), - getIdeMode: () => false, - getExperimentalZedIntegration: () => false, - getScreenReader: () => false, - getGeminiMdFileCount: () => 0, - getProjectRoot: () => '/', - getPolicyEngine: vi.fn(), - getMessageBus: () => ({ - subscribe: vi.fn(), - }), - getEnableHooks: () => false, - getToolRegistry: vi.fn(), - getContentGeneratorConfig: vi.fn(), - getModel: () => 'gemini-pro', - getEmbeddingModel: () => 'embedding-001', - getApprovalMode: () => 'default', - getCoreTools: () => [], - getTelemetryEnabled: () => false, - getTelemetryLogPromptsEnabled: () => false, - getFileFilteringRespectGitIgnore: () => true, - getOutputFormat: () => 'text', - getExtensions: () => [], - getUsageStatisticsEnabled: () => false, - refreshAuth: vi.fn(), - setTerminalBackground: vi.fn(), - } as unknown as Config; - }); - vi.mocked(loadSettings).mockReturnValue({ - errors: [], - merged: { - advanced: { autoConfigureMemory: true }, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - } as never); - try { - await main(); - } catch (e) { - // Mocked process exit throws an error. - if (!(e instanceof MockProcessExitError)) throw e; - } - - // It is critical that we call relaunch before loadCliConfig to avoid - // loading config in the outer process when we are going to relaunch. - // By ensuring we don't load the config we also ensure we don't trigger any - // operations that might require loading the config such as such as - // initializing mcp servers. - // For the sandbox case we still have to load a partial cli config. - // we can authorize outside the sandbox. - expect(callOrder).toEqual(['relaunch', 'loadCliConfig']); - processExitSpy.mockRestore(); - }); - it('should log unhandled promise rejections and open debug console on first error', async () => { const processExitSpy = vi .spyOn(process, 'exit') @@ -505,6 +443,7 @@ describe('gemini.tsx main function kitty protocol', () => { subscribe: vi.fn(), }), getEnableHooks: () => false, + getHookSystem: () => undefined, getToolRegistry: vi.fn(), getContentGeneratorConfig: vi.fn(), getModel: () => 'gemini-pro', @@ -517,19 +456,21 @@ describe('gemini.tsx main function kitty protocol', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), - } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - errors: [], - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - } as never); + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, sandbox: undefined, @@ -553,6 +494,9 @@ describe('gemini.tsx main function kitty protocol', () => { outputFormat: undefined, fakeResponses: undefined, recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, }); await act(async () => { @@ -581,17 +525,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -609,6 +554,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -618,7 +564,9 @@ describe('gemini.tsx main function kitty protocol', () => { getScreenReader: () => false, getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + refreshAuth: vi.fn(), } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); @@ -631,10 +579,13 @@ describe('gemini.tsx main function kitty protocol', () => { .spyOn(debugLogger, 'log') .mockImplementation(() => {}); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; } if (flag === 'listExtensions') { @@ -668,17 +619,18 @@ describe('gemini.tsx main function kitty protocol', () => { promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); const mockConfig = { isInteractive: () => false, @@ -692,6 +644,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -702,19 +655,61 @@ describe('gemini.tsx main function kitty protocol', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', refreshAuth: vi.fn(), + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + getToolRegistry: () => ({ getAllTools: () => [] }), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-model', + getCoreTools: () => [], + getApprovalMode: () => 'default', + getPreviewFeatures: () => false, + getTargetDir: () => '/', + getUsageStatisticsEnabled: () => false, + getTelemetryEnabled: () => false, + getTelemetryTarget: () => 'none', + getTelemetryOtlpEndpoint: () => '', + getTelemetryOtlpProtocol: () => 'grpc', + getTelemetryLogPromptsEnabled: () => false, + getContinueOnFailedApiCall: () => false, + getShellToolInactivityTimeout: () => 0, + getTruncateToolOutputThreshold: () => 0, + getUseRipgrep: () => false, + getUseWriteTodos: () => false, + getHooks: () => undefined, + getExperiments: () => undefined, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getFolderTrust: () => false, + getPendingIncludeDirectories: () => [], + getWorkspaceContext: () => ({ getDirectories: () => ['/'] }), + getModelAvailabilityService: () => ({ + reset: vi.fn(), + resetTurn: vi.fn(), + }), + getBaseLlmClient: () => ({}), + getGeminiClient: () => ({}), + getContentGenerator: () => ({}), + isTrustedFolder: () => true, + isYoloModeDisabled: () => true, + isPlanEnabled: () => false, + isEventDrivenSchedulerEnabled: () => false, } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); - vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSandboxConfig).mockResolvedValue({ + command: 'docker', + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => { await fn(); }); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; } expect(start_sandbox).toHaveBeenCalled(); @@ -737,17 +732,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: { theme: 'non-existent-theme' }, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: { theme: 'non-existent-theme' }, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -760,6 +756,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -785,14 +782,18 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, refreshAuth: vi.fn(), setTerminalBackground: vi.fn(), + getRemoteAdminSettings: () => undefined, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; } expect(debugLoggerWarnSpy).toHaveBeenCalledWith( @@ -823,13 +824,14 @@ describe('gemini.tsx main function kitty protocol', () => { }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -843,6 +845,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -866,7 +869,11 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try { @@ -904,13 +911,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -923,6 +931,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -946,7 +955,11 @@ describe('gemini.tsx main function kitty protocol', () => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any // The mock is already set up at the top of the test @@ -978,13 +991,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -997,6 +1011,7 @@ describe('gemini.tsx main function kitty protocol', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -1022,19 +1037,12 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, refreshAuth: vi.fn(), setTerminalBackground: vi.fn(), + getRemoteAdminSettings: () => undefined, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mock('./utils/readStdin.js', () => ({ readStdin: vi.fn().mockResolvedValue('stdin-data'), })); - const runNonInteractiveSpy = vi.hoisted(() => vi.fn()); - vi.mock('./nonInteractiveCli.js', () => ({ - runNonInteractive: runNonInteractiveSpy, - })); - runNonInteractiveSpy.mockClear(); - vi.mock('./validateNonInterActiveAuth.js', () => ({ - validateNonInteractiveAuth: vi.fn().mockResolvedValue({}), - })); // Mock stdin to be non-TTY Object.defineProperty(process.stdin, 'isTTY', { @@ -1042,10 +1050,13 @@ describe('gemini.tsx main function kitty protocol', () => { configurable: true, }); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); } catch (e) { if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; } expect(readStdin).toHaveBeenCalled(); @@ -1092,10 +1103,11 @@ describe('gemini.tsx main function exit codes', () => { ); const { loadSettings } = await import('./config/settings.js'); vi.mocked(loadCliConfig).mockResolvedValue({} as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: true, } as unknown as CliArgs); @@ -1123,15 +1135,16 @@ describe('gemini.tsx main function exit codes', () => { vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); vi.mocked(loadCliConfig).mockResolvedValue({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), + getRemoteAdminSettings: vi.fn().mockReturnValue(undefined), + isInteractive: vi.fn().mockReturnValue(true), } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - security: { auth: { selectedType: 'google', useExternal: false } }, - ui: {}, - }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); vi.mock('./config/auth.js', () => ({ validateAuthMethod: vi.fn().mockReturnValue(null), @@ -1170,6 +1183,7 @@ describe('gemini.tsx main function exit codes', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, getToolRegistry: vi.fn(), getContentGeneratorConfig: vi.fn(), getModel: () => 'gemini-pro', @@ -1182,13 +1196,18 @@ describe('gemini.tsx main function exit codes', () => { getOutputFormat: () => 'text', getExtensions: () => [], getUsageStatisticsEnabled: () => false, + getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, + refreshAuth: vi.fn(), } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ resume: 'invalid-session', } as unknown as CliArgs); @@ -1201,12 +1220,15 @@ describe('gemini.tsx main function exit codes', () => { })), })); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(42); + } finally { + delete process.env['GEMINI_API_KEY']; } }); @@ -1234,6 +1256,7 @@ describe('gemini.tsx main function exit codes', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: () => false, + getHookSystem: () => undefined, getToolRegistry: vi.fn(), getContentGeneratorConfig: vi.fn(), getModel: () => 'gemini-pro', @@ -1247,26 +1270,110 @@ describe('gemini.tsx main function exit codes', () => { getExtensions: () => [], getUsageStatisticsEnabled: () => false, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, + refreshAuth: vi.fn(), + getRemoteAdminSettings: () => undefined, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); Object.defineProperty(process.stdin, 'isTTY', { value: true, // Simulate TTY so it doesn't try to read stdin configurable: true, }); + process.env['GEMINI_API_KEY'] = 'test-key'; try { await main(); expect.fail('Should have thrown MockProcessExitError'); } catch (e) { expect(e).toBeInstanceOf(MockProcessExitError); expect((e as MockProcessExitError).code).toBe(42); + } finally { + delete process.env['GEMINI_API_KEY']; } }); + + it('should validate and refresh auth in non-interactive mode when no auth type is selected but env var is present', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const { AuthType } = await import('@google/gemini-cli-core'); + + const refreshAuthSpy = vi.fn(); + + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => false, + getQuestion: () => 'test prompt', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, + getMcpServers: () => ({}), + getMcpClientManager: vi.fn(), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getPolicyEngine: vi.fn(), + getMessageBus: () => ({ subscribe: vi.fn() }), + getEnableHooks: () => false, + getHookSystem: () => undefined, + getToolRegistry: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-001', + getApprovalMode: () => 'default', + getCoreTools: () => [], + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getExtensions: () => [], + getUsageStatisticsEnabled: () => false, + setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, + refreshAuth: refreshAuthSpy, + getRemoteAdminSettings: () => undefined, + } as unknown as Config); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: { selectedType: undefined } }, ui: {} }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + + runNonInteractiveSpy.mockImplementation(() => Promise.resolve()); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + processExitSpy.mockRestore(); + } + + expect(refreshAuthSpy).toHaveBeenCalledWith(AuthType.USE_GEMINI); + }); }); describe('validateDnsResolutionOrder', () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index eacef49cb3..4fed48179a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -20,11 +20,7 @@ import { loadTrustedFolders, type TrustedFoldersError, } from './config/trustedFolders.js'; -import { - loadSettings, - migrateDeprecatedSettings, - SettingScope, -} from './config/settings.js'; +import { loadSettings, SettingScope } from './config/settings.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; @@ -36,6 +32,10 @@ import { runExitCleanup, registerTelemetryConfig, } from './utils/cleanup.js'; +import { + cleanupToolOutputFiles, + cleanupExpiredSessions, +} from './utils/sessionCleanup.js'; import { type Config, type ResumedSessionData, @@ -64,26 +64,26 @@ import { ExitCodes, SessionStartSource, SessionEndReason, - fireSessionStartHook, - fireSessionEndHook, getVersion, + ValidationCancelledError, + ValidationRequiredError, + type FetchAdminControlsResponse, } from '@google/gemini-cli-core'; import { initializeApp, type InitializationResult, } from './core/initializer.js'; import { validateAuthMethod } from './config/auth.js'; -import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { runZedIntegration } from './zed-integration/zedIntegration.js'; -import { cleanupExpiredSessions } from './utils/sessionCleanup.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; import { SessionSelector } from './utils/sessionUtils.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js'; +import { StreamingState } from './ui/types.js'; +import { computeTerminalTitle } from './utils/windowTitle.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; @@ -95,14 +95,13 @@ import { } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; import { deleteSession, listSessions } from './utils/sessions.js'; -import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; -import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; +import { runDeferredCommand } from './deferred.js'; const SLOW_RENDER_MS = 200; @@ -221,12 +220,12 @@ export async function startInteractiveUI( @@ -271,8 +270,7 @@ export async function startInteractiveUI( patchConsole: false, alternateBuffer: useAlternateBuffer, incrementalRendering: - settings.merged.ui?.incrementalRendering !== false && - useAlternateBuffer, + settings.merged.ui.incrementalRendering !== false && useAlternateBuffer, }, ); @@ -292,6 +290,14 @@ export async function startInteractiveUI( export async function main() { const cliStartupHandle = startupProfiler.start('cli_startup'); + + // Listen for admin controls from parent process (IPC) in non-sandbox mode. In + // sandbox mode, we re-fetch the admin controls from the server once we enter + // the sandbox. + // TODO: Cache settings in sandbox mode as well. + const adminControlsListner = setupAdminControlsListener(); + registerCleanup(adminControlsListner.cleanup); + const cleanupStdio = patchStdio(); registerSyncCleanup(() => { // This is needed to ensure we don't lose any buffered output. @@ -317,25 +323,21 @@ export async function main() { ); }); - const migrateHandle = startupProfiler.start('migrate_settings'); - migrateDeprecatedSettings( - settings, - // Temporary extension manager only used during this non-interactive UI phase. - new ExtensionManager({ - workspaceDir: process.cwd(), - settings: settings.merged, - enabledExtensionOverrides: [], - requestConsent: requestConsentNonInteractive, - requestSetting: null, - }), - ); - migrateHandle?.end(); - await cleanupCheckpoints(); + await Promise.all([ + cleanupCheckpoints(), + cleanupToolOutputFiles(settings.merged), + ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); const argv = await parseArguments(settings.merged); parseArgsHandle?.end(); + if (argv.startupMessages) { + argv.startupMessages.forEach((msg) => { + coreEvents.emitFeedback('info', msg); + }); + } + // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { writeToStderr( @@ -357,13 +359,13 @@ export async function main() { registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( - validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), + validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder), ); // Set a default auth type if one isn't set or is set to a legacy type if ( - !settings.merged.security?.auth?.selectedType || - settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL + !settings.merged.security.auth.selectedType || + settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL ) { if ( process.env['CLOUD_SHELL'] === 'true' || @@ -371,15 +373,75 @@ export async function main() { ) { settings.setValue( SettingScope.User, - 'selectedAuthType', + 'security.auth.selectedType', AuthType.COMPUTE_ADC, ); } } + const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, { + projectHooks: settings.workspace.settings.hooks, + }); + adminControlsListner.setConfig(partialConfig); + + // Refresh auth to fetch remote admin settings from CCPA and before entering + // the sandbox because the sandbox will interfere with the Oauth2 web + // redirect. + let initialAuthFailed = false; + if (!settings.merged.security.auth.useExternal) { + try { + if ( + partialConfig.isInteractive() && + settings.merged.security.auth.selectedType + ) { + const err = validateAuthMethod( + settings.merged.security.auth.selectedType, + ); + if (err) { + throw new Error(err); + } + + await partialConfig.refreshAuth( + settings.merged.security.auth.selectedType, + ); + } else if (!partialConfig.isInteractive()) { + const authType = await validateNonInteractiveAuth( + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, + partialConfig, + settings, + ); + await partialConfig.refreshAuth(authType); + } + } catch (err) { + if (err instanceof ValidationCancelledError) { + // User cancelled verification, exit immediately. + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + + // If validation is required, we don't treat it as a fatal failure. + // We allow the app to start, and the React-based ValidationDialog + // will handle it. + if (!(err instanceof ValidationRequiredError)) { + debugLogger.error('Error authenticating:', err); + initialAuthFailed = true; + } + } + } + + const remoteAdminSettings = partialConfig.getRemoteAdminSettings(); + // Set remote admin settings if returned from CCPA. + if (remoteAdminSettings) { + settings.setRemoteAdminSettings(remoteAdminSettings); + } + + // Run deferred command now that we have admin settings. + await runDeferredCommand(settings.merged); + // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { - const memoryArgs = settings.merged.advanced?.autoConfigureMemory + const memoryArgs = settings.merged.advanced.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : []; const sandboxConfig = await loadSandboxConfig(settings.merged, argv); @@ -390,44 +452,9 @@ export async function main() { // another way to decouple refreshAuth from requiring a config. if (sandboxConfig) { - const partialConfig = await loadCliConfig( - settings.merged, - sessionId, - argv, - { projectHooks: settings.workspace.settings.hooks }, - ); - - if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - try { - if (partialConfig.isInteractive()) { - // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. - const err = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (err) { - throw new Error(err); - } - - await partialConfig.refreshAuth( - settings.merged.security.auth.selectedType, - ); - } else { - const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - partialConfig, - settings, - ); - await partialConfig.refreshAuth(authType); - } - } catch (err) { - debugLogger.error('Error authenticating:', err); - await runExitCleanup(); - process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); - } + if (initialAuthFailed) { + await runExitCleanup(); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); } let stdinData = ''; if (!process.stdin.isTTY) { @@ -467,7 +494,7 @@ export async function main() { } else { // Relaunch app so we always have a child process that can be internally // restarted if needed. - await relaunchAppInChildProcess(memoryArgs, []); + await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings); } } @@ -480,6 +507,14 @@ export async function main() { projectHooks: settings.workspace.settings.hooks, }); loadConfigHandle?.end(); + adminControlsListner.setConfig(config); + + if (config.isInteractive() && config.storage && config.getDebugMode()) { + const { registerActivityLogger } = await import( + './utils/activityLogger.js' + ); + registerActivityLogger(config); + } // Register config for telemetry shutdown // This ensures telemetry (including SessionEnd hooks) is properly flushed on exit @@ -491,11 +526,9 @@ export async function main() { // Register SessionEnd hook to fire on graceful exit // This runs before telemetry shutdown in runExitCleanup() - if (config.getEnableHooks() && messageBus) { - registerCleanup(async () => { - await fireSessionEndHook(messageBus, SessionEndReason.Exit); - }); - } + registerCleanup(async () => { + await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); + }); // Cleanup sessions after config initialization try { @@ -516,7 +549,7 @@ export async function main() { // Handle --list-sessions flag if (config.getListSessions()) { // Attempt auth for summary generation (gracefully skips if not configured) - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (authType) { try { await config.refreshAuth(authType); @@ -571,13 +604,12 @@ export async function main() { await setupTerminalAndTheme(config, settings); - setMaxSizedBoxDebugging(isDebugMode); const initAppHandle = startupProfiler.start('initialize_app'); const initializationResult = await initializeApp(config, settings); initAppHandle?.end(); if ( - settings.merged.security?.auth?.selectedType === + settings.merged.security.auth.selectedType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { @@ -592,7 +624,7 @@ export async function main() { let input = config.getQuestion(); const startupWarnings = [ ...(await getStartupWarnings()), - ...(await getUserStartupWarnings()), + ...(await getUserStartupWarnings(settings.merged)), ]; // Handle --resume flag @@ -634,30 +666,44 @@ export async function main() { await config.initialize(); startupProfiler.flush(config); - // Fire SessionStart hook through MessageBus (only if hooks are enabled) - // Must be called AFTER config.initialize() to ensure HookRegistry is loaded - const hooksEnabled = config.getEnableHooks(); - const hookMessageBus = config.getMessageBus(); - if (hooksEnabled && hookMessageBus) { - const sessionStartSource = resumedSessionData - ? SessionStartSource.Resume - : SessionStartSource.Startup; - await fireSessionStartHook(hookMessageBus, sessionStartSource); - - // Register SessionEnd hook for graceful exit - registerCleanup(async () => { - await fireSessionEndHook(hookMessageBus, SessionEndReason.Exit); - }); - } - // If not a TTY, read from stdin // This is for cases where the user pipes input directly into the command + let stdinData: string | undefined = undefined; if (!process.stdin.isTTY) { - const stdinData = await readStdin(); + stdinData = await readStdin(); if (stdinData) { - input = `${stdinData}\n\n${input}`; + input = input ? `${stdinData}\n\n${input}` : stdinData; } } + + // Fire SessionStart hook through MessageBus (only if hooks are enabled) + // Must be called AFTER config.initialize() to ensure HookRegistry is loaded + const sessionStartSource = resumedSessionData + ? SessionStartSource.Resume + : SessionStartSource.Startup; + + const hookSystem = config?.getHookSystem(); + if (hookSystem) { + const result = await hookSystem.fireSessionStartEvent(sessionStartSource); + + if (result) { + if (result.systemMessage) { + writeToStderr(result.systemMessage + '\n'); + } + const additionalContext = result.getAdditionalContext(); + if (additionalContext) { + // Prepend context to input (System Context -> Stdin -> Question) + const wrappedContext = `${additionalContext}`; + input = input ? `${wrappedContext}\n\n${input}` : wrappedContext; + } + } + } + + // Register SessionEnd hook for graceful exit + registerCleanup(async () => { + await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); + }); + if (!input) { debugLogger.error( `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, @@ -678,8 +724,8 @@ export async function main() { ); const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, config, settings, ); @@ -689,9 +735,6 @@ export async function main() { debugLogger.log('Session ID: %s', sessionId); } - const hasDeprecatedPromptArg = process.argv.some((arg) => - arg.startsWith('--prompt'), - ); initializeOutputListenersAndFlush(); await runNonInteractive({ @@ -699,7 +742,6 @@ export async function main() { settings, input, prompt_id, - hasDeprecatedPromptArg, resumedSessionData, }); // Call cleanup before process.exit, which causes cleanup to not run @@ -709,12 +751,20 @@ export async function main() { } function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui?.hideWindowTitle) { - const windowTitle = computeWindowTitle(title); - writeToStdout(`\x1b]2;${windowTitle}\x07`); + if (!settings.merged.ui.hideWindowTitle) { + // Initial state before React loop starts + const windowTitle = computeTerminalTitle({ + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName: title, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, + }); + writeToStdout(`\x1b]0;${windowTitle}\x07`); process.on('exit', () => { - writeToStdout(`\x1b]2;\x07`); + writeToStdout(`\x1b]0;\x07`); }); } } @@ -754,3 +804,36 @@ export function initializeOutputListenersAndFlush() { } coreEvents.drainBacklogs(); } + +function setupAdminControlsListener() { + let pendingSettings: FetchAdminControlsResponse | undefined; + let config: Config | undefined; + + const messageHandler = (msg: unknown) => { + const message = msg as { + type?: string; + settings?: FetchAdminControlsResponse; + }; + if (message?.type === 'admin-settings' && message.settings) { + if (config) { + config.setRemoteAdminSettings(message.settings); + } else { + pendingSettings = message.settings; + } + } + }; + + process.on('message', messageHandler); + + return { + setConfig: (newConfig: Config) => { + config = newConfig; + if (pendingSettings) { + config.setRemoteAdminSettings(pendingSettings); + } + }, + cleanup: () => { + process.off('message', messageHandler); + }, + }; +} diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 95471ef031..ec1341a768 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -189,6 +189,7 @@ describe('gemini.tsx main function cleanup', () => { getPolicyEngine: vi.fn(), getMessageBus: () => ({ subscribe: vi.fn() }), getEnableHooks: vi.fn(() => false), + getHookSystem: () => undefined, initialize: vi.fn(), getContentGeneratorConfig: vi.fn(), getMcpServers: () => ({}), @@ -214,6 +215,7 @@ describe('gemini.tsx main function cleanup', () => { getUsageStatisticsEnabled: vi.fn(() => false), setTerminalBackground: vi.fn(), refreshAuth: vi.fn(), + getRemoteAdminSettings: vi.fn(() => undefined), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index f2a2a43592..d0e21b6b6d 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -14,7 +14,6 @@ import type { UserFeedbackPayload, } from '@google/gemini-cli-core'; import { - executeToolCall, ToolErrorType, GeminiEventType, OutputFormat, @@ -39,14 +38,22 @@ import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); +const mockRegisterActivityLogger = vi.hoisted(() => vi.fn()); +vi.mock('./utils/activityLogger.js', () => ({ + registerActivityLogger: mockRegisterActivityLogger, +})); + const mockCoreEvents = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), - drainBacklogs: vi.fn(), emit: vi.fn(), + emitConsoleLog: vi.fn(), emitFeedback: vi.fn(), + drainBacklogs: vi.fn(), })); +const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); @@ -60,7 +67,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, - executeToolCall: vi.fn(), + Scheduler: class { + schedule = mockSchedulerSchedule; + cancelAll = vi.fn(); + }, isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), ChatRecordingService: MockChatRecordingService, uiTelemetryService: { @@ -84,12 +94,12 @@ vi.mock('./services/CommandService.js', () => ({ vi.mock('./services/FileCommandLoader.js'); vi.mock('./services/McpPromptLoader.js'); +vi.mock('./services/BuiltinCommandLoader.js'); describe('runNonInteractive', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockToolRegistry: ToolRegistry; - let mockCoreExecuteToolCall: Mock; let consoleErrorSpy: MockInstance; let processStdoutSpy: MockInstance; let processStderrSpy: MockInstance; @@ -120,7 +130,7 @@ describe('runNonInteractive', () => { }; beforeEach(async () => { - mockCoreExecuteToolCall = vi.mocked(executeToolCall); + mockSchedulerSchedule.mockReset(); mockCommandServiceCreate.mockResolvedValue({ getCommands: mockGetCommands, @@ -156,6 +166,11 @@ describe('runNonInteractive', () => { mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), + getMessageBus: vi.fn().mockReturnValue({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + publish: vi.fn(), + }), getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMaxSessionTurns: vi.fn().mockReturnValue(10), @@ -172,6 +187,8 @@ describe('runNonInteractive', () => { getModel: vi.fn().mockReturnValue('test-model'), getFolderTrust: vi.fn().mockReturnValue(false), isTrustedFolder: vi.fn().mockReturnValue(false), + getRawOutput: vi.fn().mockReturnValue(false), + getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), } as unknown as Config; mockSettings = { @@ -241,12 +258,61 @@ 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 // so we no longer expect shutdownTelemetry to be called directly here }); + it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl'); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger', + }); + + expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig); + vi.unstubAllEnvs(); + }); + + it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', ''); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger-off', + }); + + expect(mockRegisterActivityLogger).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + it('should handle a single tool call and respond', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, @@ -259,25 +325,27 @@ describe('runNonInteractive', () => { }, }; const toolResponse: Part[] = [{ text: 'Tool response' }]; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: { - callId: 'tool-1', - name: 'testTool', - args: { arg1: 'value1' }, - isClientInitiated: false, - prompt_id: 'prompt-id-2', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, }, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: toolResponse, - callId: 'tool-1', - error: undefined, - errorType: undefined, - contentLength: undefined, - }, - }); + ]); const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; const secondCallEvents: ServerGeminiStreamEvent[] = [ @@ -300,9 +368,8 @@ describe('runNonInteractive', () => { }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ name: 'testTool' }), + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], expect.any(AbortSignal), ); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( @@ -310,6 +377,9 @@ describe('runNonInteractive', () => { [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', + undefined, + false, + undefined, ); expect(getWrittenOutput()).toBe('Final answer\n'); }); @@ -331,16 +401,18 @@ describe('runNonInteractive', () => { }; // 2. Mock the execution of the tools. We just need them to succeed. - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: toolCallEvent.value, // This is generic enough for both calls - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: [], - callId: 'mock-tool', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: toolCallEvent.value, // This is generic enough for both calls + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'mock-tool', + }, }, - }); + ]); // 3. Define the sequence of events streamed from the mock model. // Turn 1: Model outputs text, then requests a tool call. @@ -381,7 +453,7 @@ describe('runNonInteractive', () => { expect(getWrittenOutput()).toMatchSnapshot(); // Also verify the tools were called as expected. - expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2); + expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2); }); it('should handle error during tool execution and should send error back to the model', async () => { @@ -395,34 +467,36 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-3', }, }; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'error', - request: { - callId: 'tool-1', - name: 'errorTool', - args: {}, - isClientInitiated: false, - prompt_id: 'prompt-id-3', - }, - tool: {} as AnyDeclarativeTool, - response: { - callId: 'tool-1', - error: new Error('Execution failed'), - errorType: ToolErrorType.EXECUTION_FAILED, - responseParts: [ - { - functionResponse: { - name: 'errorTool', - response: { - output: 'Error: Execution failed', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'error', + request: { + callId: 'tool-1', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + tool: {} as AnyDeclarativeTool, + response: { + callId: 'tool-1', + error: new Error('Execution failed'), + errorType: ToolErrorType.EXECUTION_FAILED, + responseParts: [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Execution failed', + }, }, }, - }, - ], - resultDisplay: 'Execution failed', - contentLength: undefined, + ], + resultDisplay: 'Execution failed', + contentLength: undefined, + }, }, - }); + ]); const finalResponse: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, @@ -444,7 +518,7 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-3', }); - expect(mockCoreExecuteToolCall).toHaveBeenCalled(); + expect(mockSchedulerSchedule).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool errorTool: Execution failed', ); @@ -463,6 +537,9 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', + undefined, + false, + undefined, ); expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); }); @@ -494,24 +571,26 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-5', }, }; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'error', - request: { - callId: 'tool-1', - name: 'nonexistentTool', - args: {}, - isClientInitiated: false, - prompt_id: 'prompt-id-5', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'error', + request: { + callId: 'tool-1', + name: 'nonexistentTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', + }, + response: { + callId: 'tool-1', + error: new Error('Tool "nonexistentTool" not found in registry.'), + resultDisplay: 'Tool "nonexistentTool" not found in registry.', + responseParts: [], + errorType: undefined, + contentLength: undefined, + }, }, - response: { - callId: 'tool-1', - error: new Error('Tool "nonexistentTool" not found in registry.'), - resultDisplay: 'Tool "nonexistentTool" not found in registry.', - responseParts: [], - errorType: undefined, - contentLength: undefined, - }, - }); + ]); const finalResponse: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, @@ -534,7 +613,7 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-5', }); - expect(mockCoreExecuteToolCall).toHaveBeenCalled(); + expect(mockSchedulerSchedule).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', ); @@ -600,6 +679,9 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', + undefined, + false, + rawInput, ); // 6. Assert the final output is correct @@ -633,6 +715,9 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + undefined, + false, + 'Test input', ); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( @@ -661,25 +746,27 @@ describe('runNonInteractive', () => { }, }; const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: { - callId: 'tool-1', - name: 'testTool', - args: { arg1: 'value1' }, - isClientInitiated: false, - prompt_id: 'prompt-id-tool-only', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, }, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: toolResponse, - callId: 'tool-1', - error: undefined, - errorType: undefined, - contentLength: undefined, - }, - }); + ]); // First call returns only tool call, no content const firstCallEvents: ServerGeminiStreamEvent[] = [ @@ -715,9 +802,8 @@ describe('runNonInteractive', () => { }); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ name: 'testTool' }), + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], expect.any(AbortSignal), ); @@ -762,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 @@ -896,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'); @@ -939,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(); @@ -1113,6 +1208,9 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', + undefined, + false, + '/unknowncommand', ); expect(getWrittenOutput()).toBe('Response to unknown\n'); @@ -1181,7 +1279,9 @@ describe('runNonInteractive', () => { './services/FileCommandLoader.js' ); const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); - + const { BuiltinCommandLoader } = await import( + './services/BuiltinCommandLoader.js' + ); mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Acknowledged' }, @@ -1206,13 +1306,17 @@ describe('runNonInteractive', () => { expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); expect(McpPromptLoader).toHaveBeenCalledTimes(1); expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); // Check that instances were passed to CommandService.create expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; - expect(loadersArg).toHaveLength(2); - expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); - expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); + expect(loadersArg).toHaveLength(3); + expect(loadersArg[0]).toBe( + vi.mocked(BuiltinCommandLoader).mock.instances[0], + ); + expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); + expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); }); it('should allow a normally-excluded tool when --allowed-tools is set', async () => { @@ -1238,25 +1342,27 @@ describe('runNonInteractive', () => { }, }; const toolResponse: Part[] = [{ text: 'file.txt' }]; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: { - callId: 'tool-shell-1', - name: 'ShellTool', - args: { command: 'ls' }, - isClientInitiated: false, - prompt_id: 'prompt-id-allowed', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: { + callId: 'tool-shell-1', + name: 'ShellTool', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-allowed', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-shell-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, }, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: toolResponse, - callId: 'tool-shell-1', - error: undefined, - errorType: undefined, - contentLength: undefined, - }, - }); + ]); const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; const secondCallEvents: ServerGeminiStreamEvent[] = [ @@ -1278,9 +1384,8 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-allowed', }); - expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ name: 'ShellTool' }), + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'ShellTool' })], expect.any(AbortSignal), ); expect(getWrittenOutput()).toBe('file.txt\n'); @@ -1417,58 +1522,6 @@ describe('runNonInteractive', () => { }); }); - it('should display a deprecation warning if hasDeprecatedPromptArg is true', async () => { - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Final Answer' }, - { - type: GeminiEventType.Finished, - value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, - }, - ]; - mockGeminiClient.sendMessageStream.mockReturnValue( - createStreamFromEvents(events), - ); - - await runNonInteractive({ - config: mockConfig, - settings: mockSettings, - input: 'Test input', - prompt_id: 'prompt-id-deprecated', - hasDeprecatedPromptArg: true, - }); - - expect(processStderrSpy).toHaveBeenCalledWith( - 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n', - ); - expect(processStdoutSpy).toHaveBeenCalledWith('Final Answer'); - }); - - it('should display a deprecation warning for JSON format', async () => { - const events: ServerGeminiStreamEvent[] = [ - { type: GeminiEventType.Content, value: 'Final Answer' }, - { - type: GeminiEventType.Finished, - value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, - }, - ]; - mockGeminiClient.sendMessageStream.mockReturnValue( - createStreamFromEvents(events), - ); - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - - await runNonInteractive({ - config: mockConfig, - settings: mockSettings, - input: 'Test input', - prompt_id: 'prompt-id-deprecated-json', - hasDeprecatedPromptArg: true, - }); - - const deprecateText = - 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n'; - expect(processStderrSpy).toHaveBeenCalledWith(deprecateText); - }); - it('should emit appropriate events for streaming JSON output', async () => { vi.mocked(mockConfig.getOutputFormat).mockReturnValue( OutputFormat.STREAM_JSON, @@ -1488,20 +1541,22 @@ describe('runNonInteractive', () => { }, }; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: toolCallEvent.value, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: [{ text: 'Tool response' }], - callId: 'tool-1', - error: undefined, - errorType: undefined, - contentLength: undefined, - resultDisplay: 'Tool executed successfully', + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [{ text: 'Tool response' }], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + resultDisplay: 'Tool executed successfully', + }, }, - }); + ]); const firstCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Thinking...' }, @@ -1678,19 +1733,21 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-tool-error', }, }; - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'success', - request: toolCallEvent.value, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - responseParts: [], - callId: 'tool-1', - error: undefined, - errorType: undefined, - contentLength: undefined, + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'success', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, }, - }); + ]); const events: ServerGeminiStreamEvent[] = [ toolCallEvent, @@ -1759,19 +1816,21 @@ describe('runNonInteractive', () => { }; // Mock tool execution returning STOP_EXECUTION - mockCoreExecuteToolCall.mockResolvedValue({ - status: 'error', - request: toolCallEvent.value, - tool: {} as AnyDeclarativeTool, - invocation: {} as AnyToolInvocation, - response: { - callId: 'stop-call', - responseParts: [{ text: 'error occurred' }], - errorType: ToolErrorType.STOP_EXECUTION, - error: new Error('Stop reason from hook'), - resultDisplay: undefined, + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'error', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason from hook'), + resultDisplay: undefined, + }, }, - }); + ]); const firstCallEvents: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Executing tool...' }, @@ -1792,9 +1851,383 @@ describe('runNonInteractive', () => { prompt_id: 'prompt-id-stop', }); - expect(mockCoreExecuteToolCall).toHaveBeenCalled(); + expect(mockSchedulerSchedule).toHaveBeenCalled(); // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input). expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stop reason from hook\n', + ); + }); + + it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-json', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'error', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + toolCallEvent, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-json', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Partial content', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-stream', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'error', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-stream', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"result"'); + expect(output).toContain('"status":"success"'); + }); + + describe('Agent Execution Events', () => { + it('should handle AgentExecutionStopped event', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stopped by hook\n', + ); + // Should exit without calling sendMessageStream again + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle AgentExecutionBlocked event', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Agent execution blocked: Blocked by hook\n', + ); + // sendMessageStream is called once, recursion is internal to it and transparent to the caller + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(getWrittenOutput()).toBe('Final answer\n'); + }); + }); + + describe('Output Sanitization', () => { + const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m'; + const OSC_HYPERLINK = + '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\'; + const PLAIN_TEXT_RED = 'Red Text'; + const PLAIN_TEXT_LINK = 'Link'; + + it('should sanitize ANSI output by default', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-sanitization', + }); + + expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`); + }); + + it('should allow ANSI output when rawOutput is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-raw', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`); + }); + + it('should allow ANSI output when only acceptRawOutputRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-accept-only', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`); + }); + + it('should warn when rawOutput is true and acceptRisk is false', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-warn', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should not warn when rawOutput is true and acceptRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-no-warn', + }); + + expect(processStderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-cancel', + }, + }; + + // Mock the scheduler to return a cancelled status + mockSchedulerSchedule.mockResolvedValue([ + { + status: 'cancelled', + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'tool-1', + responseParts: [{ text: 'Operation cancelled' }], + resultDisplay: 'Cancelled', + }, + }, + ]); + + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Content, + value: 'Model continues...', + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-cancel', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"tool_result"'); + expect(output).toContain('"status":"success"'); + }); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c81efd72f5..a2ca92a4e8 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -8,13 +8,11 @@ import type { Config, ToolCallRequestInfo, ResumedSessionData, - CompletedToolCall, UserFeedbackPayload, } from '@google/gemini-cli-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { - executeToolCall, GeminiEventType, FatalInputError, promptIdContext, @@ -29,10 +27,13 @@ import { createWorkingStdio, recordToolCallInteractions, ToolErrorType, + Scheduler, + ROOT_SCHEDULER_ID, } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; import readline from 'node:readline'; +import stripAnsi from 'strip-ansi'; import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; @@ -51,7 +52,6 @@ interface RunNonInteractiveParams { settings: LoadedSettings; input: string; prompt_id: string; - hasDeprecatedPromptArg?: boolean; resumedSessionData?: ResumedSessionData; } @@ -60,7 +60,6 @@ export async function runNonInteractive({ settings, input, prompt_id, - hasDeprecatedPromptArg, resumedSessionData, }: RunNonInteractiveParams): Promise { return promptIdContext.run(prompt_id, async () => { @@ -71,6 +70,14 @@ export async function runNonInteractive({ coreEvents.emitConsoleLog(msg.type, msg.content); }, }); + + if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) { + const { registerActivityLogger } = await import( + './utils/activityLogger.js' + ); + registerActivityLogger(config); + } + const { stdout: workingStdout } = createWorkingStdio(); const textOutput = new TextOutput(workingStdout); @@ -178,6 +185,16 @@ export async function runNonInteractive({ try { consolePatcher.patch(); + if ( + config.getRawOutput() && + !config.getAcceptRawOutputRisk() && + config.getOutputFormat() === OutputFormat.TEXT + ) { + process.stderr.write( + '[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n', + ); + } + // Setup stdin cancellation listener setupStdinCancellation(); @@ -193,6 +210,12 @@ export async function runNonInteractive({ }); const geminiClient = config.getGeminiClient(); + const scheduler = new Scheduler({ + config, + messageBus: config.getMessageBus(), + getPreferredEditor: () => undefined, + schedulerId: ROOT_SCHEDULER_ID, + }); // Initialize chat. Resume if resume data is passed. if (resumedSessionData) { @@ -264,21 +287,6 @@ export async function runNonInteractive({ let currentMessages: Content[] = [{ role: 'user', parts: query }]; let turnCount = 0; - const deprecateText = - 'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n'; - if (hasDeprecatedPromptArg) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.MESSAGE, - timestamp: new Date().toISOString(), - role: 'assistant', - content: deprecateText, - delta: true, - }); - } else { - process.stderr.write(deprecateText); - } - } while (true) { turnCount++; if ( @@ -293,6 +301,9 @@ export async function runNonInteractive({ currentMessages[0]?.parts || [], abortController.signal, prompt_id, + undefined, + false, + turnCount === 1 ? input : undefined, ); let responseText = ''; @@ -302,19 +313,22 @@ export async function runNonInteractive({ } if (event.type === GeminiEventType.Content) { + const isRaw = + config.getRawOutput() || config.getAcceptRawOutputRisk(); + const output = isRaw ? event.value : stripAnsi(event.value); if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.MESSAGE, timestamp: new Date().toISOString(), role: 'assistant', - content: event.value, + content: output, delta: true, }); } else if (config.getOutputFormat() === OutputFormat.JSON) { - responseText += event.value; + responseText += output; } else { if (event.value) { - textOutput.write(event.value); + textOutput.write(output); } } } else if (event.type === GeminiEventType.ToolCallRequest) { @@ -348,30 +362,53 @@ export async function runNonInteractive({ } } else if (event.type === GeminiEventType.Error) { throw event.value.error; + } else if (event.type === GeminiEventType.AgentExecutionStopped) { + const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + // Emit final result event for streaming JSON if needed + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'success', + stats: streamFormatter.convertToStreamStats( + metrics, + durationMs, + ), + }); + } + return; + } else if (event.type === GeminiEventType.AgentExecutionBlocked) { + const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[WARNING] ${blockMessage}\n`); + } } } if (toolCallRequests.length > 0) { textOutput.ensureTrailingNewline(); + const completedToolCalls = await scheduler.schedule( + toolCallRequests, + abortController.signal, + ); const toolResponseParts: Part[] = []; - const completedToolCalls: CompletedToolCall[] = []; - for (const requestInfo of toolCallRequests) { - const completedToolCall = await executeToolCall( - config, - requestInfo, - abortController.signal, - ); + for (const completedToolCall of completedToolCalls) { const toolResponse = completedToolCall.response; - - completedToolCalls.push(completedToolCall); + const requestInfo = completedToolCall.request; if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, timestamp: new Date().toISOString(), tool_id: requestInfo.callId, - status: toolResponse.error ? 'error' : 'success', + status: + completedToolCall.status === 'error' ? 'error' : 'success', output: typeof toolResponse.resultDisplay === 'string' ? toolResponse.resultDisplay diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 912121a2dd..e09db71312 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -13,6 +13,7 @@ import { type Config, } from '@google/gemini-cli-core'; import { CommandService } from './services/CommandService.js'; +import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; import { McpPromptLoader } from './services/McpPromptLoader.js'; import type { CommandContext } from './ui/commands/types.js'; @@ -40,7 +41,11 @@ export const handleSlashCommand = async ( } const commandService = await CommandService.create( - [new McpPromptLoader(config), new FileCommandLoader(config)], + [ + new BuiltinCommandLoader(config), + new McpPromptLoader(config), + new FileCommandLoader(config), + ], abortController.signal, ); const commands = commandService.getCommands(); diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 4d8fe6773d..2740d9ed3e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -53,13 +53,29 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => { import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; import type { Config } from '@google/gemini-cli-core'; +import { isNightly } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isNightly: vi.fn().mockResolvedValue(false), + }; +}); + vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); +vi.mock('../ui/commands/agentsCommand.js', () => ({ + agentsCommand: { name: 'agents' }, +})); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); -vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} })); +vi.mock('../ui/commands/chatCommand.js', () => ({ + chatCommand: { name: 'chat', subCommands: [] }, + debugCommand: { name: 'debug' }, +})); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); @@ -101,9 +117,14 @@ describe('BuiltinCommandLoader', () => { getFolderTrust: vi.fn().mockReturnValue(true), getEnableExtensionReloading: () => false, getEnableHooks: () => false, - isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + getEnableHooksUI: () => false, + getExtensionsEnabled: vi.fn().mockReturnValue(true), + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + isAgentsEnabled: vi.fn().mockReturnValue(false), + getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), + isAdminEnabled: vi.fn().mockReturnValue(true), }), } as unknown as Config; @@ -179,12 +200,53 @@ describe('BuiltinCommandLoader', () => { const mockConfigWithMessageBus = { ...mockConfig, getEnableHooks: () => false, + getMcpEnabled: () => true, } as unknown as Config; const loader = new BuiltinCommandLoader(mockConfigWithMessageBus); const commands = await loader.loadCommands(new AbortController().signal); const policiesCmd = commands.find((c) => c.name === 'policies'); expect(policiesCmd).toBeDefined(); }); + + it('should include agents command when agents are enabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeDefined(); + }); + + it('should exclude agents command when agents are disabled', async () => { + mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const agentsCmd = commands.find((c) => c.name === 'agents'); + expect(agentsCmd).toBeUndefined(); + }); + + describe('chat debug command', () => { + it('should NOT add debug subcommand to chatCommand if not a nightly build', async () => { + vi.mocked(isNightly).mockResolvedValue(false); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const chatCmd = commands.find((c) => c.name === 'chat'); + expect(chatCmd?.subCommands).toBeDefined(); + const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); + expect(hasDebug).toBe(false); + }); + + it('should add debug subcommand to chatCommand if it is a nightly build', async () => { + vi.mocked(isNightly).mockResolvedValue(true); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + const chatCmd = commands.find((c) => c.name === 'chat'); + expect(chatCmd?.subCommands).toBeDefined(); + const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); + expect(hasDebug).toBe(true); + }); + }); }); describe('BuiltinCommandLoader profile', () => { @@ -197,9 +259,14 @@ describe('BuiltinCommandLoader profile', () => { getCheckpointingEnabled: () => false, getEnableExtensionReloading: () => false, getEnableHooks: () => false, - isSkillsSupportEnabled: vi.fn().mockReturnValue(false), + getEnableHooksUI: () => false, + getExtensionsEnabled: vi.fn().mockReturnValue(true), + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + isAgentsEnabled: vi.fn().mockReturnValue(false), + getMcpEnabled: vi.fn().mockReturnValue(true), getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue([]), + isAdminEnabled: vi.fn().mockReturnValue(true), }), } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 6978322bbf..75cbe74cc2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -6,13 +6,22 @@ import { isDevelopment } from '../utils/installationInfo.js'; import type { ICommandLoader } from './types.js'; -import type { SlashCommand } from '../ui/commands/types.js'; -import type { Config } from '@google/gemini-cli-core'; -import { startupProfiler } from '@google/gemini-cli-core'; +import { + CommandKind, + type SlashCommand, + type CommandContext, +} from '../ui/commands/types.js'; +import type { MessageActionReturn, Config } 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'; import { bugCommand } from '../ui/commands/bugCommand.js'; -import { chatCommand } from '../ui/commands/chatCommand.js'; +import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; @@ -22,12 +31,14 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; +import { rewindCommand } from '../ui/commands/rewindCommand.js'; import { hooksCommand } from '../ui/commands/hooksCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { 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 { policiesCommand } from '../ui/commands/policiesCommand.js'; @@ -40,6 +51,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'; @@ -60,11 +72,20 @@ export class BuiltinCommandLoader implements ICommandLoader { */ async loadCommands(_signal: AbortSignal): Promise { const handle = startupProfiler.start('load_builtin_commands'); + + const isNightlyBuild = await isNightly(process.cwd()); + const allDefinitions: Array = [ aboutCommand, + ...(this.config?.isAgentsEnabled() ? [agentsCommand] : []), authCommand, bugCommand, - chatCommand, + { + ...chatCommand, + subCommands: isNightlyBuild + ? [...(chatCommand.subCommands || []), debugCommand] + : chatCommand.subCommands, + }, clearCommand, compressCommand, copyCommand, @@ -72,12 +93,52 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, - extensionsCommand(this.config?.getEnableExtensionReloading()), + ...(this.config?.getExtensionsEnabled() === false + ? [ + { + name: 'extensions', + description: 'Manage extensions', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage( + 'Extensions', + this.config ?? undefined, + ), + }), + }, + ] + : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, - ...(this.config?.getEnableHooks() ? [hooksCommand] : []), + ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), + rewindCommand, await ideCommand(), initCommand, - mcpCommand, + ...(isNightlyBuild ? [oncallCommand] : []), + ...(this.config?.getMcpEnabled() === false + ? [ + { + name: 'mcp', + description: + 'Manage configured Model Context Protocol (MCP) servers', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage('MCP', this.config ?? undefined), + }), + }, + ] + : [mcpCommand]), memoryCommand, modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), @@ -90,8 +151,31 @@ export class BuiltinCommandLoader implements ICommandLoader { statsCommand, themeCommand, toolsCommand, - ...(this.config?.isSkillsSupportEnabled() ? [skillsCommand] : []), + ...(this.config?.isSkillsSupportEnabled() + ? this.config?.getSkillManager()?.isAdminEnabled() === false + ? [ + { + name: 'skills', + description: 'Manage agent skills', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [], + action: async ( + _context: CommandContext, + ): Promise => ({ + type: 'message', + messageType: 'error', + content: getAdminErrorMessage( + 'Agent skills', + this.config ?? undefined, + ), + }), + }, + ] + : [skillsCommand] + : []), settingsCommand, + shellsCommand, vimCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index cdfef1532e..077b8c45fe 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -1337,4 +1337,69 @@ describe('FileCommandLoader', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('Sanitization', () => { + it('sanitizes command names from filenames containing control characters', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test\twith\nnewlines.toml': 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + // Non-alphanumeric characters (except - and .) become underscores + expect(commands[0].name).toBe('test_with_newlines'); + }); + + it('truncates excessively long filenames', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const longName = 'a'.repeat(60) + '.toml'; + mock({ + [userCommandsDir]: { + [longName]: 'prompt = "Test prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0].name.length).toBe(50); + expect(commands[0].name).toBe('a'.repeat(47) + '...'); + }); + + it('sanitizes descriptions containing newlines and ANSI codes', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': + 'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + // Newlines and tabs become spaces, ANSI is stripped + expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text'); + }); + + it('truncates long descriptions', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + const longDesc = 'd'.repeat(150); + mock({ + [userCommandsDir]: { + 'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`, + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(1); + expect(commands[0].description.length).toBe(100); + expect(commands[0].description).toBe('d'.repeat(97) + '...'); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 688fb0ce0e..5bfbcd8996 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -33,6 +33,7 @@ import { ShellProcessor, } from './prompt-processors/shellProcessor.js'; import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; +import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; @@ -230,15 +231,25 @@ export class FileCommandLoader implements ICommandLoader { ); const baseCommandName = relativePath .split(path.sep) - // Sanitize each path segment to prevent ambiguity. Since ':' is our - // namespace separator, we replace any literal colons in filenames - // with underscores to avoid naming conflicts. - .map((segment) => segment.replaceAll(':', '_')) + // Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores. + // Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts. + .map((segment) => { + let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_'); + + // Truncate excessively long segments to prevent UI overflow + if (sanitized.length > 50) { + sanitized = sanitized.substring(0, 47) + '...'; + } + return sanitized; + }) .join(':'); // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; + + description = sanitizeForDisplay(description, 100); + if (extensionName) { description = `[${extensionName}] ${description}`; } diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 2c93ecf8c0..0f6fb562a8 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -9,7 +9,11 @@ import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from '../../ui/commands/types.js'; import type { Config } from '@google/gemini-cli-core'; -import { ApprovalMode, getShellConfiguration } from '@google/gemini-cli-core'; +import { + ApprovalMode, + getShellConfiguration, + PolicyDecision, +} from '@google/gemini-cli-core'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; import type { PromptPipelineContent } from './types.js'; @@ -60,15 +64,23 @@ const SUCCESS_RESULT = { describe('ShellProcessor', () => { let context: CommandContext; let mockConfig: Partial; + let mockPolicyEngineCheck: Mock; beforeEach(() => { vi.clearAllMocks(); + mockPolicyEngineCheck = vi.fn().mockResolvedValue({ + decision: PolicyDecision.ALLOW, + }); + mockConfig = { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), getShellExecutionConfig: vi.fn().mockReturnValue({}), + getPolicyEngine: vi.fn().mockReturnValue({ + check: mockPolicyEngineCheck, + }), }; context = createMockCommandContext({ @@ -124,9 +136,8 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( 'The current status is: !{git status}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: true, - disallowedCommands: [], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, }); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }), @@ -134,10 +145,12 @@ describe('ShellProcessor', () => { const result = await processor.process(prompt, context); - expect(mockCheckCommandPermissions).toHaveBeenCalledWith( - 'git status', - expect.any(Object), - context.session.sessionShellAllowlist, + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { + name: 'run_shell_command', + args: { command: 'git status' }, + }, + undefined, ); expect(mockShellExecute).toHaveBeenCalledWith( 'git status', @@ -155,9 +168,8 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( '!{git status} in !{pwd}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: true, - disallowedCommands: [], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, }); mockShellExecute @@ -173,7 +185,7 @@ describe('ShellProcessor', () => { const result = await processor.process(prompt, context); - expect(mockCheckCommandPermissions).toHaveBeenCalledTimes(2); + expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(2); expect(mockShellExecute).toHaveBeenCalledTimes(2); expect(result).toEqual([{ text: 'On branch main in /usr/home' }]); }); @@ -183,9 +195,8 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: ['rm -rf /'], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, }); await expect(processor.process(prompt, context)).rejects.toThrow( @@ -198,11 +209,11 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: ['rm -rf /'], + // In YOLO mode, PolicyEngine returns ALLOW + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, }); - // Override the approval mode for this test + // Override the approval mode for this test (though PolicyEngine mock handles the decision) (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), @@ -227,17 +238,14 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something forbidden: !{reboot}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: ['reboot'], - isHardDenial: true, // This is the key difference - blockReason: 'System commands are blocked', + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.DENY, }); // Set approval mode to YOLO (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); await expect(processor.process(prompt, context)).rejects.toThrow( - /Blocked command: "reboot". Reason: System commands are blocked/, + /Blocked command: "reboot". Reason: Blocked by policy/, ); // Ensure it never tried to execute @@ -249,9 +257,8 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( 'Do something dangerous: !{rm -rf /}', ); - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: ['rm -rf /'], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, }); try { @@ -273,14 +280,12 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent( '!{cmd1} and !{cmd2}', ); - mockCheckCommandPermissions.mockImplementation((cmd) => { - if (cmd === 'cmd1') { - return { allAllowed: false, disallowedCommands: ['cmd1'] }; + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; + if (cmd === 'cmd1' || cmd === 'cmd2') { + return { decision: PolicyDecision.ASK_USER }; } - if (cmd === 'cmd2') { - return { allAllowed: false, disallowedCommands: ['cmd2'] }; - } - return { allAllowed: true, disallowedCommands: [] }; + return { decision: PolicyDecision.ALLOW }; }); try { @@ -301,11 +306,12 @@ describe('ShellProcessor', () => { 'First: !{echo "hello"}, Second: !{rm -rf /}', ); - mockCheckCommandPermissions.mockImplementation((cmd) => { + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; if (cmd.includes('rm')) { - return { allAllowed: false, disallowedCommands: [cmd] }; + return { decision: PolicyDecision.ASK_USER }; } - return { allAllowed: true, disallowedCommands: [] }; + return { decision: PolicyDecision.ALLOW }; }); await expect(processor.process(prompt, context)).rejects.toThrow( @@ -322,10 +328,13 @@ describe('ShellProcessor', () => { 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}', ); - mockCheckCommandPermissions.mockImplementation((cmd) => ({ - allAllowed: !cmd.includes('rm'), - disallowedCommands: cmd.includes('rm') ? [cmd] : [], - })); + mockPolicyEngineCheck.mockImplementation(async (toolCall) => { + const cmd = toolCall.args.command; + if (cmd.includes('rm')) { + return { decision: PolicyDecision.ASK_USER }; + } + return { decision: PolicyDecision.ALLOW }; + }); try { await processor.process(prompt, context); @@ -344,13 +353,12 @@ describe('ShellProcessor', () => { 'Run !{cmd1} and !{cmd2}', ); - // Add commands to the session allowlist + // Add commands to the session allowlist (conceptually, in this test we just mock the engine allowing them) context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']); // checkCommandPermissions should now pass for these - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: true, - disallowedCommands: [], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, }); mockShellExecute @@ -363,20 +371,58 @@ describe('ShellProcessor', () => { const result = await processor.process(prompt, context); - expect(mockCheckCommandPermissions).toHaveBeenCalledWith( - 'cmd1', - expect.any(Object), - context.session.sessionShellAllowlist, - ); - expect(mockCheckCommandPermissions).toHaveBeenCalledWith( - 'cmd2', - expect.any(Object), - context.session.sessionShellAllowlist, - ); + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); expect(mockShellExecute).toHaveBeenCalledTimes(2); expect(result).toEqual([{ text: 'Run output1 and output2' }]); }); + it('should support the full confirmation flow (Ask -> Approve -> Retry)', async () => { + // 1. Initial State: Command NOT allowed + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = + createPromptPipelineContent('!{echo "once"}'); + + // Policy Engine says ASK_USER + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + + // 2. First Attempt: processing should fail with ConfirmationRequiredError + try { + await processor.process(prompt, context); + expect.fail('Should have thrown ConfirmationRequiredError'); + } catch (e) { + expect(e).toBeInstanceOf(ConfirmationRequiredError); + expect(mockPolicyEngineCheck).toHaveBeenCalledTimes(1); + } + + // 3. User Approves: Add to session allowlist (simulating UI action) + context.session.sessionShellAllowlist.add('echo "once"'); + + // 4. Retry: calling process() again with the same context + // Reset mocks to ensure we track new calls cleanly + mockPolicyEngineCheck.mockClear(); + + // Mock successful execution + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'once' }), + }); + + const result = await processor.process(prompt, context); + + // 5. Verify Success AND Policy Engine Bypass + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); + expect(mockShellExecute).toHaveBeenCalledWith( + 'echo "once"', + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + expect(result).toEqual([{ text: 'once' }]); + }); + it('should trim whitespace from the command inside the injection before interpolation', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( @@ -389,9 +435,8 @@ describe('ShellProcessor', () => { const expectedCommand = `ls ${expectedEscapedArgs} -l`; - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: true, - disallowedCommands: [], + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ALLOW, }); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }), @@ -399,10 +444,9 @@ describe('ShellProcessor', () => { await processor.process(prompt, context); - expect(mockCheckCommandPermissions).toHaveBeenCalledWith( - expectedCommand, - expect.any(Object), - context.session.sessionShellAllowlist, + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { name: 'run_shell_command', args: { command: expectedCommand } }, + undefined, ); expect(mockShellExecute).toHaveBeenCalledWith( expectedCommand, @@ -421,7 +465,7 @@ describe('ShellProcessor', () => { const result = await processor.process(prompt, context); - expect(mockCheckCommandPermissions).not.toHaveBeenCalled(); + expect(mockPolicyEngineCheck).not.toHaveBeenCalled(); expect(mockShellExecute).not.toHaveBeenCalled(); // It replaces !{} with an empty string. @@ -615,20 +659,20 @@ describe('ShellProcessor', () => { const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: [expectedResolvedCommand], - isHardDenial: false, + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.ASK_USER, }); await expect(processor.process(prompt, context)).rejects.toThrow( ConfirmationRequiredError, ); - expect(mockCheckCommandPermissions).toHaveBeenCalledWith( - expectedResolvedCommand, - expect.any(Object), - context.session.sessionShellAllowlist, + expect(mockPolicyEngineCheck).toHaveBeenCalledWith( + { + name: 'run_shell_command', + args: { command: expectedResolvedCommand }, + }, + undefined, ); }); @@ -638,15 +682,12 @@ describe('ShellProcessor', () => { createPromptPipelineContent('!{rm {{args}}}'); const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs); const expectedResolvedCommand = `rm ${expectedEscapedArgs}`; - mockCheckCommandPermissions.mockReturnValue({ - allAllowed: false, - disallowedCommands: [expectedResolvedCommand], - isHardDenial: true, - blockReason: 'It is forbidden.', + mockPolicyEngineCheck.mockResolvedValue({ + decision: PolicyDecision.DENY, }); await expect(processor.process(prompt, context)).rejects.toThrow( - `Blocked command: "${expectedResolvedCommand}". Reason: It is forbidden.`, + `Blocked command: "${expectedResolvedCommand}". Reason: Blocked by policy.`, ); }); }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 350421c1c5..4c8369f664 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -5,12 +5,11 @@ */ import { - ApprovalMode, - checkCommandPermissions, escapeShellArg, getShellConfiguration, ShellExecutionService, flatMapTextParts, + PolicyDecision, } from '@google/gemini-cli-core'; import type { CommandContext } from '../../ui/commands/types.js'; @@ -81,7 +80,6 @@ export class ShellProcessor implements IPromptProcessor { `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, ); } - const { sessionShellAllowlist } = context.session; const injections = extractInjections( prompt, @@ -121,21 +119,25 @@ export class ShellProcessor implements IPromptProcessor { if (!command) continue; + if (context.session.sessionShellAllowlist?.has(command)) { + continue; + } + // Security check on the final, escaped command string. - const { allAllowed, disallowedCommands, blockReason, isHardDenial } = - checkCommandPermissions(command, config, sessionShellAllowlist); + const { decision } = await config.getPolicyEngine().check( + { + name: 'run_shell_command', + args: { command }, + }, + undefined, + ); - if (!allAllowed) { - if (isHardDenial) { - throw new Error( - `${this.commandName} cannot be run. Blocked command: "${command}". Reason: ${blockReason || 'Blocked by configuration.'}`, - ); - } - - // If not a hard denial, respect YOLO mode and auto-approve. - if (config.getApprovalMode() !== ApprovalMode.YOLO) { - disallowedCommands.forEach((uc) => commandsToConfirm.add(uc)); - } + if (decision === PolicyDecision.DENY) { + throw new Error( + `${this.commandName} cannot be run. Blocked command: "${command}". Reason: Blocked by policy.`, + ); + } else if (decision === PolicyDecision.ASK_USER) { + commandsToConfirm.add(command); } } diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index f7ad425f06..56d02e7053 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -9,12 +9,13 @@ import * as path from 'node:path'; import { type MCPServerConfig, type ExtensionInstallMetadata, + type ExtensionSetting, + type CustomTheme, } from '@google/gemini-cli-core'; import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, } from '../config/extensions/variables.js'; -import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; export function createExtension({ extensionsDir = 'extensions-dir', @@ -25,12 +26,20 @@ export function createExtension({ mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, + themes = undefined as CustomTheme[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers, settings }), + JSON.stringify({ + name, + version, + contextFileName, + mcpServers, + settings, + themes, + }), ); if (addContextFile) { diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 37a0edcb19..928d04c7a1 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -7,6 +7,7 @@ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; import type { LoadedSettings } from '../config/settings.js'; +import { mergeSettings } from '../config/settings.js'; import type { GitService } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; @@ -27,6 +28,8 @@ type DeepPartial = T extends object export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const defaultMocks: CommandContext = { invocation: { raw: '', @@ -35,7 +38,11 @@ export const createMockCommandContext = ( }, services: { config: null, - settings: { merged: {} } as LoadedSettings, + settings: { + merged: defaultMergedSettings, + setValue: vi.fn(), + forScope: vi.fn().mockReturnValue({ settings: {} }), + } as unknown as LoadedSettings, git: undefined as GitService | undefined, logger: { log: vi.fn(), @@ -54,6 +61,8 @@ export const createMockCommandContext = ( loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), + openAgentConfigDialog: vi.fn(), + closeAgentConfigDialog: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/cli/src/test-utils/persistentStateFake.ts b/packages/cli/src/test-utils/persistentStateFake.ts new file mode 100644 index 0000000000..512b25b95b --- /dev/null +++ b/packages/cli/src/test-utils/persistentStateFake.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; + +/** + * A fake implementation of PersistentState for testing. + * It keeps state in memory and provides spies for get and set. + */ +export class FakePersistentState { + private data: Record = {}; + + get = vi.fn().mockImplementation((key: string) => this.data[key]); + + set = vi.fn().mockImplementation((key: string, value: unknown) => { + this.data[key] = value; + }); + + /** + * Helper to reset the fake state between tests. + */ + reset() { + this.data = {}; + this.get.mockClear(); + this.set.mockClear(); + } + + /** + * Helper to clear mock call history without wiping data. + */ + mockClear() { + this.get.mockClear(); + this.set.mockClear(); + } + + /** + * Helper to set initial data for the fake. + */ + setData(data: Record) { + this.data = { ...data }; + } +} diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index d02b5af8ae..a9e997a859 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -9,14 +9,13 @@ import { Box } from 'ink'; 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 { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; -import { StreamingState } from '../ui/types.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js'; -import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { MouseProvider } from '../ui/contexts/MouseContext.js'; import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; @@ -25,8 +24,25 @@ import { type UIActions, UIActionsContext, } from '../ui/contexts/UIActionsContext.js'; +import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; +import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; +import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; -import { type Config } from '@google/gemini-cli-core'; +import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; +import { FakePersistentState } from './persistentStateFake.js'; +import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; + +export const persistentStateMock = new FakePersistentState(); + +vi.mock('../utils/persistentState.js', () => ({ + persistentState: persistentStateMock, +})); + +vi.mock('../ui/utils/terminalUtils.js', () => ({ + isLowColorDepth: vi.fn(() => false), + getColorDepth: vi.fn(() => 24), + isITerm2: vi.fn(() => false), +})); // Wrapper around ink-testing-library's render that ensures act() is called export const render = ( @@ -83,21 +99,27 @@ export const simulateClick = async ( }); }; -const mockConfig = { - getModel: () => 'gemini-pro', - getTargetDir: () => - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', - getDebugMode: () => false, - isTrustedFolder: () => true, - getIdeMode: () => false, - getEnableInteractiveShell: () => true, - getPreviewFeatures: () => false, +let mockConfigInternal: Config | undefined; + +const getMockConfigInternal = (): Config => { + if (!mockConfigInternal) { + mockConfigInternal = makeFakeConfig({ + targetDir: os.tmpdir(), + enableEventDrivenScheduler: true, + }); + } + return mockConfigInternal; }; -const configProxy = new Proxy(mockConfig, { - get(target, prop) { - if (prop in target) { - return target[prop as keyof typeof target]; +const configProxy = new Proxy({} as Config, { + get(_target, prop) { + if (prop === 'getTargetDir') { + return () => + '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long'; + } + const internal = getMockConfigInternal(); + if (prop in internal) { + return internal[prop as keyof typeof internal]; } throw new Error(`mockConfig does not have property ${String(prop)}`); }, @@ -109,7 +131,7 @@ export const mockSettings = new LoadedSettings( { path: '', settings: {}, originalSettings: {} }, { path: '', settings: {}, originalSettings: {} }, true, - new Set(), + [], ); export const createMockSettings = ( @@ -122,7 +144,7 @@ export const createMockSettings = ( { path: '', settings, originalSettings: settings }, { path: '', settings: {}, originalSettings: {} }, true, - new Set(), + [], ); }; @@ -131,10 +153,18 @@ export const createMockSettings = ( const baseMockUiState = { renderMarkdown: true, streamingState: StreamingState.Idle, - mainAreaWidth: 100, terminalWidth: 120, + terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: undefined, + activePtyId: undefined, + backgroundShells: new Map(), + backgroundShellHeight: 0, +}; + +export const mockAppState: AppState = { + version: '1.2.3', + startupWarnings: [], }; const mockUIActions: UIActions = { @@ -149,6 +179,8 @@ const mockUIActions: UIActions = { exitPrivacyNotice: vi.fn(), closeSettingsDialog: vi.fn(), closeModelDialog: vi.fn(), + openAgentConfigDialog: vi.fn(), + closeAgentConfigDialog: vi.fn(), openPermissionsDialog: vi.fn(), openSessionBrowser: vi.fn(), closeSessionBrowser: vi.fn(), @@ -165,12 +197,20 @@ const mockUIActions: UIActions = { handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), + handleValidationChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), 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(), }; export const renderWithProviders = ( @@ -184,6 +224,8 @@ export const renderWithProviders = ( config = configProxy as unknown as Config, useAlternateBuffer = true, uiActions, + persistentState, + appState = mockAppState, }: { shellFocus?: boolean; settings?: LoadedSettings; @@ -193,6 +235,11 @@ export const renderWithProviders = ( config?: Config; useAlternateBuffer?: boolean; uiActions?: Partial; + persistentState?: { + get?: typeof persistentStateMock.get; + set?: typeof persistentStateMock.set; + }; + appState?: AppState; } = {}, ): ReturnType & { simulateClick: typeof simulateClick } => { const baseState: UIState = new Proxy( @@ -213,6 +260,15 @@ export const renderWithProviders = ( }, ) as UIState; + if (persistentState?.get) { + persistentStateMock.get.mockImplementation(persistentState.get); + } + if (persistentState?.set) { + persistentStateMock.set.mockImplementation(persistentState.set); + } + + persistentStateMock.mockClear(); + const terminalWidth = width ?? baseState.terminalWidth; let finalSettings = settings; if (useAlternateBuffer !== undefined) { @@ -225,7 +281,7 @@ export const renderWithProviders = ( }); } - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings); + const mainAreaWidth = terminalWidth; const finalUiState = { ...baseState, @@ -235,35 +291,54 @@ export const renderWithProviders = ( const finalUIActions = { ...mockUIActions, ...uiActions }; + const allToolCalls = (finalUiState.pendingHistoryItems || []) + .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') + .flatMap((item) => item.tools); + const renderResult = render( - - - - - - - - - - - - {component} - - - - - - - - - - - , + + + + + + + + + + + + + + + {component} + + + + + + + + + + + + + + , terminalWidth, ); @@ -348,10 +423,13 @@ export function renderHookWithProviders( const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; + let forceUpdateFn: (() => void) | undefined; function TestComponent({ initialProps }: { initialProps: Props }) { const [props, setProps] = useState(initialProps); + const [, forceUpdate] = useState(0); setPropsFn = setProps; + forceUpdateFn = () => forceUpdate((n) => n + 1); result.current = renderCallback(props); return null; } @@ -371,8 +449,10 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { - if (setPropsFn && newProps) { - setPropsFn(newProps); + if (arguments.length > 0 && setPropsFn) { + setPropsFn(newProps as Props); + } else if (forceUpdateFn) { + forceUpdateFn(); } }); } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 64f42fae11..bd663ba195 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -4,21 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { render } from '../test-utils/render.js'; -import { Text, useIsScreenReaderEnabled } from 'ink'; -import { makeFakeConfig } from '@google/gemini-cli-core'; +import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; +import type React from 'react'; +import { renderWithProviders } from '../test-utils/render.js'; +import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; -import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; -import { StreamingState } from './types.js'; -import { ConfigContext } from './contexts/ConfigContext.js'; -import { AppContext, type AppState } from './contexts/AppContext.js'; -import { SettingsContext } from './contexts/SettingsContext.js'; -import { - type SettingScope, - LoadedSettings, - type SettingsFile, -} from '../config/settings.js'; +import { type UIState } from './contexts/UIStateContext.js'; +import { StreamingState, ToolCallStatus } from './types.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; vi.mock('ink', async (importOriginal) => { const original = await importOriginal(); @@ -28,10 +21,6 @@ vi.mock('ink', async (importOriginal) => { }; }); -vi.mock('./components/MainContent.js', () => ({ - MainContent: () => MainContent, -})); - vi.mock('./components/DialogManager.js', () => ({ DialogManager: () => DialogManager, })); @@ -40,9 +29,16 @@ vi.mock('./components/Composer.js', () => ({ Composer: () => Composer, })); -vi.mock('./components/Notifications.js', () => ({ - Notifications: () => Notifications, -})); +vi.mock('./components/Notifications.js', async () => { + const { Text, Box } = await import('ink'); + return { + Notifications: () => ( + + Notifications + + ), + }; +}); vi.mock('./components/QuittingDisplay.js', () => ({ QuittingDisplay: () => Quitting..., @@ -52,17 +48,32 @@ vi.mock('./components/HistoryItemDisplay.js', () => ({ HistoryItemDisplay: () => HistoryItemDisplay, })); -vi.mock('./components/Footer.js', () => ({ - Footer: () => Footer, -})); +vi.mock('./components/Footer.js', async () => { + const { Text, Box } = await import('ink'); + return { + Footer: () => ( + + Footer + + ), + }; +}); describe('App', () => { + beforeEach(() => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + }); + const mockUIState: Partial = { streamingState: StreamingState.Idle, quittingMessages: null, dialogsVisible: false, - mainControlsRef: { current: null }, - rootUiRef: { current: null }, + mainControlsRef: { + current: null, + } as unknown as React.MutableRefObject, + rootUiRef: { + current: null, + } as unknown as React.MutableRefObject, historyManager: { addItem: vi.fn(), history: [], @@ -72,51 +83,21 @@ describe('App', () => { }, history: [], pendingHistoryItems: [], + pendingGeminiHistoryItems: [], bannerData: { defaultText: 'Mock Banner Text', warningText: '', }, + backgroundShells: new Map(), }; - const mockConfig = makeFakeConfig(); - - const mockSettingsFile: SettingsFile = { - settings: {}, - originalSettings: {}, - path: '/mock/path', - }; - - const mockLoadedSettings = new LoadedSettings( - mockSettingsFile, - mockSettingsFile, - mockSettingsFile, - mockSettingsFile, - true, - new Set(), - ); - - const mockAppState: AppState = { - version: '1.0.0', - startupWarnings: [], - }; - - const renderWithProviders = (ui: React.ReactElement, state: UIState) => - render( - - - - - {ui} - - - - , - ); - it('should render main content and composer when not quitting', () => { - const { lastFrame } = renderWithProviders(, mockUIState as UIState); + const { lastFrame } = renderWithProviders(, { + uiState: mockUIState, + useAlternateBuffer: false, + }); - expect(lastFrame()).toContain('MainContent'); + expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); }); @@ -127,7 +108,10 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame } = renderWithProviders(, quittingUIState); + const { lastFrame } = renderWithProviders(, { + uiState: quittingUIState, + useAlternateBuffer: false, + }); expect(lastFrame()).toContain('Quitting...'); }); @@ -140,15 +124,13 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - mockLoadedSettings.merged.ui = { useAlternateBuffer: true }; - - const { lastFrame } = renderWithProviders(, quittingUIState); + const { lastFrame } = renderWithProviders(, { + uiState: quittingUIState, + useAlternateBuffer: true, + }); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); - - // Reset settings - mockLoadedSettings.merged.ui = { useAlternateBuffer: false }; }); it('should render dialog manager when dialogs are visible', () => { @@ -157,9 +139,11 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame } = renderWithProviders(, dialogUIState); + const { lastFrame } = renderWithProviders(, { + uiState: dialogUIState, + }); - expect(lastFrame()).toContain('MainContent'); + expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('DialogManager'); }); @@ -176,7 +160,9 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame } = renderWithProviders(, uiState); + const { lastFrame } = renderWithProviders(, { + uiState, + }); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); }, @@ -185,37 +171,88 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame } = renderWithProviders(, mockUIState as UIState); + const { lastFrame } = renderWithProviders(, { + uiState: mockUIState, + }); - expect(lastFrame()).toContain( - 'Notifications\nFooter\nMainContent\nComposer', - ); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Footer'); + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Composer'); }); it('should render DefaultAppLayout when screen reader is not enabled', () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame } = renderWithProviders(, mockUIState as UIState); + const { lastFrame } = renderWithProviders(, { + uiState: mockUIState, + }); - expect(lastFrame()).toContain('MainContent\nNotifications\nComposer'); + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Composer'); + }); + + it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => { + (useIsScreenReaderEnabled as Mock).mockReturnValue(false); + + const toolCalls = [ + { + callId: 'call-1', + name: 'ls', + description: 'list directory', + status: ToolCallStatus.Confirming, + resultDisplay: '', + confirmationDetails: { + type: 'exec' as const, + title: 'Confirm execution', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ]; + + const stateWithConfirmingTool = { + ...mockUIState, + pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }], + } as UIState; + + const configWithExperiment = makeFakeConfig(); + vi.spyOn( + configWithExperiment, + 'isEventDrivenSchedulerEnabled', + ).mockReturnValue(true); + vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); + + const { lastFrame } = renderWithProviders(, { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + }); + + expect(lastFrame()).toContain('Tips for getting started'); + expect(lastFrame()).toContain('Notifications'); + expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue + expect(lastFrame()).toContain('Composer'); + expect(lastFrame()).toMatchSnapshot(); }); describe('Snapshots', () => { it('renders default layout correctly', () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame } = renderWithProviders( - , - mockUIState as UIState, - ); + const { lastFrame } = renderWithProviders(, { + uiState: mockUIState, + }); expect(lastFrame()).toMatchSnapshot(); }); it('renders screen reader layout correctly', () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame } = renderWithProviders( - , - mockUIState as UIState, - ); + const { lastFrame } = renderWithProviders(, { + uiState: mockUIState, + }); expect(lastFrame()).toMatchSnapshot(); }); @@ -224,7 +261,9 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame } = renderWithProviders(, dialogUIState); + const { lastFrame } = renderWithProviders(, { + uiState: dialogUIState, + }); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 1d7fce5aa9..ef0a24cd92 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -20,6 +20,8 @@ import { cleanup } from 'ink-testing-library'; 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, @@ -27,6 +29,9 @@ import { type UserFeedbackPayload, type ResumedSessionData, AuthType, + UserAccountManager, + type ContentGeneratorConfig, + type AgentDefinition, } from '@google/gemini-cli-core'; // Mock coreEvents @@ -42,6 +47,11 @@ const mockIdeClient = vi.hoisted(() => ({ getInstance: vi.fn().mockReturnValue(new Promise(() => {})), })); +// Mock UserAccountManager +const mockUserAccountManager = vi.hoisted(() => ({ + getCachedGoogleAccount: vi.fn().mockReturnValue(null), +})); + // Mock stdout const mocks = vi.hoisted(() => ({ mockStdout: { write: vi.fn() }, @@ -71,6 +81,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { })), enableMouseEvents: vi.fn(), disableMouseEvents: vi.fn(), + UserAccountManager: vi + .fn() + .mockImplementation(() => mockUserAccountManager), FileDiscoveryService: vi.fn().mockImplementation(() => ({ initialize: vi.fn(), })), @@ -82,7 +95,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import type { LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings, mergeSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; @@ -136,13 +149,14 @@ vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); -vi.mock('./hooks/useAutoAcceptIndicator.js'); +vi.mock('./hooks/useApprovalModeIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); vi.mock('./hooks/useInputHistoryStore.js'); +vi.mock('./hooks/useHookDisplayState.js'); // Mock external utilities vi.mock('../utils/events.js'); @@ -163,7 +177,7 @@ import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useSessionStats } from './contexts/SessionContext.js'; @@ -171,6 +185,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; +import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; @@ -181,6 +196,7 @@ import { disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; +import { WARNING_PROMPT_DURATION_MS } from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -234,7 +250,7 @@ describe('AppContainer State Management', () => { const mockedUseFolderTrust = useFolderTrust as Mock; const mockedUseIdeTrustListener = useIdeTrustListener as Mock; const mockedUseMessageQueue = useMessageQueue as Mock; - const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock; + const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; const mockedUseGitBranchName = useGitBranchName as Mock; const mockedUseVimMode = useVimMode as Mock; const mockedUseSessionStats = useSessionStats as Mock; @@ -243,24 +259,37 @@ describe('AppContainer State Management', () => { const mockedUseLoadingIndicator = useLoadingIndicator as Mock; const mockedUseKeypress = useKeypress as Mock; const mockedUseInputHistoryStore = useInputHistoryStore as Mock; + const mockedUseHookDisplayState = useHookDisplayState 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(); + mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); + // Initialize mock stdout for terminal title tests + mocks.mockStdout.write.mockClear(); - // Mock computeWindowTitle function to centralize title logic testing - vi.mock('../utils/windowTitle.js', async () => ({ - computeWindowTitle: vi.fn( - (folderName: string) => - // Default behavior: return "Gemini - {folderName}" unless CLI_TITLE is set - process.env['CLI_TITLE'] || `Gemini - ${folderName}`, - ), - })); - capturedUIState = null!; - capturedUIActions = null!; // **Provide a default return value for EVERY mocked hook.** mockedUseQuotaAndFallback.mockReturnValue({ @@ -315,14 +344,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, @@ -339,7 +361,7 @@ describe('AppContainer State Management', () => { clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), }); - mockedUseAutoAcceptIndicator.mockReturnValue(false); + mockedUseApprovalModeIndicator.mockReturnValue(false); mockedUseGitBranchName.mockReturnValue('main'); mockedUseVimMode.mockReturnValue({ isVimEnabled: false, @@ -363,6 +385,7 @@ describe('AppContainer State Management', () => { elapsedTime: '0.0s', currentLoadingPhrase: '', }); + mockedUseHookDisplayState.mockReturnValue([]); // Mock Config mockConfig = makeFakeConfig(); @@ -383,16 +406,20 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockSettings = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', ui: { + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, + showUserIdentity: true, }, useAlternateBuffer: false, }, @@ -409,6 +436,7 @@ describe('AppContainer State Management', () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); describe('Basic Rendering', () => { @@ -463,6 +491,162 @@ describe('AppContainer State Management', () => { }); }); + describe('Authentication Check', () => { + it('displays correct message for LOGIN_WITH_GOOGLE auth type', async () => { + // Explicitly mock implementation to ensure we control the instance + (UserAccountManager as unknown as Mock).mockImplementation( + () => mockUserAccountManager, + ); + + mockUserAccountManager.getCachedGoogleAccount.mockReturnValue( + 'test@example.com', + ); + const mockAddItem = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: mockAddItem, + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + + // Explicitly enable showUserIdentity + mockSettings.merged.ui = { + ...mockSettings.merged.ui, + showUserIdentity: true, + }; + + // Need to ensure config.getContentGeneratorConfig() returns appropriate authType + const authConfig = makeFakeConfig(); + // Mock getTargetDir as well since makeFakeConfig might not set it up fully for the component + vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + + vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + } as unknown as ContentGeneratorConfig); + vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier'); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ config: authConfig }); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(UserAccountManager).toHaveBeenCalled(); + expect( + mockUserAccountManager.getCachedGoogleAccount, + ).toHaveBeenCalled(); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Logged in with Google: test@example.com (Plan: Standard Tier)', + }), + ); + }); + await act(async () => { + unmount!(); + }); + }); + it('displays correct message for USE_GEMINI auth type', async () => { + // Explicitly mock implementation to ensure we control the instance + (UserAccountManager as unknown as Mock).mockImplementation( + () => mockUserAccountManager, + ); + + mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(null); + const mockAddItem = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: mockAddItem, + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + + const authConfig = makeFakeConfig(); + vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + + vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: AuthType.USE_GEMINI, + } as unknown as ContentGeneratorConfig); + vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier'); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ config: authConfig }); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Authenticated with gemini-api-key'), + }), + ); + }); + await act(async () => { + unmount!(); + }); + }); + + it('does not display authentication message if showUserIdentity is false', async () => { + mockUserAccountManager.getCachedGoogleAccount.mockReturnValue( + 'test@example.com', + ); + const mockAddItem = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem: mockAddItem, + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + + mockSettings.merged.ui = { + ...mockSettings.merged.ui, + showUserIdentity: false, + }; + + const authConfig = makeFakeConfig(); + vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + + vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + } as unknown as ContentGeneratorConfig); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ config: authConfig }); + unmount = result.unmount; + }); + + // Give it some time to potentially call addItem + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + }), + ); + + await act(async () => { + unmount!(); + }); + }); + }); + describe('Context Providers', () => { it('provides AppContext with correct values', async () => { let unmount: () => void; @@ -509,8 +693,10 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsAllHidden = { merged: { + ...defaultMergedSettings, hideBanner: true, hideFooter: true, hideTips: true, @@ -528,8 +714,10 @@ describe('AppContainer State Management', () => { }); it('handles settings with memory usage enabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsWithMemory = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, @@ -576,7 +764,7 @@ describe('AppContainer State Management', () => { it('handles undefined settings gracefully', async () => { const undefinedSettings = { - merged: {}, + merged: mergeSettings({}, {}, {}, {}, true), } as LoadedSettings; let unmount: () => void; @@ -991,42 +1179,92 @@ describe('AppContainer State Management', () => { expect(stdout).toBe(mocks.mockStdout); }); - it('should not update terminal title when showStatusInTitle is false', () => { + it('should update terminal title with Workingโ€ฆ when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithShowStatusFalse = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, }, } as unknown as LoadedSettings; + // Mock the streaming state as Active + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Some thought' }, + }); + // Act: Render the container const { unmount } = renderAppContainer({ settings: mockSettingsWithShowStatusFalse, }); - // Assert: Check that no title-related writes occurred + // Assert: Check that title was updated with "Workingโ€ฆ" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); - expect(titleWrites).toHaveLength(0); + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'โœฆ Workingโ€ฆ (workspace)'.padEnd(80, ' ')}\x07`, + ); unmount(); }); - it('should not update terminal title when hideWindowTitle is true', () => { - // Arrange: Set up mock settings with hideWindowTitle enabled - const mockSettingsWithHideTitleTrue = { + it('should use legacy terminal title when dynamicWindowTitle is false', () => { + // Arrange: Set up mock settings with dynamicWindowTitle disabled + const mockSettingsWithDynamicTitleFalse = { ...mockSettings, merged: { ...mockSettings.merged, ui: { ...mockSettings.merged.ui, + dynamicWindowTitle: false, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock the streaming state + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Some thought' }, + }); + + // Act: Render the container + const { unmount } = renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }); + + // Assert: Check that legacy title was used + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + + expect(titleWrites).toHaveLength(1); + expect(titleWrites[0][0]).toBe( + `\x1b]0;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`, + ); + unmount(); + }); + + it('should not update terminal title when hideWindowTitle is true', () => { + // Arrange: Set up mock settings with hideWindowTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithHideTitleTrue = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: true, }, @@ -1040,7 +1278,7 @@ describe('AppContainer State Management', () => { // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(0); @@ -1049,12 +1287,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1064,12 +1303,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 @@ -1077,26 +1313,27 @@ describe('AppContainer State Management', () => { settings: mockSettingsWithTitleEnabled, }); - // Assert: Check that title was updated with thought subject + // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + `\x1b]0;${`โœฆ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, ); unmount(); }); it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1104,14 +1341,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({ @@ -1120,24 +1350,25 @@ describe('AppContainer State Management', () => { // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`, + `\x1b]0;${'โ—‡ Ready (workspace)'.padEnd(80, ' ')}\x07`, ); unmount(); }); - it('should update terminal title when in WaitingForConfirmation state with thought subject', () => { + it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1147,39 +1378,316 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; mockedUseGeminiStream.mockReturnValue({ - streamingState: 'waitingForConfirmation', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'waiting_for_confirmation', thought: { subject: thoughtSubject }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + unmount = result.unmount; }); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, + `\x1b]0;${'โœ‹ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount(); + unmount!(); + }); + + describe('Shell Focus Action Required', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show Action Required in title after a delay when shell is awaiting focus', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty but not focused + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [], + activePtyId: 'pty-1', + lastOutputTime: startTime + 100, // Trigger aggressive delay + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + // Act: Render the container (embeddedShellFocused is false by default in state) + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Initially it should show the working status + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + expect(titleWrites[titleWrites.length - 1][0]).toContain( + 'โœฆ Executing shell command', + ); + + // Fast-forward time by 40 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(40000); + }); + + // Now it should show Action Required + const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; + expect(lastTitle).toContain('โœ‹ Action Required'); + + unmount(); + }); + + it('should show Workingโ€ฆ in title for redirected commands after 2 mins', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty with redirection active + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [ + { + request: { + name: 'run_shell_command', + args: { command: 'ls > out' }, + }, + status: 'executing', + } as unknown as TrackedToolCall, + ], + activePtyId: 'pty-1', + lastOutputTime: startTime, + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Fast-forward time by 65 seconds - should still NOT be Action Required + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( + 'โœ‹ Action Required', + ); + + // Fast-forward to 2 minutes (120000ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + + const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( + 'โฒ Workingโ€ฆ', + ); + + unmount(); + }); + + it('should show Workingโ€ฆ in title for silent non-redirected commands after 1 min', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty with NO output since operation started (silent) + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + pendingToolCalls: [], + activePtyId: 'pty-1', + lastOutputTime: startTime, // lastOutputTime <= operationStartTime + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Fast-forward time by 65 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWrites[titleWrites.length - 1][0]; + // Should show Workingโ€ฆ (โฒ) instead of Action Required (โœ‹) + expect(lastTitle).toContain('โฒ Workingโ€ฆ'); + + unmount(); + }); + + it('should NOT show Action Required in title if shell is streaming output', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty but not focused + let lastOutputTime = startTime + 1000; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + activePtyId: 'pty-1', + lastOutputTime, + })); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + // Act: Render the container + const { unmount, rerender } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Fast-forward time by 20 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + // Update lastOutputTime to simulate new output + lastOutputTime = startTime + 21000; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', + thought: { subject: 'Executing shell command' }, + activePtyId: 'pty-1', + lastOutputTime, + })); + + // Rerender to propagate the new lastOutputTime + await act(async () => { + rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); + }); + + // Fast-forward time by another 20 seconds + // Total time elapsed: 40s. + // Time since last output: 20s. + // It should NOT show Action Required yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = + titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; + expect(lastTitle).not.toContain('โœ‹ Action Required'); + expect(lastTitle).toContain('โœฆ Executing shell command'); + + // Fast-forward another 40 seconds (Total 60s since last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(40000); + }); + + // Now it SHOULD show Action Required + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; + expect(lastTitleFinal).toContain('โœ‹ Action Required'); + + unmount(); + }); }); it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1189,12 +1697,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 @@ -1204,28 +1709,26 @@ describe('AppContainer State Management', () => { // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); const calledWith = titleWrites[0][0]; - const expectedTitle = shortTitle.padEnd(80, ' '); - - expect(calledWith).toContain(shortTitle); - expect(calledWith).toContain('\x1b]2;'); - expect(calledWith).toContain('\x07'); - expect(calledWith).toBe('\x1b]2;' + expectedTitle + '\x07'); + const expectedTitle = `โœฆ ${shortTitle} (workspace)`.padEnd(80, ' '); + const expectedEscapeSequence = `\x1b]0;${expectedTitle}\x07`; + expect(calledWith).toBe(expectedEscapeSequence); unmount(); }); it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1235,12 +1738,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 @@ -1250,24 +1750,24 @@ describe('AppContainer State Management', () => { // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); - const expectedEscapeSequence = `\x1b]2;${title.padEnd(80, ' ')}\x07`; + const expectedEscapeSequence = `\x1b]0;${`โœฆ ${title} (workspace)`.padEnd(80, ' ')}\x07`; expect(titleWrites[0][0]).toBe(expectedEscapeSequence); unmount(); }); it('should use CLI_TITLE environment variable when set', () => { - // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { + // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) + const mockSettingsWithTitleDisabled = { ...mockSettings, merged: { ...mockSettings.merged, ui: { ...mockSettings.merged.ui, - showStatusInTitle: true, + showStatusInTitle: false, hideWindowTitle: false, }, }, @@ -1276,29 +1776,25 @@ describe('AppContainer State Management', () => { // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); - // Mock the streaming state as Idle with no thought + // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: 'responding', }); // Act: Render the container const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, + settings: mockSettingsWithTitleDisabled, }); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), + call[0].includes('\x1b]0;'), ); expect(titleWrites).toHaveLength(1); expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`, + `\x1b]0;${'โœฆ Workingโ€ฆ (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, ); unmount(); }); @@ -1311,6 +1807,7 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it('should set and clear the queue error message after a timeout', async () => { @@ -1384,12 +1881,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', }); @@ -1430,9 +1922,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'c', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, ...key, } as Key); }); @@ -1460,11 +1953,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, }); @@ -1479,16 +1968,14 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); 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(); @@ -1523,7 +2010,7 @@ describe('AppContainer State Management', () => { // Advance timer past the reset threshold act(() => { - vi.advanceTimersByTime(1001); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey({ name: 'c', ctrl: true }); @@ -1568,7 +2055,7 @@ describe('AppContainer State Management', () => { // Advance timer past the reset threshold act(() => { - vi.advanceTimersByTime(1001); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey({ name: 'd', ctrl: true }); @@ -1585,12 +2072,13 @@ describe('AppContainer State Management', () => { const setupCopyModeTest = async (isAlternateMode = false) => { // Update settings for this test run + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, useAlternateBuffer: isAlternateMode, }, }, @@ -1616,6 +2104,7 @@ describe('AppContainer State Management', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe.each([ @@ -1637,10 +2126,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, - paste: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1664,10 +2153,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, - paste: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1679,10 +2168,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'any', // Any key should exit copy mode - ctrl: false, - meta: false, shift: false, - paste: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'a', }); @@ -1700,10 +2189,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, - paste: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1716,10 +2205,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'a', - ctrl: false, - meta: false, shift: false, - paste: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'a', }); @@ -1778,6 +2267,77 @@ describe('AppContainer State Management', () => { }); }); + describe('Agent Configuration Dialog Integration', () => { + it('should initialize with dialog closed and no agent selected', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount!(); + }); + + it('should update state when openAgentConfigDialog is called', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + expect(capturedUIState.selectedAgentName).toBe('test-agent'); + expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); + expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); + unmount!(); + }); + + it('should clear state when closeAgentConfigDialog is called', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + + act(() => { + capturedUIActions.closeAgentConfigDialog(); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount!(); + }); + }); + describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { let unmount: () => void; @@ -1871,9 +2431,79 @@ describe('AppContainer State Management', () => { }); // Assert: Verify model is updated - expect(capturedUIState.currentModel).toBe('new-model'); + await waitFor(() => { + expect(capturedUIState.currentModel).toBe('new-model'); + }); unmount!(); }); + + it('provides activeHooks from useHookDisplayState', async () => { + const mockHooks = [{ name: 'hook1', eventName: 'event1' }]; + mockedUseHookDisplayState.mockReturnValue(mockHooks); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + 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', () => { @@ -1885,12 +2515,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 }); @@ -1987,7 +2612,9 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - expect(mockSetText).toHaveBeenCalledWith('previous message'); + await waitFor(() => { + expect(mockSetText).toHaveBeenCalledWith('previous message'); + }); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bba0f1fd4e..e1d23115ca 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,9 +25,12 @@ import { type HistoryItem, ToolCallStatus, type HistoryItemWithoutId, + type HistoryItemToolGroup, AuthState, + type ConfirmationRequest, } from './types.js'; import { MessageType, StreamingState } from './types.js'; +import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { type EditorType, type Config, @@ -35,11 +38,13 @@ import { type IdeContext, type UserTierId, type UserFeedbackPayload, + type AgentDefinition, IdeClient, ideContextStore, getErrorMessage, getAllGeminiMdFilenames, AuthType, + UserAccountManager, clearCachedCredentialFile, type ResumedSessionData, recordExitFail, @@ -59,9 +64,10 @@ import { startupProfiler, SessionStartSource, SessionEndReason, - fireSessionStartHook, - fireSessionEndHook, generateSummary, + type ConsentRequestPayload, + type AgentsDiscoveredPayload, + ChangeAuthRequestedError, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -83,18 +89,19 @@ import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; import { basename } from 'node:path'; -import { computeWindowTitle } from '../utils/windowTitle.js'; +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'; import { useFocus } from './hooks/useFocus.js'; -import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { keyMatchers, Command } from './keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; @@ -105,7 +112,8 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; +import { useMcpStatus } from './hooks/useMcpStatus.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { @@ -123,11 +131,16 @@ import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; -import { enableBracketedPaste } from './utils/bracketedPaste.js'; import { useBanner } from './hooks/useBanner.js'; - -const WARNING_PROMPT_DURATION_MS = 1000; -const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; +import { useHookDisplayState } from './hooks/useHookDisplayState.js'; +import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; +import { + WARNING_PROMPT_DURATION_MS, + QUEUE_ERROR_DISPLAY_DURATION_MS, +} from './constants.js'; +import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; +import { NewAgentsChoice } from './components/NewAgentsNotification.js'; +import { isSlashCommand } from './utils/commandUtils.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -140,6 +153,16 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { }); } +function isToolAwaitingConfirmation( + pendingHistoryItems: HistoryItemWithoutId[], +) { + return pendingHistoryItems + .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') + .some((item) => + item.tools.some((tool) => ToolCallStatus.Confirming === tool.status), + ); +} + interface AppContainerProps { config: Config; startupWarnings?: string[]; @@ -162,11 +185,58 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; + const settings = useSettings(); + 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 settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); @@ -185,11 +255,18 @@ export const AppContainer = (props: AppContainerProps) => { ); const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); + const toggleBackgroundShellRef = useRef<() => void>(() => {}); + const isBackgroundShellVisibleRef = useRef(false); + const backgroundShellsRef = useRef>(new Map()); + + const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); const [shellModeActive, setShellModeActive] = useState(false); const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = useState(false); const [historyRemountKey, setHistoryRemountKey] = useState(0); + const [settingsNonce, setSettingsNonce] = useState(0); + const activeHooks = useHookDisplayState(); const [updateInfo, setUpdateInfo] = useState(null); const [isTrustedFolder, setIsTrustedFolder] = useState( isWorkspaceTrusted(settings.merged).isTrusted, @@ -199,6 +276,8 @@ export const AppContainer = (props: AppContainerProps) => { null, ); + const [newAgents, setNewAgents] = useState(null); + const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); const [bannerVisible, setBannerVisible] = useState(true); @@ -248,6 +327,34 @@ export const AppContainer = (props: AppContainerProps) => { setPermissionsDialogProps(null); }, []); + const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false); + const [selectedAgentName, setSelectedAgentName] = useState< + string | undefined + >(); + const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState< + string | undefined + >(); + const [selectedAgentDefinition, setSelectedAgentDefinition] = useState< + AgentDefinition | undefined + >(); + + const openAgentConfigDialog = useCallback( + (name: string, displayName: string, definition: AgentDefinition) => { + setSelectedAgentName(name); + setSelectedAgentDisplayName(displayName); + setSelectedAgentDefinition(definition); + setIsAgentConfigDialogOpen(true); + }, + [], + ); + + const closeAgentConfigDialog = useCallback(() => { + setIsAgentConfigDialogOpen(false); + setSelectedAgentName(undefined); + setSelectedAgentDisplayName(undefined); + setSelectedAgentDefinition(undefined); + }, []); + const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], @@ -277,9 +384,6 @@ export const AppContainer = (props: AppContainerProps) => { const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); - const originalTitleRef = useRef( - computeWindowTitle(basename(config.getTargetDir())), - ); const lastTitleRef = useRef(null); const staticExtraHeight = 3; @@ -292,15 +396,34 @@ export const AppContainer = (props: AppContainerProps) => { setConfigInitialized(true); startupProfiler.flush(config); - // Fire SessionStart hook through MessageBus (only if hooks are enabled) - // Must be called AFTER config.initialize() to ensure HookRegistry is loaded - const hooksEnabled = config.getEnableHooks(); - const hookMessageBus = config.getMessageBus(); - if (hooksEnabled && hookMessageBus) { - const sessionStartSource = resumedSessionData - ? SessionStartSource.Resume - : SessionStartSource.Startup; - await fireSessionStartHook(hookMessageBus, sessionStartSource); + const sessionStartSource = resumedSessionData + ? SessionStartSource.Resume + : SessionStartSource.Startup; + const result = await config + .getHookSystem() + ?.fireSessionStartEvent(sessionStartSource); + + if (result) { + if (result.systemMessage) { + historyManager.addItem( + { + type: MessageType.INFO, + text: result.systemMessage, + }, + Date.now(), + ); + } + + const additionalContext = result.getAdditionalContext(); + const geminiClient = config.getGeminiClient(); + if (additionalContext && geminiClient) { + await geminiClient.addHistory({ + role: 'user', + parts: [ + { text: `${additionalContext}` }, + ], + }); + } } // Fire-and-forget: generate summary for previous session in background @@ -311,16 +434,24 @@ 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(); // Fire SessionEnd hook on cleanup (only if hooks are enabled) - const hooksEnabled = config.getEnableHooks(); - const hookMessageBus = config.getMessageBus(); - if (hooksEnabled && hookMessageBus) { - await fireSessionEndHook(hookMessageBus, SessionEndReason.Exit); - } + await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); }); + // Disable the dependencies check here. historyManager gets flagged + // but we don't want to react to changes to it because each new history + // item, including the ones from the start session hook will cause a + // re-render and an error when we try to reload config. + // + // eslint-disable-next-line react-hooks/exhaustive-deps }, [config, resumedSessionData]); useEffect( @@ -340,6 +471,32 @@ export const AppContainer = (props: AppContainerProps) => { }; }, [config]); + useEffect(() => { + const handleSettingsChanged = () => { + setSettingsNonce((prev) => prev + 1); + }; + + const handleAdminSettingsChanged = () => { + setAdminSettingsChanged(true); + }; + + const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => { + setNewAgents(payload.agents); + }; + + coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged); + coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged); + coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); + return () => { + coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged); + coreEvents.off( + CoreEvent.AdminSettingsChanged, + handleAdminSettingsChanged, + ); + coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); + }; + }, []); + const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = useConsoleMessages(); @@ -361,6 +518,11 @@ export const AppContainer = (props: AppContainerProps) => { } }, []); + const getPreferredEditor = useCallback( + () => settings.merged.general.preferredEditor as EditorType, + [settings.merged.general.preferredEditor], + ); + const buffer = useTextBuffer({ initialText: '', viewport: { height: 10, width: inputWidth }, @@ -368,6 +530,7 @@ export const AppContainer = (props: AppContainerProps) => { setRawMode, isValidPath, shellModeActive, + getPreferredEditor, }); // Initialize input history from logger (past sessions) @@ -394,8 +557,7 @@ export const AppContainer = (props: AppContainerProps) => { disableLineWrapping(); app.rerender(); } - enableBracketedPaste(); - terminalCapabilityManager.enableKittyProtocol(); + terminalCapabilityManager.enableSupportedModes(); refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); @@ -408,7 +570,7 @@ export const AppContainer = (props: AppContainerProps) => { useEffect(() => { if ( - !(settings.merged.ui?.hideBanner || config.getScreenReader()) && + !(settings.merged.ui.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { @@ -438,13 +600,29 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, apiKeyDefaultValue, reloadApiKey, - } = useAuthCommand(settings, config); + } = useAuthCommand(settings, config, initializationResult.authError); + const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( + {}, + ); - const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ + useEffect(() => { + if (authState === AuthState.Authenticated && authContext.requiresRestart) { + setAuthState(AuthState.AwaitingGoogleLoginRestart); + setAuthContext({}); + } + }, [authState, authContext, setAuthState]); + + const { + proQuotaRequest, + handleProQuotaChoice, + validationRequest, + handleValidationChoice, + } = useQuotaAndFallback({ config, historyManager, userTier, setModelSwitchedFromQuotaError, + onShowAuthSelection: () => setAuthState(AuthState.Updating), }); // Derive auth state variables for backward compatibility with UIStateContext @@ -454,7 +632,7 @@ export const AppContainer = (props: AppContainerProps) => { // Session browser and resume functionality const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); - const { loadHistoryForResume } = useSessionResume({ + const { loadHistoryForResume, isResuming } = useSessionResume({ config, historyManager, refreshStatic, @@ -482,6 +660,11 @@ export const AppContainer = (props: AppContainerProps) => { const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + setAuthContext({ requiresRestart: true }); + } else { + setAuthContext({}); + } await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); @@ -489,6 +672,9 @@ export const AppContainer = (props: AppContainerProps) => { await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); } catch (e) { + if (e instanceof ChangeAuthRequestedError) { + return; + } onAuthError( `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, ); @@ -510,7 +696,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } setAuthState(AuthState.Authenticated); }, - [settings, config, setAuthState, onAuthError], + [settings, config, setAuthState, onAuthError, setAuthContext], ); const handleApiKeySubmit = useCallback( @@ -553,17 +739,17 @@ Logging in with Google... Restarting Gemini CLI to continue. // Check for enforced auth type mismatch useEffect(() => { if ( - settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && - settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType + settings.merged.security.auth.enforcedType && + settings.merged.security.auth.selectedType && + settings.merged.security.auth.enforcedType !== + settings.merged.security.auth.selectedType ) { onAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, + `Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`, ); } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal ) { // We skip validation for Gemini API key here because it might be stored // in the keychain, which we can't check synchronously. @@ -580,9 +766,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.enforcedType, + settings.merged.security.auth.useExternal, onAuthError, ]); @@ -602,6 +788,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), @@ -611,6 +801,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentConfigDialog, openPermissionsDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); @@ -624,6 +815,18 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, + toggleBackgroundShell: () => { + toggleBackgroundShellRef.current(); + if (!isBackgroundShellVisibleRef.current) { + setEmbeddedShellFocused(true); + if (backgroundShellsRef.current.size > 1) { + setIsBackgroundShellListOpenRef.current(true); + } else { + setIsBackgroundShellListOpenRef.current(false); + } + } + }, + setText: (text: string) => buffer.setText(text), }), [ setAuthState, @@ -632,6 +835,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentConfigDialog, setQuittingMessages, setDebugMessage, setShowPrivacyNotice, @@ -640,6 +844,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openPermissionsDialog, addConfirmUpdateExtensionRequest, toggleDebugProfiler, + buffer, ], ); @@ -648,8 +853,7 @@ Logging in with Google... Restarting Gemini CLI to continue. slashCommands, pendingHistoryItems: pendingSlashCommandHistoryItems, commandContext, - shellConfirmationRequest, - confirmationRequest, + confirmationRequest: commandConfirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -666,6 +870,26 @@ Logging in with Google... Restarting Gemini CLI to continue. setCustomDialog, ); + const [authConsentRequest, setAuthConsentRequest] = + useState(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( { @@ -714,11 +938,6 @@ Logging in with Google... Restarting Gemini CLI to continue. () => {}, ); - const getPreferredEditor = useCallback( - () => settings.merged.general?.preferredEditor as EditorType, - [settings.merged.general?.preferredEditor], - ); - const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => { if (shouldRestorePrompt) { setPendingRestorePrompt(true); @@ -753,10 +972,18 @@ Logging in with Google... Restarting Gemini CLI to continue. pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + pendingToolCalls, handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, lastOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + backgroundShells, + dismissBackgroundShell, + retryStatus, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -778,13 +1005,56 @@ 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]); + + const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({ + activePtyId, + lastOutputTime, + streamingState, + pendingToolCalls, + embeddedShellFocused, + isInteractiveShellEnabled: config.isInteractiveShellEnabled(), + }); + + const shouldShowActionRequiredTitle = inactivityStatus === 'action_required'; + const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working'; + // Auto-accept indicator - const showAutoAcceptIndicator = useAutoAcceptIndicator({ + const showApprovalModeIndicator = useApprovalModeIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, + isActive: !embeddedShellFocused, }); + const { isMcpReady } = useMcpStatus(config); + const { messageQueue, addMessage, @@ -795,6 +1065,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isConfigInitialized, streamingState, submitQuery, + isMcpReady, }); cancelHandlerRef.current = useCallback( @@ -803,8 +1074,11 @@ Logging in with Google... Restarting Gemini CLI to continue. ...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems, ]; + if (isToolAwaitingConfirmation(pendingHistoryItems)) { + return; // Don't clear - user may be composing a follow-up message + } if (isToolExecuting(pendingHistoryItems)) { - buffer.setText(''); // Just clear the prompt + buffer.setText(''); // Clear for Ctrl+C cancellation return; } @@ -833,10 +1107,31 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleFinalSubmit = useCallback( (submittedValue: string) => { - addMessage(submittedValue); + const isSlash = isSlashCommand(submittedValue.trim()); + const isIdle = streamingState === StreamingState.Idle; + + if (isSlash || (isIdle && isMcpReady)) { + void submitQuery(submittedValue); + } else { + // Check messageQueue.length === 0 to only notify on the first queued item + if (isIdle && !isMcpReady && messageQueue.length === 0) { + coreEvents.emitFeedback( + 'info', + 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.', + ); + } + addMessage(submittedValue); + } addInput(submittedValue); // Track input for up-arrow history }, - [addMessage, addInput], + [ + addMessage, + addInput, + submitQuery, + isMcpReady, + streamingState, + messageQueue.length, + ], ); const handleClearScreen = useCallback(() => { @@ -856,8 +1151,10 @@ Logging in with Google... Restarting Gemini CLI to continue. * - Any future streaming states not explicitly allowed */ const isInputActive = + isConfigInitialized && !initError && !isProcessing && + !isResuming && !!slashCommands && (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding) && @@ -880,7 +1177,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({ @@ -889,23 +1190,22 @@ Logging in with Google... Restarting Gemini CLI to continue. Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1, ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, + pager: settings.merged.tools.shell.pager, + showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, }); const isFocused = useFocus(); - useBracketedPaste(); // Context file names computation const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; + const fromSettings = settings.merged.context.fileName; return fromSettings ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] : getAllGeminiMdFilenames(); - }, [settings.merged.context?.fileName]); + }, [settings.merged.context.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); @@ -979,7 +1279,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const shouldShowIdePrompt = Boolean( currentIDE && !config.getIdeMode() && - !settings.merged.ide?.hasSeenNudge && + !settings.merged.ide.hasSeenNudge && !idePromptAnswered, ); @@ -1009,19 +1309,20 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); + const warningTimeoutRef = useRef(null); + const tabFocusTimeoutRef = useRef(null); + + const handleWarning = useCallback((message: string) => { + setWarningMessage(message); + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + warningTimeoutRef.current = setTimeout(() => { + setWarningMessage(null); + }, WARNING_PROMPT_DURATION_MS); + }, []); + useEffect(() => { - let timeoutId: NodeJS.Timeout; - - const handleWarning = (message: string) => { - setWarningMessage(message); - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - setWarningMessage(null); - }, WARNING_PROMPT_DURATION_MS); - }; - const handleSelectionWarning = () => { handleWarning('Press Ctrl-S to enter selection mode to copy text.'); }; @@ -1033,11 +1334,14 @@ Logging in with Google... Restarting Gemini CLI to continue. return () => { appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning); appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout); - if (timeoutId) { - clearTimeout(timeoutId); + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + if (tabFocusTimeoutRef.current) { + clearTimeout(tabFocusTimeoutRef.current); } }; - }, []); + }, [handleWarning]); useEffect(() => { if (ideNeedsRestart) { @@ -1154,12 +1458,11 @@ Logging in with Google... Restarting Gemini CLI to continue. [handleSlashCommand, settings], ); - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( + const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({ streamingState, - settings.merged.ui?.customWittyPhrases, - !!activePtyId && !embeddedShellFocused, - lastOutputTime, - ); + shouldShowFocusHint, + retryStatus, + }); const handleGlobalKeypress = useCallback( (key: Key) => { @@ -1167,18 +1470,18 @@ Logging in with Google... Restarting Gemini CLI to continue. setCopyModeEnabled(false); enableMouseEvents(); // We don't want to process any other keys if we're in copy mode. - return; + return true; } // Debug log keystrokes if enabled - if (settings.merged.general?.debugKeystrokeLogging) { + if (settings.merged.general.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); - return; + return true; } if (keyMatchers[Command.QUIT](key)) { @@ -1187,13 +1490,13 @@ Logging in with Google... Restarting Gemini CLI to continue. cancelOngoingRequest?.(); setCtrlCPressCount((prev) => prev + 1); - return; + return true; } else if (keyMatchers[Command.EXIT](key)) { if (buffer.text.length > 0) { - return; + return false; } setCtrlDPressCount((prev) => prev + 1); - return; + return true; } let enteringConstrainHeightMode = false; @@ -1204,8 +1507,13 @@ Logging in with Google... Restarting Gemini CLI to continue. if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { setShowErrorDetails((prev) => !prev); + return true; + } else if (keyMatchers[Command.SUSPEND_APP](key)) { + handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z'); + return true; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); + return true; } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; @@ -1213,23 +1521,102 @@ Logging in with Google... Restarting Gemini CLI to continue. refreshStatic(); return newValue; }); + return true; } else if ( - keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) && + keyMatchers[Command.SHOW_IDE_CONTEXT_DETAIL](key) && config.getIdeMode() && ideContextState ) { // eslint-disable-next-line @typescript-eslint/no-floating-promises handleSlashCommand('/ide status'); + return true; } else if ( keyMatchers[Command.SHOW_MORE_LINES](key) && !enteringConstrainHeightMode ) { setConstrainHeight(false); - } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { - if (activePtyId || embeddedShellFocused) { - setEmbeddedShellFocused((prev) => !prev); + return true; + } else if ( + keyMatchers[Command.FOCUS_SHELL_INPUT](key) && + (activePtyId || + (isBackgroundShellVisible && backgroundShells.size > 0)) && + buffer.text.length === 0 + ) { + if (key.name === 'tab' && key.shift) { + // Always change focus + setEmbeddedShellFocused(false); + 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 && !activePtyId) { + if (tabFocusTimeoutRef.current) { + clearTimeout(tabFocusTimeoutRef.current); + } + toggleBackgroundShell(); + if (!isBackgroundShellVisible) { + // We are about to show it, so focus it + setEmbeddedShellFocused(true); + if (backgroundShells.size > 1) { + setIsBackgroundShellListOpen(true); + } + } 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; + } + + // 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; }, [ constrainHeight, @@ -1244,49 +1631,55 @@ Logging in with Google... Restarting Gemini CLI to continue. cancelOngoingRequest, activePtyId, embeddedShellFocused, - settings.merged.general?.debugKeystrokeLogging, + settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, copyModeEnabled, isAlternateBuffer, + backgroundCurrentShell, + toggleBackgroundShell, + backgroundShells, + isBackgroundShellVisible, + setIsBackgroundShellListOpen, + lastOutputTimeRef, + tabFocusTimeoutRef, + handleWarning, ], ); - useKeypress(handleGlobalKeypress, { isActive: true }); + useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); - // Update terminal title with Gemini CLI status and thoughts useEffect(() => { - // Respect both showStatusInTitle and hideWindowTitle settings - if ( - !settings.merged.ui?.showStatusInTitle || - settings.merged.ui?.hideWindowTitle - ) - return; + // Respect hideWindowTitle settings + if (settings.merged.ui.hideWindowTitle) return; - let title; - if (streamingState === StreamingState.Idle) { - title = originalTitleRef.current; - } else { - const statusText = thought?.subject - ?.replace(/[\r\n]+/g, ' ') - .substring(0, 80); - title = statusText || originalTitleRef.current; - } - - // Pad the title to a fixed width to prevent taskbar icon resizing. - const paddedTitle = title.padEnd(80, ' '); + const paddedTitle = computeTerminalTitle({ + streamingState, + thoughtSubject: thought?.subject, + isConfirming: + !!commandConfirmationRequest || shouldShowActionRequiredTitle, + isSilentWorking: shouldShowSilentWorkingTitle, + folderName: basename(config.getTargetDir()), + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, + }); // Only update the title if it's different from the last value we set if (lastTitleRef.current !== paddedTitle) { lastTitleRef.current = paddedTitle; - stdout.write(`\x1b]2;${paddedTitle}\x07`); + stdout.write(`\x1b]0;${paddedTitle}\x07`); } // Note: We don't need to reset the window title on exit because Gemini CLI is already doing that elsewhere }, [ streamingState, thought, - settings.merged.ui?.showStatusInTitle, - settings.merged.ui?.hideWindowTitle, + commandConfirmationRequest, + shouldShowActionRequiredTitle, + shouldShowSilentWorkingTitle, + settings.merged.ui.showStatusInTitle, + settings.merged.ui.dynamicWindowTitle, + settings.merged.ui.hideWindowTitle, + config, stdout, ]); @@ -1358,14 +1751,16 @@ Logging in with Google... Restarting Gemini CLI to continue. const dialogsVisible = shouldShowIdePrompt || isFolderTrustDialogOpen || - !!shellConfirmationRequest || - !!confirmationRequest || + adminSettingsChanged || + !!commandConfirmationRequest || + !!authConsentRequest || !!customDialog || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || + isAgentConfigDialogOpen || isPermissionsDialogOpen || isAuthenticating || isAuthDialogOpen || @@ -1373,15 +1768,26 @@ Logging in with Google... Restarting Gemini CLI to continue. showPrivacyNotice || showIdeRestartPrompt || !!proQuotaRequest || + !!validationRequest || isSessionBrowserOpen || - isAuthDialogOpen || - authState === AuthState.AwaitingApiKeyInput; + authState === AuthState.AwaitingApiKeyInput || + !!newAgents; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], ); + const allToolCalls = useMemo( + () => + pendingHistoryItems + .filter( + (item): item is HistoryItemToolGroup => item.type === 'tool_group', + ) + .flatMap((item) => item.tools), + [pendingHistoryItems], + ); + const [geminiMdFileCount, setGeminiMdFileCount] = useState( config.getGeminiMdFileCount(), ); @@ -1448,13 +1854,17 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, - shellConfirmationRequest, - confirmationRequest, + commandConfirmationRequest, + authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, geminiMdFileCount, @@ -1468,6 +1878,7 @@ Logging in with Google... Restarting Gemini CLI to continue. inputWidth, suggestionsWidth, isInputActive, + isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, isTrustedFolder, @@ -1484,12 +1895,14 @@ Logging in with Google... Restarting Gemini CLI to continue. elapsedTime, currentLoadingPhrase, historyRemountKey, + activeHooks, messageQueue, queueErrorMessage, - showAutoAcceptIndicator, + showApprovalModeIndicator, currentModel, userTier, proQuotaRequest, + validationRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1512,6 +1925,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, embeddedShellFocused, showDebugProfiler, customDialog, @@ -1520,6 +1935,13 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), + settingsNonce, + backgroundShells, + activeBackgroundShellPid, + backgroundShellHeight, + isBackgroundShellListOpen, + adminSettingsChanged, + newAgents, }), [ isThemeDialogOpen, @@ -1537,13 +1959,17 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, pendingSlashCommandHistoryItems, commandContext, - shellConfirmationRequest, - confirmationRequest, + commandConfirmationRequest, + authConsentRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, geminiMdFileCount, @@ -1557,6 +1983,7 @@ Logging in with Google... Restarting Gemini CLI to continue. inputWidth, suggestionsWidth, isInputActive, + isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, isTrustedFolder, @@ -1573,11 +2000,13 @@ Logging in with Google... Restarting Gemini CLI to continue. elapsedTime, currentLoadingPhrase, historyRemountKey, + activeHooks, messageQueue, queueErrorMessage, - showAutoAcceptIndicator, + showApprovalModeIndicator, userTier, proQuotaRequest, + validationRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1601,6 +2030,8 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, historyManager, embeddedShellFocused, showDebugProfiler, @@ -1612,6 +2043,13 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerData, bannerVisible, config, + settingsNonce, + backgroundShellHeight, + isBackgroundShellListOpen, + activeBackgroundShellPid, + backgroundShells, + adminSettingsChanged, + newAgents, ], ); @@ -1633,6 +2071,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, @@ -1645,6 +2085,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFinalSubmit, handleClearScreen, handleProQuotaChoice, + handleValidationChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -1654,7 +2095,45 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + handleWarning, setEmbeddedShellFocused, + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, + setAuthContext, + handleRestart: async () => { + if (process.send) { + const remoteSettings = config.getRemoteAdminSettings(); + if (remoteSettings) { + process.send({ + type: 'admin-settings-update', + settings: remoteSettings, + }); + } + } + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }, + handleNewAgentsSelect: async (choice: NewAgentsChoice) => { + if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { + const registry = config.getAgentRegistry(); + try { + await Promise.all( + newAgents.map((agent) => registry.acknowledgeAgent(agent)), + ); + } catch (error) { + debugLogger.error('Failed to acknowledge agents:', error); + historyManager.addItem( + { + type: MessageType.ERROR, + text: `Failed to acknowledge agents: ${getErrorMessage(error)}`, + }, + Date.now(), + ); + } + } + setNewAgents(null); + }, }), [ handleThemeSelect, @@ -1668,6 +2147,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, @@ -1680,6 +2161,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleFinalSubmit, handleClearScreen, handleProQuotaChoice, + handleValidationChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -1689,10 +2171,30 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + handleWarning, setEmbeddedShellFocused, + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, + setAuthContext, + newAgents, + config, + historyManager, ], ); + if (authState === AuthState.AwaitingGoogleLoginRestart) { + return ( + { + setAuthContext({}); + setAuthState(AuthState.Updating); + }} + config={config} + /> + ); + } + return ( @@ -1703,9 +2205,11 @@ Logging in with Google... Restarting Gemini CLI to continue. startupWarnings: props.startupWarnings || [], }} > - - - + + + + + diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 2dfa6a263f..409a6469f6 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -32,7 +32,9 @@ export function IdeIntegrationNudge({ userSelection: 'no', isExtensionPreInstalled: false, }); + return true; } + return false; }, { isActive: true }, ); diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index c91912df21..f8451ee353 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -1,21 +1,151 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`App > Snapshots > renders default layout correctly 1`] = ` -"MainContent +" + โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + +Tips for getting started: +1. Ask questions, edit files, or run commands. +2. Be specific for the best results. +3. Create GEMINI.md files to customize your interactions with Gemini. +4. /help for more information. + + + + + + + + + + + + + + + + + + + + + + Notifications -Composer" +Composer +" `; exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer -MainContent + + โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + +Tips for getting started: +1. Ask questions, edit files, or run commands. +2. Be specific for the best results. +3. Create GEMINI.md files to customize your interactions with Gemini. +4. /help for more information. Composer" `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` -"Notifications -Footer -MainContent -DialogManager" +" + โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Notifications +DialogManager +" +`; + +exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` +" + โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ + โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ + โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + +Tips for getting started: +1. Ask questions, edit files, or run commands. +2. Be specific for the best results. +3. Create GEMINI.md files to customize your interactions with Gemini. +4. /help for more information. +HistoryItemDisplay +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Action Required โ”‚ +โ”‚ โ”‚ +โ”‚ ? ls list directory โ”‚ +โ”‚ โ”‚ +โ”‚ ls โ”‚ +โ”‚ Allow execution of: 'ls'? โ”‚ +โ”‚ โ”‚ +โ”‚ โ— 1. Allow once โ”‚ +โ”‚ 2. Allow for this session โ”‚ +โ”‚ 3. No, suggest changes (esc) โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + + + + + + + + +Notifications +Composer +" `; diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index a17c2708bf..551cc68634 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -5,6 +5,7 @@ */ import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { ApiAuthDialog } from './ApiAuthDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -34,7 +35,7 @@ vi.mock('../components/shared/text-buffer.js', () => ({ vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ - mainAreaWidth: 80, + terminalWidth: 80, })), })); @@ -108,11 +109,10 @@ describe('ApiAuthDialog', () => { keypressHandler({ name: keyName, - sequence, - ctrl: false, - meta: false, shift: false, - paste: false, + ctrl: false, + cmd: false, + sequence, }); expect(expectedCall).toHaveBeenCalledWith(...args); @@ -133,17 +133,20 @@ describe('ApiAuthDialog', () => { it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { render(); - // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) + // Call 0 is ApiAuthDialog (isActive: true) + // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; - await keypressHandler({ + keypressHandler({ name: 'c', - ctrl: true, - meta: false, shift: false, + ctrl: true, + cmd: false, }); - expect(clearApiKey).toHaveBeenCalled(); - expect(mockBuffer.setText).toHaveBeenCalledWith(''); + await waitFor(() => { + expect(clearApiKey).toHaveBeenCalled(); + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + }); }); }); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index 6345599634..a9864e27af 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -28,8 +28,8 @@ export function ApiAuthDialog({ error, defaultValue = '', }: ApiAuthDialogProps): React.JSX.Element { - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; + const { terminalWidth } = useUIState(); + const viewportWidth = terminalWidth - 8; const pendingPromise = useRef<{ cancel: () => void } | null>(null); @@ -86,10 +86,12 @@ export function ApiAuthDialog({ }; useKeypress( - async (key) => { + (key) => { if (keyMatchers[Command.CLEAR_INPUT](key)) { - await handleClear(); + void handleClear(); + return true; } + return false; }, { isActive: true }, ); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 16f0f9cbe8..b71d2cd2d2 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -72,12 +72,14 @@ describe('AuthDialog', () => { setAuthState: (state: AuthState) => void; authError: string | null; onAuthError: (error: string | null) => void; + setAuthContext: (context: { requiresRestart?: boolean }) => void; }; - const originalEnv = { ...process.env }; - beforeEach(() => { vi.resetAllMocks(); - process.env = {}; + vi.stubEnv('CLOUD_SHELL', undefined as unknown as string); + vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string); + vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string); + vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string); props = { config: { @@ -94,11 +96,12 @@ describe('AuthDialog', () => { setAuthState: vi.fn(), authError: null, onAuthError: vi.fn(), + setAuthContext: vi.fn(), }; }); afterEach(() => { - process.env = originalEnv; + vi.unstubAllEnvs(); }); describe('Environment Variable Effects on Auth Options', () => { @@ -136,7 +139,9 @@ describe('AuthDialog', () => { ])( 'correctly shows/hides COMPUTE_ADC options $desc', ({ env, shouldContain, shouldNotContain }) => { - process.env = { ...env }; + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value as string); + } renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { @@ -150,7 +155,7 @@ describe('AuthDialog', () => { }); it('filters auth types when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); @@ -158,7 +163,7 @@ describe('AuthDialog', () => { }); it('sets initial index to 0 when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); @@ -168,7 +173,7 @@ describe('AuthDialog', () => { it.each([ { setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_VERTEX_AI; }, expected: AuthType.USE_VERTEX_AI, @@ -176,14 +181,14 @@ describe('AuthDialog', () => { }, { setup: () => { - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI; + vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI); }, expected: AuthType.USE_GEMINI, desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var', }, { setup: () => { - process.env['GEMINI_API_KEY'] = 'test-key'; + vi.stubEnv('GEMINI_API_KEY', 'test-key'); }, expected: AuthType.USE_GEMINI, desc: 'from GEMINI_API_KEY env var', @@ -217,9 +222,31 @@ describe('AuthDialog', () => { expect(props.settings.setValue).not.toHaveBeenCalled(); }); + it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { + mockedValidateAuthMethod.mockReturnValue(null); + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + + expect(props.setAuthContext).toHaveBeenCalledWith({ + requiresRestart: true, + }); + }); + + it('sets auth context with empty object for other auth types', async () => { + mockedValidateAuthMethod.mockReturnValue(null); + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthContext).toHaveBeenCalledWith({}); + }); + it('skips API key dialog on initial setup if env var is present', async () => { mockedValidateAuthMethod.mockReturnValue(null); - process.env['GEMINI_API_KEY'] = 'test-key-from-env'; + vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup renderWithProviders(); @@ -234,7 +261,7 @@ describe('AuthDialog', () => { it('skips API key dialog if env var is present but empty', async () => { mockedValidateAuthMethod.mockReturnValue(null); - process.env['GEMINI_API_KEY'] = ''; // Empty string + vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here renderWithProviders(); @@ -264,9 +291,9 @@ describe('AuthDialog', () => { it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => { mockedValidateAuthMethod.mockReturnValue(null); - process.env['GEMINI_API_KEY'] = 'test-key-from-env'; + vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // Simulate that the user has already authenticated once - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; renderWithProviders(); @@ -325,7 +352,7 @@ describe('AuthDialog', () => { { desc: 'calls onAuthError on escape if no auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = undefined; + props.settings.merged.security.auth.selectedType = undefined; }, expectations: (p: typeof props) => { expect(p.onAuthError).toHaveBeenCalledWith( @@ -336,7 +363,7 @@ describe('AuthDialog', () => { { desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_GEMINI; }, expectations: (p: typeof props) => { @@ -368,7 +395,7 @@ describe('AuthDialog', () => { }); it('renders correctly with enforced auth type', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { lastFrame } = renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index b133acf52b..0acb27e2af 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -31,6 +31,7 @@ interface AuthDialogProps { setAuthState: (state: AuthState) => void; authError: string | null; onAuthError: (error: string | null) => void; + setAuthContext: (context: { requiresRestart?: boolean }) => void; } export function AuthDialog({ @@ -39,6 +40,7 @@ export function AuthDialog({ setAuthState, authError, onAuthError, + setAuthContext, }: AuthDialogProps): React.JSX.Element { const [exiting, setExiting] = useState(false); let items = [ @@ -76,9 +78,9 @@ export function AuthDialog({ }, ]; - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { items = items.filter( - (item) => item.value === settings.merged.security?.auth?.enforcedType, + (item) => item.value === settings.merged.security.auth.enforcedType, ); } @@ -92,7 +94,7 @@ export function AuthDialog({ } let initialAuthIndex = items.findIndex((item) => { - if (settings.merged.security?.auth?.selectedType) { + if (settings.merged.security.auth.selectedType) { return item.value === settings.merged.security.auth.selectedType; } @@ -106,7 +108,7 @@ export function AuthDialog({ return item.value === AuthType.LOGIN_WITH_GOOGLE; }); - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { initialAuthIndex = 0; } @@ -116,6 +118,11 @@ export function AuthDialog({ return; } if (authType) { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + setAuthContext({ requiresRestart: true }); + } else { + setAuthContext({}); + } await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); @@ -143,7 +150,7 @@ export function AuthDialog({ } setAuthState(AuthState.Unauthenticated); }, - [settings, config, setAuthState, exiting], + [settings, config, setAuthState, exiting, setAuthContext], ); const handleAuthSelect = (authMethod: AuthType) => { @@ -162,18 +169,20 @@ export function AuthDialog({ // Prevent exit if there is an error message. // This means they user is not authenticated yet. if (authError) { - return; + return true; } - if (settings.merged.security?.auth?.selectedType === undefined) { + if (settings.merged.security.auth.selectedType === undefined) { // Prevent exiting if no auth method is set onAuthError( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', ); - return; + return true; } // eslint-disable-next-line @typescript-eslint/no-floating-promises onSelect(undefined, SettingScope.User); + return true; } + return false; }, { isActive: true }, ); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx new file mode 100644 index 0000000000..ac0966c111 --- /dev/null +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; +import { type Config } from '@google/gemini-cli-core'; + +// Mocks +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn(), +})); + +const mockedUseKeypress = useKeypress as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +describe('LoginWithGoogleRestartDialog', () => { + const onDismiss = vi.fn(); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const mockConfig = { + getRemoteAdminSettings: vi.fn(), + } as unknown as Config; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy.mockClear(); + vi.useRealTimers(); + }); + + it('renders correctly', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('calls onDismiss when escape is pressed', () => { + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'escape', + shift: false, + ctrl: false, + cmd: false, + sequence: '\u001b', + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it.each(['r', 'R'])( + 'calls runExitCleanup and process.exit when %s is pressed', + async (keyName) => { + vi.useFakeTimers(); + + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: keyName, + shift: false, + ctrl: false, + cmd: false, + sequence: keyName, + }); + + // Advance timers to trigger the setTimeout callback + await vi.runAllTimersAsync(); + + expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + + vi.useRealTimers(); + }, + ); +}); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx new file mode 100644 index 0000000000..86cd645fee --- /dev/null +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config } from '@google/gemini-cli-core'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; + +interface LoginWithGoogleRestartDialogProps { + onDismiss: () => void; + config: Config; +} + +export const LoginWithGoogleRestartDialog = ({ + onDismiss, + config, +}: LoginWithGoogleRestartDialogProps) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onDismiss(); + return true; + } else if (key.name === 'r' || key.name === 'R') { + setTimeout(async () => { + if (process.send) { + const remoteSettings = config.getRemoteAdminSettings(); + if (remoteSettings) { + process.send({ + type: 'admin-settings-update', + settings: remoteSettings, + }); + } + } + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }, 100); + return true; + } + return false; + }, + { isActive: true }, + ); + + const message = + 'You have successfully logged in with Google. Gemini CLI needs to be restarted.'; + + return ( + + + {message} Press 'r' to restart, or 'escape' to + choose a different auth method. + + + ); +}; diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap new file mode 100644 index 0000000000..effd559184 --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = ` +"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to โ”‚ +โ”‚ restart, or 'escape' to choose a different auth method. โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" +`; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 004e362d10..2b61265890 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings( authType: AuthType, settings: LoadedSettings, ): string | null { - const enforcedType = settings.merged.security?.auth?.enforcedType; + const enforcedType = settings.merged.security.auth.enforcedType; if (enforcedType && enforcedType !== authType) { return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; } - if (settings.merged.security?.auth?.useExternal) { + if (settings.merged.security.auth.useExternal) { return null; } // If using Gemini API key, we don't validate it here as we might need to prompt for it. @@ -34,12 +34,16 @@ export function validateAuthMethodWithSettings( return validateAuthMethod(authType); } -export const useAuthCommand = (settings: LoadedSettings, config: Config) => { +export const useAuthCommand = ( + settings: LoadedSettings, + config: Config, + initialAuthError: string | null = null, +) => { const [authState, setAuthState] = useState( - AuthState.Unauthenticated, + initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); - const [authError, setAuthError] = useState(null); + const [authError, setAuthError] = useState(initialAuthError); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); @@ -80,7 +84,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { return; } - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (!authType) { if (process.env['GEMINI_API_KEY']) { onAuthError( diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index b21dfa5233..f1c010678e 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -39,6 +39,7 @@ describe('aboutCommand', () => { config: { getModel: vi.fn(), getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), }, settings: { merged: { @@ -87,20 +88,18 @@ describe('aboutCommand', () => { await aboutCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ABOUT, - cliVersion: 'test-version', - osVersion: 'test-os', - sandboxEnv: 'no sandbox', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-gcp-project', - ideClient: 'test-ide', - userEmail: 'test-email@example.com', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ABOUT, + cliVersion: 'test-version', + osVersion: 'test-os', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + gcpProject: 'test-gcp-project', + ideClient: 'test-ide', + userEmail: 'test-email@example.com', + tier: undefined, + }); }); it('should show the correct sandbox environment variable', async () => { @@ -115,7 +114,6 @@ describe('aboutCommand', () => { expect.objectContaining({ sandboxEnv: 'gemini-sandbox', }), - expect.any(Number), ); }); @@ -132,7 +130,6 @@ describe('aboutCommand', () => { expect.objectContaining({ sandboxEnv: 'sandbox-exec (test-profile)', }), - expect.any(Number), ); }); @@ -159,7 +156,23 @@ describe('aboutCommand', () => { gcpProject: 'test-gcp-project', ideClient: '', }), - expect.any(Number), + ); + }); + + it('should display the tier when getUserTierName returns a value', async () => { + vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( + 'Enterprise Tier', + ); + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + tier: 'Enterprise Tier', + }), ); }); }); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 9b4cc34d0c..cf21d9b0d5 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = { const modelVersion = context.services.config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; + context.services.settings.merged.security.auth.selectedType || ''; const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; const ideClient = await getIdeClientName(context); @@ -44,6 +44,8 @@ export const aboutCommand: SlashCommand = { }); const userEmail = cachedAccount ?? undefined; + const tier = context.services.config?.getUserTierName(); + const aboutItem: Omit = { type: MessageType.ABOUT, cliVersion, @@ -54,9 +56,10 @@ export const aboutCommand: SlashCommand = { gcpProject, ideClient, userEmail, + tier, }; - context.ui.addItem(aboutItem, Date.now()); + context.ui.addItem(aboutItem); }, }; diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts new file mode 100644 index 0000000000..6b0a40ed5c --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -0,0 +1,459 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { agentsCommand } from './agentsCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Config } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { MessageType } from '../types.js'; +import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; + +vi.mock('../../utils/agentSettings.js', () => ({ + enableAgent: vi.fn(), + disableAgent: vi.fn(), +})); + +vi.mock('../../utils/agentUtils.js', () => ({ + renderAgentActionFeedback: vi.fn(), +})); + +describe('agentsCommand', () => { + let mockContext: ReturnType; + let mockConfig: { + getAgentRegistry: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = { + getAgentRegistry: vi.fn().mockReturnValue({ + getAllDefinitions: vi.fn().mockReturnValue([]), + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: vi.fn(), + }), + }; + + mockContext = createMockCommandContext({ + services: { + config: mockConfig as unknown as Config, + settings: { + workspace: { path: '/mock/path' }, + merged: { agents: { overrides: {} } }, + } as unknown as LoadedSettings, + }, + }); + }); + + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const result = await agentsCommand.action!(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should show an error if agent registry is not available', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); + + const result = await agentsCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }); + }); + + it('should call addItem with correct agents list', async () => { + const mockAgents = [ + { + name: 'agent1', + displayName: 'Agent One', + description: 'desc1', + kind: 'local', + }, + { + name: 'agent2', + displayName: undefined, + description: 'desc2', + kind: 'remote', + }, + ]; + mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents); + + await agentsCommand.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.AGENTS_LIST, + agents: mockAgents, + }), + ); + }); + + it('should reload the agent registry when refresh subcommand is called', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + reload: reloadSpy, + }); + + const refreshCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'refresh', + ); + expect(refreshCommand).toBeDefined(); + + const result = await refreshCommand!.action!(mockContext, ''); + + expect(reloadSpy).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Agents refreshed successfully.', + }); + }); + + it('should show an error if agent registry is not available during refresh', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); + + const refreshCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'refresh', + ); + const result = await refreshCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }); + }); + + it('should enable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: reloadSpy, + }); + // Add agent to disabled overrides so validation passes + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + enabled: false, + }; + + vi.mocked(enableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue('Enabled test-agent.'); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + expect(enableCommand).toBeDefined(); + + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Enabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled test-agent.', + }); + }); + + it('should handle no-op when enabling an agent', async () => { + mockConfig + .getAgentRegistry() + .getAllAgentNames.mockReturnValue(['test-agent']); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).not.toHaveBeenCalled(); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already enabled.", + }); + }); + + it('should show usage error if no agent name provided for enable', async () => { + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }); + }); + + it('should show an error if config is not available for enable', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { config: null }, + }); + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should disable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue(['test-agent']), + reload: reloadSpy, + }); + vi.mocked(disableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue( + 'Disabled test-agent.', + ); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + expect(disableCommand).toBeDefined(); + + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + expect.anything(), // Scope is derived in the command + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Disabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled test-agent.', + }); + }); + + it('should show info message if agent is already disabled', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + enabled: false, + }; + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already disabled.", + }); + }); + + it('should show error if agent is not found when disabling', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: "Agent 'test-agent' not found.", + }); + }); + + it('should show usage error if no agent name provided for disable', async () => { + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }); + }); + + it('should show an error if config is not available for disable', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { config: null }, + }); + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + describe('config sub-command', () => { + it('should return dialog action for a valid agent', async () => { + const mockDefinition = { + name: 'test-agent', + displayName: 'Test Agent', + description: 'test desc', + kind: 'local', + }; + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + expect(configCommand).toBeDefined(); + + const result = await configCommand!.action!(mockContext, 'test-agent'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agentConfig', + props: { + name: 'test-agent', + displayName: 'Test Agent', + definition: mockDefinition, + }, + }); + }); + + it('should use name as displayName if displayName is missing', async () => { + const mockDefinition = { + name: 'test-agent', + description: 'test desc', + kind: 'local', + }; + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, 'test-agent'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agentConfig', + props: { + name: 'test-agent', + displayName: 'test-agent', // Falls back to name + definition: mockDefinition, + }, + }); + }); + + it('should show error if agent is not found', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getDiscoveredDefinition: vi.fn().mockReturnValue(undefined), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, 'non-existent'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: "Agent 'non-existent' not found.", + }); + }); + + it('should show usage error if no agent name provided', async () => { + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents config ', + }); + }); + + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { config: null }, + }); + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + const result = await configCommand!.action!(contextWithoutConfig, 'test'); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should provide completions for discovered agents', async () => { + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllDiscoveredAgentNames: vi + .fn() + .mockReturnValue(['agent1', 'agent2', 'other']), + }); + + const configCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'config', + ); + expect(configCommand?.completion).toBeDefined(); + + const completions = await configCommand!.completion!(mockContext, 'age'); + expect(completions).toEqual(['agent1', 'agent2']); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts new file mode 100644 index 0000000000..32acbf69b7 --- /dev/null +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -0,0 +1,369 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + CommandContext, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType, type HistoryItemAgentsList } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; +import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; + +const agentsListCommand: SlashCommand = { + name: 'list', + description: 'List available local and remote agents', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const agents = agentRegistry.getAllDefinitions().map((def) => ({ + name: def.name, + displayName: def.displayName, + description: def.description, + kind: def.kind, + })); + + const agentsListItem: HistoryItemAgentsList = { + type: MessageType.AGENTS_LIST, + agents, + }; + + context.ui.addItem(agentsListItem); + + return; + }, +}; + +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const { config, settings } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.enabled === false, + ); + + if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already enabled.`, + }; + } + + if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const result = enableAgent(settings, agentName); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Enabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const { config, settings } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.enabled === false, + ); + + if (disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already disabled.`, + }; + } + + if (!allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + const result = disableAgent(settings, agentName, scope); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Disabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +async function configAction( + context: CommandContext, + args: string, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents config ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const definition = agentRegistry.getDiscoveredDefinition(agentName); + if (!definition) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const displayName = definition.displayName || agentName; + + return { + type: 'dialog', + dialog: 'agentConfig', + props: { + name: agentName, + displayName, + definition, + }, + }; +} + +function completeAgentsToEnable(context: CommandContext, partialArg: string) { + const { config, settings } = context.services; + if (!config) return []; + + const overrides = settings.merged.agents.overrides; + const disabledAgents = Object.entries(overrides) + .filter(([_, override]) => override?.enabled === false) + .map(([name]) => name); + + return disabledAgents.filter((name) => name.startsWith(partialArg)); +} + +function completeAgentsToDisable(context: CommandContext, partialArg: string) { + const { config } = context.services; + if (!config) return []; + + const agentRegistry = config.getAgentRegistry(); + const allAgents = agentRegistry ? agentRegistry.getAllAgentNames() : []; + return allAgents.filter((name: string) => name.startsWith(partialArg)); +} + +function completeAllAgents(context: CommandContext, partialArg: string) { + const { config } = context.services; + if (!config) return []; + + const agentRegistry = config.getAgentRegistry(); + const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? []; + return allAgents.filter((name: string) => name.startsWith(partialArg)); +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: enableAction, + completion: completeAgentsToEnable, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an enabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: disableAction, + completion: completeAgentsToDisable, +}; + +const configCommand: SlashCommand = { + name: 'config', + description: 'Configure an agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: configAction, + completion: completeAllAgents, +}; + +const agentsRefreshCommand: SlashCommand = { + name: 'refresh', + description: 'Reload the agent registry', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { config } = context.services; + const agentRegistry = config?.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: 'Refreshing agent registry...', + }); + + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: 'Agents refreshed successfully.', + }; + }, +}; + +export const agentsCommand: SlashCommand = { + name: 'agents', + description: 'Manage agents', + kind: CommandKind.BUILT_IN, + subCommands: [ + agentsListCommand, + agentsRefreshCommand, + enableCommand, + disableCommand, + configCommand, + ], + action: async (context: CommandContext, args) => + // Default to list if no subcommand is provided + agentsListCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 9031e918f5..88db905e77 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -6,15 +6,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import open from 'open'; +import path from 'node:path'; import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { getVersion } from '@google/gemini-cli-core'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; +import { formatBytes } from '../utils/formatters.js'; // Mock dependencies vi.mock('open'); vi.mock('../utils/formatters.js'); +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +38,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }, sessionId: 'test-session-id', getVersion: vi.fn(), + INITIAL_HISTORY_LENGTH: 1, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, }; }); vi.mock('node:process', () => ({ @@ -50,13 +68,16 @@ vi.mock('../utils/terminalCapabilityManager.js', () => ({ describe('bugCommand', () => { beforeEach(() => { vi.mocked(getVersion).mockResolvedValue('0.1.0'); - vi.mocked(formatMemoryUsage).mockReturnValue('100 MB'); + vi.mocked(formatBytes).mockReturnValue('100 MB'); vi.stubEnv('SANDBOX', 'gemini-test'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); }); afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + vi.useRealTimers(); }); it('should generate the default GitHub issue URL', async () => { @@ -66,6 +87,12 @@ describe('bugCommand', () => { getModel: () => 'gemini-pro', getBugCommand: () => undefined, getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => [], + }), + }), + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), }, }, }); @@ -80,19 +107,66 @@ describe('bugCommand', () => { * **Operating System:** test-platform v20.0.0 * **Sandbox Environment:** test * **Model Version:** gemini-pro +* **Auth Type:** oauth-personal * **Memory Usage:** 100 MB * **Terminal Name:** Test Terminal * **Terminal Background:** #000000 * **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; - const expectedUrl = - 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' + - encodeURIComponent(expectedInfo); + const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`; expect(open).toHaveBeenCalledWith(expectedUrl); }); + it('should export chat history if available', async () => { + const history = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ]; + const mockContext = createMockCommandContext({ + services: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => history, + }), + }), + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + }, + }, + }); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(mockContext, 'Bug with history'); + + const expectedPath = path.join( + '/tmp/gemini', + 'bug-report-history-1704067200000.json', + ); + expect(exportHistoryToFile).toHaveBeenCalledWith({ + history, + filePath: expectedPath, + }); + + const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; + const messageText = addItemCall[0].text; + expect(messageText).toContain(expectedPath); + expect(messageText).toContain('๐Ÿ“„ **Chat History Exported**'); + expect(messageText).toContain('Privacy Disclaimer:'); + expect(messageText).not.toContain('additional-context='); + expect(messageText).toContain('problem='); + const reminder = + '\n\n[ACTION REQUIRED] ๐Ÿ“Ž PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.'; + expect(messageText).toContain(encodeURIComponent(reminder)); + }); + it('should use a custom URL template from config if provided', async () => { const customTemplate = 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; @@ -102,6 +176,12 @@ describe('bugCommand', () => { getModel: () => 'gemini-pro', getBugCommand: () => ({ urlTemplate: customTemplate }), getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => [], + }), + }), + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), }, }, }); @@ -116,6 +196,7 @@ describe('bugCommand', () => { * **Operating System:** test-platform v20.0.0 * **Sandbox Environment:** test * **Model Version:** gemini-pro +* **Auth Type:** vertex-ai * **Memory Usage:** 100 MB * **Terminal Name:** Test Terminal * **Terminal Background:** #000000 diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 21df2028cc..26ddb7e850 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -13,9 +13,17 @@ import { } from './types.js'; import { MessageType } from '../types.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; -import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core'; +import { formatBytes } from '../utils/formatters.js'; +import { + IdeClient, + sessionId, + getVersion, + INITIAL_HISTORY_LENGTH, + debugLogger, +} from '@google/gemini-cli-core'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; +import path from 'node:path'; export const bugCommand: SlashCommand = { name: 'bug', @@ -37,7 +45,7 @@ export const bugCommand: SlashCommand = { } const modelVersion = config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); - const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); + const memoryUsage = formatBytes(process.memoryUsage().rss); const ideClient = await getIdeClientName(context); const terminalName = terminalCapabilityManager.getTerminalName() || 'Unknown'; @@ -46,6 +54,7 @@ export const bugCommand: SlashCommand = { const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled() ? 'Supported' : 'Unsupported'; + const authType = config?.getContentGeneratorConfig()?.authType || 'Unknown'; let info = ` * **CLI Version:** ${cliVersion} @@ -54,6 +63,7 @@ export const bugCommand: SlashCommand = { * **Operating System:** ${osVersion} * **Sandbox Environment:** ${sandboxEnv} * **Model Version:** ${modelVersion} +* **Auth Type:** ${authType} * **Memory Usage:** ${memoryUsage} * **Terminal Name:** ${terminalName} * **Terminal Background:** ${terminalBgColor} @@ -63,8 +73,31 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } + const chat = config?.getGeminiClient()?.getChat(); + const history = chat?.getHistory() || []; + let historyFileMessage = ''; + let problemValue = bugDescription; + + if (history.length > INITIAL_HISTORY_LENGTH) { + const tempDir = config?.storage?.getProjectTempDir(); + if (tempDir) { + const historyFileName = `bug-report-history-${Date.now()}.json`; + const historyFilePath = path.join(tempDir, historyFileName); + try { + await exportHistoryToFile({ history, filePath: historyFilePath }); + historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n๐Ÿ“„ **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`; + problemValue += `\n\n[ACTION REQUIRED] ๐Ÿ“Ž PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + debugLogger.error( + `Failed to export chat history for bug report: ${errorMessage}`, + ); + } + } + } + let bugReportUrl = - 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}'; + 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}'; const bugCommandSettings = config?.getBugCommand(); if (bugCommandSettings?.urlTemplate) { @@ -73,12 +106,13 @@ export const bugCommand: SlashCommand = { bugReportUrl = bugReportUrl .replace('{title}', encodeURIComponent(bugDescription)) - .replace('{info}', encodeURIComponent(info)); + .replace('{info}', encodeURIComponent(info)) + .replace('{problem}', encodeURIComponent(problemValue)); context.ui.addItem( { type: MessageType.INFO, - text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, + text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`, }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c934c29dfd..6ff8d8a52e 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mocked } from 'vitest'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { SlashCommand, CommandContext } from './types.js'; @@ -13,7 +12,11 @@ import type { Content } from '@google/genai'; import { AuthType, type GeminiClient } from '@google/gemini-cli-core'; import * as fsPromises from 'node:fs/promises'; -import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js'; +import { chatCommand, debugCommand } from './chatCommand.js'; +import { + serializeHistoryToMarkdown, + exportHistoryToFile, +} from '../utils/historyExportUtils.js'; import type { Stats } from 'node:fs'; import type { HistoryItemWithoutId } from '../types.js'; import path from 'node:path'; @@ -24,8 +27,18 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), })); +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); + describe('chatCommand', () => { - const mockFs = fsPromises as Mocked; + const mockFs = vi.mocked(fsPromises); + const mockExport = vi.mocked(exportHistoryToFile); let mockContext: CommandContext; let mockGetChat: ReturnType; @@ -114,22 +127,19 @@ describe('chatCommand', () => { await listCommand?.action?.(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: 'chat_list', - chats: [ - { - name: 'test1', - mtime: date1.toISOString(), - }, - { - name: 'test2', - mtime: date2.toISOString(), - }, - ], - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: 'chat_list', + chats: [ + { + name: 'test1', + mtime: date1.toISOString(), + }, + { + name: 'test2', + mtime: date2.toISOString(), + }, + ], + }); }); }); describe('save subcommand', () => { @@ -448,9 +458,10 @@ describe('chatCommand', () => { process.cwd(), 'gemini-conversation-1234567890.json', ); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2)); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -462,9 +473,10 @@ describe('chatCommand', () => { const filePath = 'my-chat.json'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2)); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -476,30 +488,10 @@ describe('chatCommand', () => { const filePath = 'my-chat.md'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const expectedContent = `## USER ๐Ÿง‘โ€๐Ÿ’ป - -context - ---- - -## MODEL โœจ - -context response - ---- - -## USER ๐Ÿง‘โ€๐Ÿ’ป - -Hello - ---- - -## MODEL โœจ - -Hi there!`; - expect(actualContent).toEqual(expectedContent); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -510,7 +502,7 @@ Hi there!`; it('should return an error for unsupported file extensions', async () => { const filePath = 'my-chat.txt'; const result = await shareCommand?.action?.(mockContext, filePath); - expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', @@ -523,7 +515,7 @@ Hi there!`; { role: 'user', parts: [{ text: 'context' }] }, ]); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); - expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -533,7 +525,7 @@ Hi there!`; it('should handle errors during file writing', async () => { const error = new Error('Permission denied'); - mockFs.writeFile.mockRejectedValue(error); + mockExport.mockRejectedValue(error); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(result).toEqual({ type: 'message', @@ -546,14 +538,9 @@ Hi there!`; const filePath = 'my-chat.json'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const parsedContent = JSON.parse(actualContent as string); - expect(Array.isArray(parsedContent)).toBe(true); - parsedContent.forEach((item: Content) => { - expect(item).toHaveProperty('role'); - expect(item).toHaveProperty('parts'); - expect(Array.isArray(item.parts)).toBe(true); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, }); }); @@ -561,15 +548,9 @@ Hi there!`; const filePath = 'my-chat.md'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const entries = (actualContent as string).split('\n\n---\n\n'); - expect(entries.length).toBe(mockHistory.length); - entries.forEach((entry: string, index: number) => { - const { role, parts } = mockHistory[index]; - const text = parts.map((p) => p.text).join(''); - const roleIcon = role === 'user' ? '๐Ÿง‘โ€๐Ÿ’ป' : 'โœจ'; - expect(entry).toBe(`## ${role.toUpperCase()} ${roleIcon}\n\n${text}`); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, }); }); }); @@ -709,5 +690,66 @@ Hi there!`; const result = serializeHistoryToMarkdown(history as Content[]); expect(result).toBe(expectedMarkdown); }); + describe('debug subcommand', () => { + let mockGetLatestApiRequest: ReturnType; + + beforeEach(() => { + mockGetLatestApiRequest = vi.fn(); + mockContext.services.config!.getLatestApiRequest = + mockGetLatestApiRequest; + vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + mockFs.writeFile.mockClear(); + }); + + it('should return an error if no API request is found', async () => { + mockGetLatestApiRequest.mockReturnValue(undefined); + + const result = await debugCommand.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No recent API request found to export.', + }); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); + + it('should convert and write the API request to a json file', async () => { + const mockRequest = { + contents: [{ role: 'user', parts: [{ text: 'test' }] }], + }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); + + const result = await debugCommand.action?.(mockContext, ''); + + const expectedFilename = 'gcli-request-1234567890.json'; + const expectedPath = path.join('/project/root', expectedFilename); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + expectedPath, + expect.stringContaining('"role": "user"'), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Debug API request saved to ${expectedFilename}`, + }); + }); + + it('should handle errors during file write', async () => { + const mockRequest = { contents: [] }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); + mockFs.writeFile.mockRejectedValue(new Error('Write failed')); + + const result = await debugCommand.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Error saving debug request: Write failed', + }); + }); + }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 4b0078309c..3dafe59554 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -26,7 +26,8 @@ import type { ChatDetail, } from '../types.js'; import { MessageType } from '../types.js'; -import type { Content } from '@google/genai'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; +import { convertToRestPayload } from '@google/gemini-cli-core'; const getSavedChatTags = async ( context: CommandContext, @@ -80,7 +81,7 @@ const listCommand: SlashCommand = { chats: chatDetails, }; - context.ui.addItem(item, Date.now()); + context.ui.addItem(item); }, }; @@ -272,38 +273,6 @@ const deleteCommand: SlashCommand = { }, }; -export function serializeHistoryToMarkdown(history: Content[]): string { - return history - .map((item) => { - const text = - item.parts - ?.map((part) => { - if (part.text) { - return part.text; - } - if (part.functionCall) { - return `**Tool Command**:\n\`\`\`json\n${JSON.stringify( - part.functionCall, - null, - 2, - )}\n\`\`\``; - } - if (part.functionResponse) { - return `**Tool Response**:\n\`\`\`json\n${JSON.stringify( - part.functionResponse, - null, - 2, - )}\n\`\`\``; - } - return ''; - }) - .join('') || ''; - const roleIcon = item.role === 'user' ? '๐Ÿง‘โ€๐Ÿ’ป' : 'โœจ'; - return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`; - }) - .join('\n\n---\n\n'); -} - const shareCommand: SlashCommand = { name: 'share', description: @@ -348,15 +317,8 @@ const shareCommand: SlashCommand = { }; } - let content = ''; - if (extension === '.json') { - content = JSON.stringify(history, null, 2); - } else { - content = serializeHistoryToMarkdown(history); - } - try { - await fsPromises.writeFile(filePath, content); + await exportHistoryToFile({ history, filePath }); return { type: 'message', messageType: 'info', @@ -373,6 +335,46 @@ const shareCommand: SlashCommand = { }, }; +export const debugCommand: SlashCommand = { + name: 'debug', + description: 'Export the most recent API request as a JSON payload', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context): Promise => { + const req = context.services.config?.getLatestApiRequest(); + if (!req) { + return { + type: 'message', + messageType: 'error', + content: 'No recent API request found to export.', + }; + } + + const restPayload = convertToRestPayload(req); + const filename = `gcli-request-${Date.now()}.json`; + const filePath = path.join(process.cwd(), filename); + + try { + await fsPromises.writeFile( + filePath, + JSON.stringify(restPayload, null, 2), + ); + return { + type: 'message', + messageType: 'info', + content: `Debug API request saved to ${filename}`, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { + type: 'message', + messageType: 'error', + content: `Error saving debug request: ${errorMessage}`, + }; + } + }, +}; + export const chatCommand: SlashCommand = { name: 'chat', description: 'Manage conversation history', diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 7f16a3ddf6..bc204044f7 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -46,6 +46,10 @@ describe('clearCommand', () => { setSessionId: vi.fn(), getEnableHooks: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), }, }, }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index d2edbebbf2..7707876000 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -6,14 +6,13 @@ import { uiTelemetryService, - fireSessionEndHook, - fireSessionStartHook, SessionEndReason, SessionStartSource, flushTelemetry, } from '@google/gemini-cli-core'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; import { randomUUID } from 'node:crypto'; export const clearCommand: SlashCommand = { @@ -28,11 +27,11 @@ export const clearCommand: SlashCommand = { ?.getGeminiClient() ?.getChat() .getChatRecordingService(); - const messageBus = config?.getMessageBus(); // Fire SessionEnd hook before clearing - if (config?.getEnableHooks() && messageBus) { - await fireSessionEndHook(messageBus, SessionEndReason.Clear); + const hookSystem = config?.getHookSystem(); + if (hookSystem) { + await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); } if (geminiClient) { @@ -52,8 +51,9 @@ export const clearCommand: SlashCommand = { } // Fire SessionStart hook after clearing - if (config?.getEnableHooks() && messageBus) { - await fireSessionStartHook(messageBus, SessionStartSource.Clear); + let result; + if (hookSystem) { + result = await hookSystem.fireSessionStartEvent(SessionStartSource.Clear); } // Give the event loop a chance to process any pending telemetry operations @@ -68,5 +68,15 @@ export const clearCommand: SlashCommand = { uiTelemetryService.setLastPromptTokenCount(0); context.ui.clear(); + + if (result?.systemMessage) { + context.ui.addItem( + { + type: MessageType.INFO, + text: result.systemMessage, + }, + Date.now(), + ); + } }, }; diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 1c43149440..91ace7fca5 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -17,9 +17,18 @@ import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; import { MessageType } from '../types.js'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; import * as trustedFolders from '../../config/trustedFolders.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p) => p), + }; +}); + vi.mock('../utils/directoryUtils.js', async (importOriginal) => { const actual = await importOriginal(); @@ -42,12 +51,14 @@ describe('directoryCommand', () => { beforeEach(() => { mockWorkspaceContext = { + targetDir: path.resolve('/test/dir'), addDirectory: vi.fn(), + addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }), getDirectories: vi .fn() .mockReturnValue([ - path.normalize('/home/user/project1'), - path.normalize('/home/user/project2'), + path.resolve('/home/user/project1'), + path.resolve('/home/user/project2'), ]), } as unknown as WorkspaceContext; @@ -56,8 +67,11 @@ describe('directoryCommand', () => { isRestrictiveSandbox: vi.fn().mockReturnValue(false), getGeminiClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), + getChatRecordingService: vi.fn().mockReturnValue({ + recordDirectories: vi.fn(), + }), }), - getWorkingDir: () => '/test/dir', + getWorkingDir: () => path.resolve('/test/dir'), shouldLoadMemoryFromIncludeDirectories: () => false, getDebugMode: () => false, getFileService: () => ({}), @@ -90,11 +104,10 @@ describe('directoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: `Current workspace directories:\n- ${path.normalize( + text: `Current workspace directories:\n- ${path.resolve( '/home/user/project1', - )}\n- ${path.normalize('/home/user/project2')}`, + )}\n- ${path.resolve('/home/user/project2')}`, }), - expect.any(Number), ); }); }); @@ -121,46 +134,56 @@ describe('directoryCommand', () => { type: MessageType.ERROR, text: 'Please provide at least one path to add.', }), - expect.any(Number), ); }); it('should call addDirectory and show a success message for a single path', async () => { - const newPath = path.normalize('/home/user/new-project'); + const newPath = path.resolve('/home/user/new-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, newPath); - expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Successfully added directories:\n- ${newPath}`, }), - expect.any(Number), ); }); it('should call addDirectory for each path and show a success message for multiple paths', async () => { - const newPath1 = path.normalize('/home/user/new-project1'); - const newPath2 = path.normalize('/home/user/new-project2'); + const newPath1 = path.resolve('/home/user/new-project1'); + const newPath2 = path.resolve('/home/user/new-project2'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath1, newPath2], + failed: [], + }); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, `${newPath1},${newPath2}`); - expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1); - expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath1, + newPath2, + ]); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, }), - expect.any(Number), ); }); it('should show an error if addDirectory throws an exception', async () => { const error = new Error('Directory does not exist'); - vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => { - throw error; + const newPath = path.resolve('/home/user/invalid-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [], + failed: [{ path: newPath, error }], }); - const newPath = path.normalize('/home/user/invalid-project'); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, newPath); expect(mockContext.ui.addItem).toHaveBeenCalledWith( @@ -168,22 +191,27 @@ describe('directoryCommand', () => { type: MessageType.ERROR, text: `Error adding '${newPath}': ${error.message}`, }), - expect.any(Number), ); }); it('should add directory directly when folder trust is disabled', async () => { if (!addCommand?.action) throw new Error('No action'); vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false); - const newPath = path.normalize('/home/user/new-project'); + const newPath = path.resolve('/home/user/new-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); await addCommand.action(mockContext, newPath); - expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); }); it('should show an info message for an already added directory', async () => { - const existingPath = path.normalize('/home/user/project1'); + const existingPath = path.resolve('/home/user/project1'); if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, existingPath); expect(mockContext.ui.addItem).toHaveBeenCalledWith( @@ -191,25 +219,45 @@ describe('directoryCommand', () => { type: MessageType.INFO, text: `The following directories are already in the workspace:\n- ${existingPath}`, }), - expect.any(Number), ); expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalledWith( existingPath, ); }); - it('should handle a mix of successful and failed additions', async () => { - const validPath = path.normalize('/home/user/valid-project'); - const invalidPath = path.normalize('/home/user/invalid-project'); - const error = new Error('Directory does not exist'); - vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation( - (p: string) => { - if (p === invalidPath) { - throw error; - } - }, + it('should show an info message for an already added directory specified as a relative path', async () => { + const existingPath = path.resolve('/home/user/project1'); + const relativePath = './project1'; + const absoluteRelativePath = path.resolve( + path.resolve('/test/dir'), + relativePath, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === absoluteRelativePath) return existingPath; + return p as string; + }); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, relativePath); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `The following directories are already in the workspace:\n- ${relativePath}`, + }), + ); + }); + + it('should handle a mix of successful and failed additions', async () => { + const validPath = path.resolve('/home/user/valid-project'); + const invalidPath = path.resolve('/home/user/invalid-project'); + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [validPath], + failed: [{ path: invalidPath, error }], + }); + if (!addCommand?.action) throw new Error('No action'); await addCommand.action(mockContext, `${validPath},${invalidPath}`); @@ -218,7 +266,6 @@ describe('directoryCommand', () => { type: MessageType.INFO, text: `Successfully added directories:\n- ${validPath}`, }), - expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( @@ -226,7 +273,6 @@ describe('directoryCommand', () => { type: MessageType.ERROR, text: `Error adding '${invalidPath}': ${error.message}`, }), - expect.any(Number), ); }); @@ -269,6 +315,21 @@ describe('directoryCommand', () => { expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); expect(results).toEqual(['docs/, src/']); }); + + it('should filter out existing directories from suggestions', async () => { + const existingPath = path.resolve(process.cwd(), 'existing'); + vi.mocked(mockWorkspaceContext.getDirectories).mockReturnValue([ + existingPath, + ]); + vi.mocked(getDirectorySuggestions).mockResolvedValue([ + 'existing/', + 'new/', + ]); + + const results = await completion(mockContext, 'ex'); + + expect(results).toEqual(['new/']); + }); }); }); @@ -277,10 +338,7 @@ describe('directoryCommand', () => { beforeEach(() => { vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true); - vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ - isTrusted: true, - source: 'file', - }); + // isWorkspaceTrusted is no longer checked, so we don't need to mock it returning true mockIsPathTrusted = vi.fn(); const mockLoadedFolders = { isPathTrusted: mockIsPathTrusted, @@ -297,34 +355,23 @@ describe('directoryCommand', () => { it('should add a trusted directory', async () => { if (!addCommand?.action) throw new Error('No action'); mockIsPathTrusted.mockReturnValue(true); - const newPath = path.normalize('/home/user/trusted-project'); + const newPath = path.resolve('/home/user/trusted-project'); + vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({ + added: [newPath], + failed: [], + }); await addCommand.action(mockContext, newPath); - expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([ + newPath, + ]); }); - it('should show an error for an untrusted directory', async () => { + it('should return a custom dialog for an explicitly untrusted directory (upgrade flow)', async () => { if (!addCommand?.action) throw new Error('No action'); - mockIsPathTrusted.mockReturnValue(false); - const newPath = path.normalize('/home/user/untrusted-project'); - - await addCommand.action(mockContext, newPath); - - expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled(); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.ERROR, - text: expect.stringContaining('explicitly untrusted'), - }), - expect.any(Number), - ); - }); - - it('should return a custom dialog for a directory with undefined trust', async () => { - if (!addCommand?.action) throw new Error('No action'); - mockIsPathTrusted.mockReturnValue(undefined); - const newPath = path.normalize('/home/user/undefined-trust-project'); + mockIsPathTrusted.mockReturnValue(false); // DO_NOT_TRUST + const newPath = path.resolve('/home/user/untrusted-project'); const result = await addCommand.action(mockContext, newPath); @@ -343,6 +390,48 @@ describe('directoryCommand', () => { .component as React.ReactElement; expect(component.props.folders.includes(newPath)).toBeTruthy(); }); + + it('should return a custom dialog for a directory with undefined trust', async () => { + if (!addCommand?.action) throw new Error('No action'); + mockIsPathTrusted.mockReturnValue(undefined); + const newPath = path.resolve('/home/user/undefined-trust-project'); + + const result = await addCommand.action(mockContext, newPath); + + expect(result).toEqual( + expect.objectContaining({ + type: 'custom_dialog', + component: expect.objectContaining({ + type: expect.any(Function), // React component for MultiFolderTrustDialog + }), + }), + ); + if (!result) { + throw new Error('Command did not return a result'); + } + const component = (result as OpenCustomDialogActionReturn) + .component as React.ReactElement; + expect(component.props.folders.includes(newPath)).toBeTruthy(); + }); + + it('should prompt for directory even if workspace is untrusted', async () => { + if (!addCommand?.action) throw new Error('No action'); + // Even if workspace is untrusted, we should still check directory trust + vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({ + isTrusted: false, + source: 'file', + }); + mockIsPathTrusted.mockReturnValue(undefined); + const newPath = path.resolve('/home/user/new-project'); + + const result = await addCommand.action(mockContext, newPath); + + expect(result).toEqual( + expect.objectContaining({ + type: 'custom_dialog', + }), + ); + }); }); it('should correctly expand a Windows-style home directory path', () => { diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 872945ecea..2da2f107df 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -6,7 +6,6 @@ import { isFolderTrustEnabled, - isWorkspaceTrusted, loadTrustedFolders, } from '../../config/trustedFolders.js'; import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js'; @@ -17,57 +16,63 @@ import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; import { expandHomeDir, getDirectorySuggestions, + batchAddDirectories, } from '../utils/directoryUtils.js'; import type { Config } from '@google/gemini-cli-core'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; async function finishAddingDirectories( config: Config, - addItem: (itemData: Omit, baseTimestamp: number) => number, + addItem: ( + itemData: Omit, + baseTimestamp?: number, + ) => number, added: string[], errors: string[], ) { if (!config) { - addItem( - { - type: MessageType.ERROR, - text: 'Configuration is not available.', - }, - Date.now(), - ); + addItem({ + type: MessageType.ERROR, + text: 'Configuration is not available.', + }); return; } - try { - if (config.shouldLoadMemoryFromIncludeDirectories()) { - await refreshServerHierarchicalMemory(config); - } - addItem( - { + if (added.length > 0) { + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + await refreshServerHierarchicalMemory(config); + } + addItem({ type: MessageType.INFO, text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, - }, - Date.now(), - ); - } catch (error) { - errors.push(`Error refreshing memory: ${(error as Error).message}`); + }); + } catch (error) { + errors.push(`Error refreshing memory: ${(error as Error).message}`); + } } if (added.length > 0) { const gemini = config.getGeminiClient(); if (gemini) { await gemini.addDirectoryContext(); + + // Persist directories to session file for resume support + const chatRecordingService = gemini.getChatRecordingService(); + const workspaceContext = config.getWorkspaceContext(); + chatRecordingService?.recordDirectories( + workspaceContext.getDirectories(), + ); } - addItem( - { - type: MessageType.INFO, - text: `Successfully added directories:\n- ${added.join('\n- ')}`, - }, - Date.now(), - ); + addItem({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${added.join('\n- ')}`, + }); } if (errors.length > 0) { - addItem({ type: MessageType.ERROR, text: errors.join('\n') }, Date.now()); + addItem({ type: MessageType.ERROR, text: errors.join('\n') }); } } @@ -97,12 +102,38 @@ export const directoryCommand: SlashCommand = { const suggestions = await getDirectorySuggestions(trimmedLastPart); - if (parts.length > 1) { - const prefix = parts.slice(0, -1).join(',') + ','; - return suggestions.map((s) => prefix + leadingWhitespace + s); + // Filter out existing directories + let filteredSuggestions = suggestions; + if (context.services.config) { + const workspaceContext = + context.services.config.getWorkspaceContext(); + const existingDirs = new Set( + workspaceContext.getDirectories().map((dir) => path.resolve(dir)), + ); + + filteredSuggestions = suggestions.filter((s) => { + const expanded = expandHomeDir(s); + const absolute = path.resolve(expanded); + + if (existingDirs.has(absolute)) { + return false; + } + if ( + absolute.endsWith(path.sep) && + existingDirs.has(absolute.slice(0, -1)) + ) { + return false; + } + return true; + }); } - return suggestions.map((s) => leadingWhitespace + s); + if (parts.length > 1) { + const prefix = parts.slice(0, -1).join(',') + ','; + return filteredSuggestions.map((s) => prefix + leadingWhitespace + s); + } + + return filteredSuggestions.map((s) => leadingWhitespace + s); }, action: async (context: CommandContext, args: string) => { const { @@ -112,13 +143,10 @@ export const directoryCommand: SlashCommand = { const [...rest] = args.split(' '); if (!config) { - addItem( - { - type: MessageType.ERROR, - text: 'Configuration is not available.', - }, - Date.now(), - ); + addItem({ + type: MessageType.ERROR, + text: 'Configuration is not available.', + }); return; } @@ -136,13 +164,10 @@ export const directoryCommand: SlashCommand = { .split(',') .filter((p) => p); if (pathsToAdd.length === 0) { - addItem( - { - type: MessageType.ERROR, - text: 'Please provide at least one path to add.', - }, - Date.now(), - ); + addItem({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }); return; } @@ -155,75 +180,68 @@ export const directoryCommand: SlashCommand = { const pathsToProcess: string[] = []; for (const pathToAdd of pathsToAdd) { - const expandedPath = expandHomeDir(pathToAdd.trim()); - if (currentWorkspaceDirs.includes(expandedPath)) { - alreadyAdded.push(pathToAdd.trim()); - } else { - pathsToProcess.push(pathToAdd.trim()); + const trimmedPath = pathToAdd.trim(); + const expandedPath = expandHomeDir(trimmedPath); + try { + const absolutePath = path.resolve( + workspaceContext.targetDir, + expandedPath, + ); + const resolvedPath = fs.realpathSync(absolutePath); + if (currentWorkspaceDirs.includes(resolvedPath)) { + alreadyAdded.push(trimmedPath); + continue; + } + } catch (_e) { + // Path might not exist or be inaccessible. + // We'll let batchAddDirectories handle it later. } + pathsToProcess.push(trimmedPath); } if (alreadyAdded.length > 0) { - addItem( - { - type: MessageType.INFO, - text: `The following directories are already in the workspace:\n- ${alreadyAdded.join( - '\n- ', - )}`, - }, - Date.now(), - ); + addItem({ + type: MessageType.INFO, + text: `The following directories are already in the workspace:\n- ${alreadyAdded.join( + '\n- ', + )}`, + }); } if (pathsToProcess.length === 0) { return; } - if ( - isFolderTrustEnabled(settings.merged) && - isWorkspaceTrusted(settings.merged).isTrusted - ) { + if (isFolderTrustEnabled(settings.merged)) { const trustedFolders = loadTrustedFolders(); - const untrustedDirs: string[] = []; - const undefinedTrustDirs: string[] = []; + const dirsToConfirm: string[] = []; const trustedDirs: string[] = []; for (const pathToAdd of pathsToProcess) { - const expandedPath = expandHomeDir(pathToAdd.trim()); + const expandedPath = path.resolve(expandHomeDir(pathToAdd.trim())); const isTrusted = trustedFolders.isPathTrusted(expandedPath); - if (isTrusted === false) { - untrustedDirs.push(pathToAdd.trim()); - } else if (isTrusted === undefined) { - undefinedTrustDirs.push(pathToAdd.trim()); - } else { + // If explicitly trusted, add immediately. + // If undefined or explicitly untrusted (DO_NOT_TRUST), prompt for confirmation. + // This allows users to "upgrade" a DO_NOT_TRUST folder to trusted via the dialog. + if (isTrusted === true) { trustedDirs.push(pathToAdd.trim()); + } else { + dirsToConfirm.push(pathToAdd.trim()); } } - if (untrustedDirs.length > 0) { - errors.push( - `The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join( - '\n- ', - )}\nPlease use the permissions command to modify their trust level.`, - ); + if (trustedDirs.length > 0) { + const result = batchAddDirectories(workspaceContext, trustedDirs); + added.push(...result.added); + errors.push(...result.errors); } - for (const pathToAdd of trustedDirs) { - try { - workspaceContext.addDirectory(expandHomeDir(pathToAdd)); - added.push(pathToAdd); - } catch (e) { - const error = e as Error; - errors.push(`Error adding '${pathToAdd}': ${error.message}`); - } - } - - if (undefinedTrustDirs.length > 0) { + if (dirsToConfirm.length > 0) { return { type: 'custom_dialog', component: ( `- ${dir}`).join('\n'); - addItem( - { - type: MessageType.INFO, - text: `Current workspace directories:\n${directoryList}`, - }, - Date.now(), - ); + addItem({ + type: MessageType.INFO, + text: `Current workspace directories:\n${directoryList}`, + }); }, }, ], diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 4af145b631..9e46ab47aa 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -31,6 +31,7 @@ import { inferInstallMetadata, } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; +import { stat } from 'node:fs/promises'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = @@ -42,11 +43,16 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => { }); import open from 'open'; +import type { Stats } from 'node:fs'; vi.mock('open', () => ({ default: vi.fn(), })); +vi.mock('node:fs/promises', () => ({ + stat: vi.fn(), +})); + vi.mock('../../config/extensions/update.js', () => ({ updateExtension: vi.fn(), checkForAllExtensionUpdates: vi.fn(), @@ -142,13 +148,10 @@ describe('extensionsCommand', () => { if (!command.action) throw new Error('Action not defined'); await command.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - extensions: expect.any(Array), - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); }); it('should show a message if no extensions are installed', async () => { @@ -157,13 +160,10 @@ describe('extensionsCommand', () => { if (!command.action) throw new Error('Action not defined'); await command.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); }); }); @@ -238,26 +238,20 @@ describe('extensionsCommand', () => { it('should show usage if no args are provided', async () => { await updateAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions update |--all', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }); }); it('should show a message if no extensions are installed', async () => { mockGetExtensions.mockReturnValue([]); await updateAction(mockContext, 'ext-one'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); }); it('should inform user if there are no extensions to update with --all', async () => { @@ -270,13 +264,10 @@ describe('extensionsCommand', () => { ); await updateAction(mockContext, '--all'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions to update.', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions to update.', + }); }); it('should call setPendingItem and addItem in a finally block on success', async () => { @@ -304,13 +295,10 @@ describe('extensionsCommand', () => { extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - extensions: expect.any(Array), - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); }); it('should call setPendingItem and addItem in a finally block on failure', async () => { @@ -323,20 +311,14 @@ describe('extensionsCommand', () => { extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - extensions: expect.any(Array), - }, - expect.any(Number), - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Something went wrong', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Something went wrong', + }); }); it('should update a single extension by name', async () => { @@ -397,13 +379,10 @@ describe('extensionsCommand', () => { extensions: expect.any(Array), }); expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.EXTENSIONS_LIST, - extensions: expect.any(Array), - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.EXTENSIONS_LIST, + extensions: expect.any(Array), + }); }); }); @@ -424,13 +403,10 @@ describe('extensionsCommand', () => { await exploreAction(mockContext, ''); const extensionsUrl = 'https://geminicli.com/extensions/'; - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Opening extensions page in your browser: ${extensionsUrl}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }); expect(open).toHaveBeenCalledWith(extensionsUrl); }); @@ -443,13 +419,10 @@ describe('extensionsCommand', () => { await exploreAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `View available extensions at ${extensionsUrl}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }); // Ensure 'open' was not called in the sandbox expect(open).not.toHaveBeenCalled(); @@ -462,13 +435,10 @@ describe('extensionsCommand', () => { await exploreAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }); // Ensure 'open' was not called in test environment expect(open).not.toHaveBeenCalled(); @@ -482,45 +452,45 @@ describe('extensionsCommand', () => { await exploreAction(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }); }); }); describe('when enableExtensionReloading is true', () => { - it('should include enable, disable, install, and uninstall subcommands', () => { + it('should include enable, disable, install, link, and uninstall subcommands', () => { const command = extensionsCommand(true); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); expect(subCommandNames).toContain('install'); + expect(subCommandNames).toContain('link'); expect(subCommandNames).toContain('uninstall'); }); }); describe('when enableExtensionReloading is false', () => { - it('should not include enable, disable, install, and uninstall subcommands', () => { + it('should not include enable, disable, install, link, and uninstall subcommands', () => { const command = extensionsCommand(false); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('link'); expect(subCommandNames).not.toContain('uninstall'); }); }); describe('when enableExtensionReloading is not provided', () => { - it('should not include enable, disable, install, and uninstall subcommands by default', () => { + it('should not include enable, disable, install, link, and uninstall subcommands by default', () => { const command = extensionsCommand(); const subCommandNames = command.subCommands?.map((cmd) => cmd.name); expect(subCommandNames).not.toContain('enable'); expect(subCommandNames).not.toContain('disable'); expect(subCommandNames).not.toContain('install'); + expect(subCommandNames).not.toContain('link'); expect(subCommandNames).not.toContain('uninstall'); }); }); @@ -540,13 +510,10 @@ describe('extensionsCommand', () => { it('should show usage if no extension name is provided', async () => { await installAction!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions install ', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions install ', + }); expect(mockInstallExtension).not.toHaveBeenCalled(); }); @@ -563,20 +530,14 @@ describe('extensionsCommand', () => { source: packageName, type: 'git', }); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Installing extension from "${packageName}"...`, - }, - expect.any(Number), - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Extension "${packageName}" installed successfully.`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Installing extension from "${packageName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${packageName}" installed successfully.`, + }); }); it('should show error message on installation failure', async () => { @@ -594,25 +555,89 @@ describe('extensionsCommand', () => { source: packageName, type: 'git', }); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: `Failed to install extension from "${packageName}": ${errorMessage}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to install extension from "${packageName}": ${errorMessage}`, + }); }); it('should show error message for invalid source', async () => { const invalidSource = 'a;b'; await installAction!(mockContext, invalidSource); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: `Invalid source: ${invalidSource}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Invalid source: ${invalidSource}`, + }); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + }); + + describe('link', () => { + let linkAction: SlashCommand['action']; + + beforeEach(() => { + linkAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'link', + )?.action; + + expect(linkAction).not.toBeNull(); + mockContext.invocation!.name = 'link'; + }); + + it('should show usage if no extension is provided', async () => { + await linkAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions link ', + }); + expect(mockInstallExtension).not.toHaveBeenCalled(); + }); + + it('should call installExtension and show success message', async () => { + const packageName = 'test-extension-package'; + mockInstallExtension.mockResolvedValue({ name: packageName }); + vi.mocked(stat).mockResolvedValue({ + size: 100, + } as Stats); + await linkAction!(mockContext, packageName); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'link', + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Linking extension from "${packageName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${packageName}" linked successfully.`, + }); + }); + + it('should show error message on linking failure', async () => { + const packageName = 'test-extension-package'; + const errorMessage = 'link failed'; + mockInstallExtension.mockRejectedValue(new Error(errorMessage)); + vi.mocked(stat).mockResolvedValue({ + size: 100, + } as Stats); + + await linkAction!(mockContext, packageName); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'link', + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to link extension from "${packageName}": ${errorMessage}`, + }); + }); + + it('should show error message for invalid source', async () => { + const packageName = 'test-extension-package'; + const errorMessage = 'invalid path'; + vi.mocked(stat).mockRejectedValue(new Error(errorMessage)); + await linkAction!(mockContext, packageName); expect(mockInstallExtension).not.toHaveBeenCalled(); }); }); @@ -632,13 +657,10 @@ describe('extensionsCommand', () => { it('should show usage if no extension name is provided', async () => { await uninstallAction!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions uninstall ', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions uninstall ', + }); expect(mockUninstallExtension).not.toHaveBeenCalled(); }); @@ -646,20 +668,14 @@ describe('extensionsCommand', () => { const extensionName = 'test-extension'; await uninstallAction!(mockContext, extensionName); expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Uninstalling extension "${extensionName}"...`, - }, - expect.any(Number), - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: `Extension "${extensionName}" uninstalled successfully.`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Uninstalling extension "${extensionName}"...`, + }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: `Extension "${extensionName}" uninstalled successfully.`, + }); }); it('should show error message on uninstallation failure', async () => { @@ -669,13 +685,10 @@ describe('extensionsCommand', () => { await uninstallAction!(mockContext, extensionName); expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, + }); }); }); @@ -694,13 +707,10 @@ describe('extensionsCommand', () => { it('should show usage if no extension name is provided', async () => { await enableAction!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions enable [--scope=]', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions enable [--scope=]', + }); }); it('should call enableExtension with the provided scope', async () => { @@ -749,13 +759,10 @@ describe('extensionsCommand', () => { it('should show usage if no extension name is provided', async () => { await disableAction!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Usage: /extensions disable [--scope=]', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Usage: /extensions disable [--scope=]', + }); }); it('should call disableExtension with the provided scope', async () => { @@ -821,13 +828,10 @@ describe('extensionsCommand', () => { await restartAction!(mockContext, '--all'); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); }); it('restarts all active extensions when --all is provided', async () => { @@ -848,14 +852,12 @@ describe('extensionsCommand', () => { type: MessageType.INFO, text: 'Restarting 2 extensions...', }), - expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: '2 extensions restarted successfully.', }), - expect.any(Number), ); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ type: 'RESTARTED', @@ -895,7 +897,6 @@ describe('extensionsCommand', () => { type: MessageType.ERROR, text: "Extensions are not yet loaded, can't restart yet", }), - expect.any(Number), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); @@ -908,7 +909,6 @@ describe('extensionsCommand', () => { type: MessageType.ERROR, text: 'Usage: /extensions restart |--all', }), - expect.any(Number), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); @@ -928,7 +928,6 @@ describe('extensionsCommand', () => { type: MessageType.ERROR, text: 'Failed to restart some extensions:\n ext1: Failed to restart', }), - expect.any(Number), ); }); @@ -947,7 +946,6 @@ describe('extensionsCommand', () => { type: MessageType.WARNING, text: 'Extension(s) not found or not active: ext2', }), - expect.any(Number), ); }); @@ -965,7 +963,6 @@ describe('extensionsCommand', () => { type: MessageType.WARNING, text: 'Extension(s) not found or not active: ext2, ext3', }), - expect.any(Number), ); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 99ea05bccf..1258e30002 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { debugLogger, listExtensions } from '@google/gemini-cli-core'; +import { + debugLogger, + listExtensions, + type ExtensionInstallMetadata, +} from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; import { @@ -25,20 +29,19 @@ import { inferInstallMetadata, } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; import { theme } from '../semantic-colors.js'; +import { stat } from 'node:fs/promises'; function showMessageIfNoExtensions( context: CommandContext, extensions: unknown[], ): boolean { if (extensions.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', + }); return true; } return false; @@ -58,7 +61,7 @@ async function listAction(context: CommandContext) { extensions, }; - context.ui.addItem(historyItem, Date.now()); + context.ui.addItem(historyItem); } function updateAction(context: CommandContext, args: string): Promise { @@ -67,13 +70,10 @@ function updateAction(context: CommandContext, args: string): Promise { const names = all ? null : updateArgs; if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Usage: /extensions update |--all', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /extensions update |--all', + }); return Promise.resolve(); } @@ -98,16 +98,13 @@ function updateAction(context: CommandContext, args: string): Promise { // eslint-disable-next-line @typescript-eslint/no-floating-promises updateComplete.then((updateInfos) => { if (updateInfos.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: 'No extensions to update.', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: 'No extensions to update.', + }); } - context.ui.addItem(historyItem, Date.now()); + context.ui.addItem(historyItem); context.ui.setPendingItem(null); }); @@ -131,26 +128,20 @@ function updateAction(context: CommandContext, args: string): Promise { (extension) => extension.name === name, ); if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Extension ${name} not found.`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Extension ${name} not found.`, + }); continue; } } } } catch (error) { resolveUpdateComplete!([]); - context.ui.addItem( - { - type: MessageType.ERROR, - text: getErrorMessage(error), - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: getErrorMessage(error), + }); } return updateComplete.then((_) => {}); } @@ -161,13 +152,10 @@ async function restartAction( ): Promise { const extensionLoader = context.services.config?.getExtensionLoader(); if (!extensionLoader) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: "Extensions are not yet loaded, can't restart yet", - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: "Extensions are not yet loaded, can't restart yet", + }); return; } @@ -180,13 +168,10 @@ async function restartAction( const all = restartArgs.length === 1 && restartArgs[0] === '--all'; const names = all ? null : restartArgs; if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /extensions restart |--all', + }); return Promise.resolve(); } @@ -203,15 +188,10 @@ async function restartAction( !extensionsToRestart.some((extension) => extension.name === name), ); if (notFound.length > 0) { - context.ui.addItem( - { - type: MessageType.WARNING, - text: `Extension(s) not found or not active: ${notFound.join( - ', ', - )}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.WARNING, + text: `Extension(s) not found or not active: ${notFound.join(', ')}`, + }); } } } @@ -227,7 +207,7 @@ async function restartAction( text: `Restarting ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; - context.ui.addItem(restartingMessage, Date.now()); + context.ui.addItem(restartingMessage); const results = await Promise.allSettled( extensionsToRestart.map(async (extension) => { @@ -254,13 +234,10 @@ async function restartAction( return `${extensionName}: ${getErrorMessage(failure.reason)}`; }) .join('\n '); - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to restart some extensions:\n ${errorMessages}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to restart some extensions:\n ${errorMessages}`, + }); } else { const infoItem: HistoryItemInfo = { type: MessageType.INFO, @@ -268,7 +245,7 @@ async function restartAction( icon: emptyIcon, color: theme.text.primary, }; - context.ui.addItem(infoItem, Date.now()); + context.ui.addItem(infoItem); } } @@ -277,42 +254,30 @@ async function exploreAction(context: CommandContext) { // Only check for NODE_ENV for explicit test mode, not for unit test framework if (process.env['NODE_ENV'] === 'test') { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }); } else if ( process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec' ) { - context.ui.addItem( - { - type: MessageType.INFO, - text: `View available extensions at ${extensionsUrl}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }); } else { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Opening extensions page in your browser: ${extensionsUrl}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }); try { await open(extensionsUrl); } catch (_error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }); } } } @@ -341,13 +306,10 @@ function getEnableDisableContext( (parts.length === 3 && parts[1] === '--scope') // --scope ) ) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, + }); return null; } let scope: SettingScope; @@ -367,13 +329,10 @@ function getEnableDisableContext( scope = SettingScope.Session; break; default: - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, + }); debugLogger.error(); return null; } @@ -405,13 +364,10 @@ async function disableAction(context: CommandContext, args: string) { const { names, scope, extensionManager } = enableContext; for (const name of names) { await extensionManager.disableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${name}" disabled for the scope "${scope}"`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${name}" disabled for the scope "${scope}"`, + }); } } @@ -422,13 +378,42 @@ async function enableAction(context: CommandContext, args: string) { const { names, scope, extensionManager } = enableContext; for (const name of names) { await extensionManager.enableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${name}" enabled for the scope "${scope}"`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${name}" enabled for the scope "${scope}"`, + }); + + // Auto-enable any disabled MCP servers for this extension + const extension = extensionManager + .getExtensions() + .find((e) => e.name === name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpClientManager = context.services.config?.getMcpClientManager(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers ?? {}), + ); + + if (mcpClientManager && enabledServers.length > 0) { + const restartPromises = enabledServers.map((serverName) => + mcpClientManager.restartServer(serverName).catch((error) => { + context.ui.addItem({ + type: MessageType.WARNING, + text: `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, + }); + }), + ); + await Promise.all(restartPromises); + } + + if (enabledServers.length > 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: `Re-enabled MCP servers: ${enabledServers.join(', ')}`, + }); + } + } } } @@ -443,13 +428,10 @@ async function installAction(context: CommandContext, args: string) { const source = args.trim(); if (!source) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Usage: /extensions install `, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions install `, + }); return; } @@ -468,45 +450,97 @@ async function installAction(context: CommandContext, args: string) { } if (!isValid) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Invalid source: ${source}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid source: ${source}`, + }); return; } - context.ui.addItem( - { - type: MessageType.INFO, - text: `Installing extension from "${source}"...`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Installing extension from "${source}"...`, + }); try { const installMetadata = await inferInstallMetadata(source); const extension = await extensionLoader.installOrUpdateExtension(installMetadata); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${extension.name}" installed successfully.`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extension.name}" installed successfully.`, + }); } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to install extension from "${source}": ${getErrorMessage( - error, - )}`, - }, - Date.now(), + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to install extension from "${source}": ${getErrorMessage( + error, + )}`, + }); + } +} + +async function linkAction(context: CommandContext, args: string) { + const extensionLoader = context.services.config?.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, ); + return; + } + + const sourceFilepath = args.trim(); + if (!sourceFilepath) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions link `, + }); + return; + } + if (/[;&|`'"]/.test(sourceFilepath)) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Source file path contains disallowed characters: ${sourceFilepath}`, + }); + return; + } + + try { + await stat(sourceFilepath); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid source: ${sourceFilepath}`, + }); + debugLogger.error( + `Failed to stat path "${sourceFilepath}": ${getErrorMessage(error)}`, + ); + return; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Linking extension from "${sourceFilepath}"...`, + }); + + try { + const installMetadata: ExtensionInstallMetadata = { + source: sourceFilepath, + type: 'link', + }; + const extension = + await extensionLoader.installOrUpdateExtension(installMetadata); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extension.name}" linked successfully.`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to link extension from "${sourceFilepath}": ${getErrorMessage( + error, + )}`, + }); } } @@ -521,43 +555,31 @@ async function uninstallAction(context: CommandContext, args: string) { const name = args.trim(); if (!name) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Usage: /extensions uninstall `, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Usage: /extensions uninstall `, + }); return; } - context.ui.addItem( - { - type: MessageType.INFO, - text: `Uninstalling extension "${name}"...`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Uninstalling extension "${name}"...`, + }); try { await extensionLoader.uninstallExtension(name, false); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Extension "${name}" uninstalled successfully.`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${name}" uninstalled successfully.`, + }); } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: `Failed to uninstall extension "${name}": ${getErrorMessage( - error, - )}`, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to uninstall extension "${name}": ${getErrorMessage( + error, + )}`, + }); } } @@ -645,6 +667,14 @@ const installCommand: SlashCommand = { action: installAction, }; +const linkCommand: SlashCommand = { + name: 'link', + description: 'Link an extension from a local path', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: linkAction, +}; + const uninstallCommand: SlashCommand = { name: 'uninstall', description: 'Uninstall an extension', @@ -675,7 +705,13 @@ export function extensionsCommand( enableExtensionReloading?: boolean, ): SlashCommand { const conditionalCommands = enableExtensionReloading - ? [disableCommand, enableCommand, installCommand, uninstallCommand] + ? [ + disableCommand, + enableCommand, + installCommand, + uninstallCommand, + linkCommand, + ] : []; return { name: 'extensions', diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index 9eff142ba0..58b02251f9 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -40,7 +40,6 @@ describe('helpCommand', () => { type: MessageType.HELP, timestamp: expect.any(Date), }), - expect.any(Number), ); }); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index f7d469a7e7..cacebafe01 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -20,6 +20,6 @@ export const helpCommand: SlashCommand = { timestamp: new Date(), }; - context.ui.addItem(helpItem, Date.now()); + context.ui.addItem(helpItem); }, }; diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 990228809c..ed7f7bb747 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -11,6 +11,7 @@ import { MessageType } from '../types.js'; import type { HookRegistryEntry } from '@google/gemini-cli-core'; import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; +import { SettingScope } from '../../config/settings.js'; describe('hooksCommand', () => { let mockContext: CommandContext; @@ -21,14 +22,21 @@ describe('hooksCommand', () => { }; let mockConfig: { getHookSystem: ReturnType; + getEnableHooks: ReturnType; + updateDisabledHooks: ReturnType; }; let mockSettings: { merged: { - hooks?: { + hooksConfig?: { disabled?: string[]; }; }; setValue: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspace: { path: string; settings: any }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: { path: string; settings: any }; + forScope: ReturnType; }; beforeEach(() => { @@ -46,17 +54,38 @@ describe('hooksCommand', () => { // Create mock config mockConfig = { getHookSystem: vi.fn().mockReturnValue(mockHookSystem), + getEnableHooks: vi.fn().mockReturnValue(true), + updateDisabledHooks: vi.fn(), }; // Create mock settings + const mockUser = { + path: '/mock/user.json', + settings: { hooksConfig: { disabled: [] } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const mockWorkspace = { + path: '/mock/workspace.json', + settings: { hooksConfig: { disabled: [] } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + mockSettings = { merged: { - hooks: { + hooksConfig: { disabled: [], }, }, setValue: vi.fn(), - }; + workspace: mockWorkspace, + user: mockUser, + forScope: vi.fn((scope) => { + if (scope === SettingScope.User) return mockUser; + if (scope === SettingScope.Workspace) return mockWorkspace; + return mockUser; + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; // Create mock context with config and settings mockContext = createMockCommandContext({ @@ -79,12 +108,14 @@ describe('hooksCommand', () => { it('should have all expected subcommands', () => { expect(hooksCommand.subCommands).toBeDefined(); - expect(hooksCommand.subCommands).toHaveLength(3); + expect(hooksCommand.subCommands).toHaveLength(5); const subCommandNames = hooksCommand.subCommands!.map((cmd) => cmd.name); expect(subCommandNames).toContain('panel'); expect(subCommandNames).toContain('enable'); expect(subCommandNames).toContain('disable'); + expect(subCommandNames).toContain('enable-all'); + expect(subCommandNames).toContain('disable-all'); }); it('should delegate to panel action when invoked without subcommand', async () => { @@ -102,7 +133,6 @@ describe('hooksCommand', () => { expect.objectContaining({ type: MessageType.HOOKS_LIST, }), - expect.any(Number), ); }); }); @@ -131,7 +161,7 @@ describe('hooksCommand', () => { }); }); - it('should return info message when hook system is not enabled', async () => { + it('should display panel even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -141,18 +171,21 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - const result = await panelCmd.action(mockContext, ''); + await panelCmd.action(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'Hook system is not enabled. Enable it in settings with tools.enableHooks', - }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + hooks: [], + }), + ); }); - it('should return info message when no hooks are configured', async () => { + it('should display panel when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); + (mockContext.services.settings.merged as Record)[ + 'hooksConfig' + ] = { enabled: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', @@ -161,14 +194,14 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - const result = await panelCmd.action(mockContext, ''); + await panelCmd.action(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'No hooks configured. Add hooks to your settings to get started.', - }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HOOKS_LIST, + hooks: [], + }), + ); }); it('should display hooks list when hooks are configured', async () => { @@ -178,6 +211,9 @@ describe('hooksCommand', () => { ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + (mockContext.services.settings.merged as Record)[ + 'hooksConfig' + ] = { enabled: true }; const panelCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'panel', @@ -193,7 +229,6 @@ describe('hooksCommand', () => { type: MessageType.HOOKS_LIST, hooks: mockHooks, }), - expect.any(Number), ); }); }); @@ -259,10 +294,12 @@ describe('hooksCommand', () => { }); it('should enable a hook and update settings', async () => { - // Update the context's settings with disabled hooks - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook', 'other-hook'], - }; + // Update the user settings with disabled hooks + mockSettings.user.settings.hooksConfig.disabled = [ + 'test-hook', + 'other-hook', + ]; + mockSettings.workspace.settings.hooksConfig.disabled = []; const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', @@ -274,8 +311,8 @@ describe('hooksCommand', () => { const result = await enableCmd.action(mockContext, 'test-hook'); expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( - expect.any(String), - 'hooks.disabled', + SettingScope.User, + 'hooksConfig.disabled', ['other-hook'], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( @@ -285,28 +322,8 @@ describe('hooksCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Hook "test-hook" enabled successfully.', - }); - }); - - it('should handle error when enabling hook fails', async () => { - mockSettings.setValue.mockImplementationOnce(() => { - throw new Error('Failed to save settings'); - }); - - const enableCmd = hooksCommand.subCommands!.find( - (cmd) => cmd.name === 'enable', - ); - if (!enableCmd?.action) { - throw new Error('enable command must have an action'); - } - - const result = await enableCmd.action(mockContext, 'test-hook'); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Failed to enable hook: Failed to save settings', + content: + 'Hook "test-hook" enabled by removing it from the disabled list in user (/mock/user.json) and workspace (/mock/workspace.json) settings.', }); }); @@ -318,7 +335,7 @@ describe('hooksCommand', () => { const hookEntry = createMockHook( './hooks/test.sh', HookEventName.BeforeTool, - true, + false, // Must be disabled for enable completion ); hookEntry.config.name = 'friendly-name'; @@ -390,9 +407,9 @@ describe('hooksCommand', () => { }); it('should disable a hook and update settings', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; + // Ensure not disabled anywhere + mockSettings.workspace.settings.hooksConfig.disabled = []; + mockSettings.user.settings.hooksConfig.disabled = []; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -403,9 +420,10 @@ describe('hooksCommand', () => { const result = await disableCmd.action(mockContext, 'test-hook'); + // Should default to workspace if present expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( - expect.any(String), - 'hooks.disabled', + SettingScope.Workspace, + 'hooksConfig.disabled', ['test-hook'], ); expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( @@ -415,15 +433,14 @@ describe('hooksCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Hook "test-hook" disabled successfully.', + content: + 'Hook "test-hook" disabled by adding it to the disabled list in workspace (/mock/workspace.json) settings.', }); }); it('should return info when hook is already disabled', async () => { - // Update the context's settings with the hook already disabled - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook'], - }; + // Update the context's settings with the hook already disabled in Workspace + mockSettings.workspace.settings.hooksConfig.disabled = ['test-hook']; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -435,7 +452,6 @@ describe('hooksCommand', () => { const result = await disableCmd.action(mockContext, 'test-hook'); expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); - expect(mockHookSystem.setHookEnabled).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -443,28 +459,22 @@ describe('hooksCommand', () => { }); }); - it('should handle error when disabling hook fails', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; - mockSettings.setValue.mockImplementationOnce(() => { - throw new Error('Failed to save settings'); - }); - + it('should complete hook names using friendly names', () => { const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', + )!; + + const hookEntry = createMockHook( + './hooks/test.sh', + HookEventName.BeforeTool, + true, // Must be enabled for disable completion ); - if (!disableCmd?.action) { - throw new Error('disable command must have an action'); - } + hookEntry.config.name = 'friendly-name'; - const result = await disableCmd.action(mockContext, 'test-hook'); + mockHookSystem.getAllHooks.mockReturnValue([hookEntry]); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Failed to disable hook: Failed to save settings', - }); + const completions = disableCmd.completion!(mockContext, 'frie'); + expect(completions).toContain('friendly-name'); }); }); @@ -501,50 +511,52 @@ describe('hooksCommand', () => { expect(result).toEqual([]); }); - it('should return matching hook names', () => { + it('should return matching hook names based on status', () => { const mockHooks: HookRegistryEntry[] = [ - createMockHook('test-hook-1', HookEventName.BeforeTool, true), - createMockHook('test-hook-2', HookEventName.AfterTool, true), - createMockHook('other-hook', HookEventName.AfterAgent, false), + createMockHook('test-hook-enabled', HookEventName.BeforeTool, true), + createMockHook('test-hook-disabled', HookEventName.AfterTool, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', - ); - if (!enableCmd?.completion) { - throw new Error('enable command must have completion'); - } + )!; + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + )!; - const result = enableCmd.completion(mockContext, 'test'); - expect(result).toEqual(['test-hook-1', 'test-hook-2']); + const enableResult = enableCmd.completion!(mockContext, 'test'); + expect(enableResult).toEqual(['test-hook-disabled']); + + const disableResult = disableCmd.completion!(mockContext, 'test'); + expect(disableResult).toEqual(['test-hook-enabled']); }); - it('should return all hook names when partial is empty', () => { + it('should return all relevant hook names when partial is empty', () => { const mockHooks: HookRegistryEntry[] = [ - createMockHook('hook-1', HookEventName.BeforeTool, true), - createMockHook('hook-2', HookEventName.AfterTool, true), + createMockHook('hook-enabled', HookEventName.BeforeTool, true), + createMockHook('hook-disabled', HookEventName.AfterTool, false), ]; mockHookSystem.getAllHooks.mockReturnValue(mockHooks); const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', - ); - if (!enableCmd?.completion) { - throw new Error('enable command must have completion'); - } + )!; + const disableCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable', + )!; - const result = enableCmd.completion(mockContext, ''); - expect(result).toEqual(['hook-1', 'hook-2']); + expect(enableCmd.completion!(mockContext, '')).toEqual(['hook-disabled']); + expect(disableCmd.completion!(mockContext, '')).toEqual(['hook-enabled']); }); it('should handle hooks without command name gracefully', () => { const mockHooks: HookRegistryEntry[] = [ - createMockHook('test-hook', HookEventName.BeforeTool, true), + createMockHook('test-hook', HookEventName.BeforeTool, false), { - ...createMockHook('', HookEventName.AfterTool, true), + ...createMockHook('', HookEventName.AfterTool, false), config: { command: '', type: HookType.Command, timeout: 30 }, }, ]; @@ -562,6 +574,254 @@ describe('hooksCommand', () => { expect(result).toEqual(['test-hook']); }); }); + + describe('enable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should enable all disabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + createMockHook('hook-3', HookEventName.BeforeAgent, true), // already enabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), // enableAll uses legacy logic so it might return 'Workspace' or 'User' depending on ternary + 'hooksConfig.disabled', + [], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + true, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already enabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const enableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'enable-all', + ); + if (!enableAllCmd?.action) { + throw new Error('enable-all command must have an action'); + } + + const result = await enableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }); + }); + }); + + describe('disable-all subcommand', () => { + it('should return error when config is not loaded', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(contextWithoutConfig, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + it('should return error when hook system is not enabled', async () => { + mockConfig.getHookSystem.mockReturnValue(null); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }); + }); + + it('should disable all enabled hooks', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, true), + createMockHook('hook-2', HookEventName.AfterTool, true), + createMockHook('hook-3', HookEventName.BeforeAgent, false), // already disabled + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.any(String), + 'hooksConfig.disabled', + ['hook-1', 'hook-2', 'hook-3'], + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-1', + false, + ); + expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith( + 'hook-2', + false, + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled 2 hook(s) successfully.', + }); + }); + + it('should return info when no hooks are configured', async () => { + mockHookSystem.getAllHooks.mockReturnValue([]); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }); + }); + + it('should return info when all hooks are already disabled', async () => { + const mockHooks = [ + createMockHook('hook-1', HookEventName.BeforeTool, false), + createMockHook('hook-2', HookEventName.AfterTool, false), + ]; + mockHookSystem.getAllHooks.mockReturnValue(mockHooks); + + const disableAllCmd = hooksCommand.subCommands!.find( + (cmd) => cmd.name === 'disable-all', + ); + if (!disableAllCmd?.action) { + throw new Error('disable-all command must have an action'); + } + + const result = await disableAllCmd.action(mockContext, ''); + + expect(mockContext.services.settings.setValue).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }); + }); + }); }); /** diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 6bbfbb83e7..92fa72b235 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -12,7 +12,9 @@ import type { MessageActionReturn, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; -import { SettingScope } from '../../config/settings.js'; +import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; +import { enableHook, disableHook } from '../../utils/hookSettings.js'; +import { renderHookActionFeedback } from '../../utils/hookUtils.js'; /** * Display a formatted list of hooks with their status @@ -30,31 +32,14 @@ async function panelAction( } const hookSystem = config.getHookSystem(); - if (!hookSystem) { - return { - type: 'message', - messageType: 'info', - content: - 'Hook system is not enabled. Enable it in settings with tools.enableHooks', - }; - } - - const allHooks = hookSystem.getAllHooks(); - if (allHooks.length === 0) { - return { - type: 'message', - messageType: 'info', - content: - 'No hooks configured. Add hooks to your settings to get started.', - }; - } + const allHooks = hookSystem?.getAllHooks() || []; const hooksListItem: HistoryItemHooksList = { type: MessageType.HOOKS_LIST, hooks: allHooks, }; - context.ui.addItem(hooksListItem, Date.now()); + context.ui.addItem(hooksListItem); } /** @@ -91,34 +76,23 @@ async function enableAction( }; } - // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); + const result = enableHook(settings, hookName); - // Remove from disabled list if present - const newDisabledHooks = disabledHooks.filter( - (name: string) => name !== hookName, + if (result.status === 'success') { + hookSystem.setHookEnabled(hookName, true); + } + + const feedback = renderHookActionFeedback( + result, + (label, path) => `${label} (${path})`, ); - // Update settings (setValue automatically saves) - try { - settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); - - // Enable in hook system - hookSystem.setHookEnabled(hookName, true); - - return { - type: 'message', - messageType: 'info', - content: `Hook "${hookName}" enabled successfully.`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to enable hook: ${getErrorMessage(error)}`, - }; - } + return { + type: 'message', + messageType: result.status === 'error' ? 'error' : 'info', + content: feedback, + }; } /** @@ -155,46 +129,31 @@ async function disableAction( }; } - // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); + const scope = settings.workspace ? SettingScope.Workspace : SettingScope.User; - // Add to disabled list if not already present - if (!disabledHooks.includes(hookName)) { - const newDisabledHooks = [...disabledHooks, hookName]; + const result = disableHook(settings, hookName, scope); - // Update settings (setValue automatically saves) - try { - settings.setValue(SettingScope.User, 'hooks.disabled', newDisabledHooks); - - // Disable in hook system - hookSystem.setHookEnabled(hookName, false); - - return { - type: 'message', - messageType: 'info', - content: `Hook "${hookName}" disabled successfully.`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to disable hook: ${getErrorMessage(error)}`, - }; - } - } else { - return { - type: 'message', - messageType: 'info', - content: `Hook "${hookName}" is already disabled.`, - }; + if (result.status === 'success') { + hookSystem.setHookEnabled(hookName, false); } + + const feedback = renderHookActionFeedback( + result, + (label, path) => `${label} (${path})`, + ); + + return { + type: 'message', + messageType: result.status === 'error' ? 'error' : 'info', + content: feedback, + }; } /** - * Completion function for hook names + * Completion function for enabled hook names (to be disabled) */ -function completeHookNames( +function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { @@ -205,8 +164,30 @@ function completeHookNames( if (!hookSystem) return []; const allHooks = hookSystem.getAllHooks(); - const hookNames = allHooks.map((hook) => getHookDisplayName(hook)); - return hookNames.filter((name) => name.startsWith(partialArg)); + return allHooks + .filter((hook) => hook.enabled) + .map((hook) => getHookDisplayName(hook)) + .filter((name) => name.startsWith(partialArg)); +} + +/** + * Completion function for disabled hook names (to be enabled) + */ +function completeDisabledHookNames( + context: CommandContext, + partialArg: string, +): string[] { + const { config } = context.services; + if (!config) return []; + + const hookSystem = config.getHookSystem(); + if (!hookSystem) return []; + + const allHooks = hookSystem.getAllHooks(); + return allHooks + .filter((hook) => !hook.enabled) + .map((hook) => getHookDisplayName(hook)) + .filter((name) => name.startsWith(partialArg)); } /** @@ -216,6 +197,147 @@ function getHookDisplayName(hook: HookRegistryEntry): string { return hook.config.name || hook.config.command || 'unknown-hook'; } +/** + * Enable all hooks by clearing the disabled list + */ +async function enableAllAction( + context: CommandContext, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const disabledHooks = allHooks.filter((hook) => !hook.enabled); + if (disabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already enabled.', + }; + } + + try { + const scopes = [SettingScope.Workspace, SettingScope.User]; + for (const scope of scopes) { + if (isLoadableSettingScope(scope)) { + settings.setValue(scope, 'hooksConfig.disabled', []); + } + } + + for (const hook of disabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, true); + } + + return { + type: 'message', + messageType: 'info', + content: `Enabled ${disabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to enable hooks: ${getErrorMessage(error)}`, + }; + } +} + +/** + * Disable all hooks by adding all hooks to the disabled list + */ +async function disableAllAction( + context: CommandContext, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const hookSystem = config.getHookSystem(); + if (!hookSystem) { + return { + type: 'message', + messageType: 'error', + content: 'Hook system is not enabled.', + }; + } + + const settings = context.services.settings; + const allHooks = hookSystem.getAllHooks(); + + if (allHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No hooks configured.', + }; + } + + const enabledHooks = allHooks.filter((hook) => hook.enabled); + if (enabledHooks.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'All hooks are already disabled.', + }; + } + + try { + const allHookNames = allHooks.map((hook) => getHookDisplayName(hook)); + const scope = settings.workspace + ? SettingScope.Workspace + : SettingScope.User; + settings.setValue(scope, 'hooksConfig.disabled', allHookNames); + + for (const hook of enabledHooks) { + const hookName = getHookDisplayName(hook); + hookSystem.setHookEnabled(hookName, false); + } + + return { + type: 'message', + messageType: 'info', + content: `Disabled ${enabledHooks.length} hook(s) successfully.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to disable hooks: ${getErrorMessage(error)}`, + }; + } +} + const panelCommand: SlashCommand = { name: 'panel', altNames: ['list', 'show'], @@ -230,7 +352,7 @@ const enableCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: enableAction, - completion: completeHookNames, + completion: completeDisabledHookNames, }; const disableCommand: SlashCommand = { @@ -239,13 +361,37 @@ const disableCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: disableAction, - completion: completeHookNames, + completion: completeEnabledHookNames, +}; + +const enableAllCommand: SlashCommand = { + name: 'enable-all', + altNames: ['enableall'], + description: 'Enable all disabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: enableAllAction, +}; + +const disableAllCommand: SlashCommand = { + name: 'disable-all', + altNames: ['disableall'], + description: 'Disable all enabled hooks', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: disableAllAction, }; export const hooksCommand: SlashCommand = { name: 'hooks', description: 'Manage hooks', kind: CommandKind.BUILT_IN, - subCommands: [panelCommand, enableCommand, disableCommand], + subCommands: [ + panelCommand, + enableCommand, + disableCommand, + enableAllCommand, + disableAllCommand, + ], action: async (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 54bb4d164e..62991c7610 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -13,10 +13,14 @@ import type { CommandContext } from './types.js'; import type { SubmitPromptActionReturn } from '@google/gemini-cli-core'; // Mock the 'fs' module -vi.mock('fs', () => ({ - existsSync: vi.fn(), - writeFileSync: vi.fn(), -})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); describe('initCommand', () => { let mockContext: CommandContext; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 85ee967143..83b5dbb179 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -231,7 +231,6 @@ describe('mcpCommand', () => { }), ]), }), - expect.any(Number), ); }); @@ -246,7 +245,6 @@ describe('mcpCommand', () => { type: MessageType.MCP_STATUS, showDescriptions: true, }), - expect.any(Number), ); }); @@ -261,7 +259,6 @@ describe('mcpCommand', () => { type: MessageType.MCP_STATUS, showDescriptions: false, }), - expect.any(Number), ); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 8df7a9c397..62154eb6fd 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -20,9 +20,17 @@ import { getErrorMessage, MCPOAuthTokenStorage, mcpServerRequiresOAuth, + CoreEvent, + coreEvents, } from '@google/gemini-cli-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; + import { MessageType, type HistoryItemMcpStatus } from '../types.js'; +import { + McpServerEnablementManager, + normalizeServerId, + canLoadServer, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; const authCommand: SlashCommand = { name: 'auth', @@ -91,19 +99,15 @@ const authCommand: SlashCommand = { // The authentication process will discover OAuth requirements automatically const displayListener = (message: string) => { - context.ui.addItem({ type: 'info', text: message }, Date.now()); + context.ui.addItem({ type: 'info', text: message }); }; - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - + coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener); try { - context.ui.addItem( - { - type: 'info', - text: `Starting OAuth authentication for MCP server '${serverName}'...`, - }, - Date.now(), - ); + context.ui.addItem({ + type: 'info', + text: `Starting OAuth authentication for MCP server '${serverName}'...`, + }); // Import dynamically to avoid circular dependencies const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); @@ -115,31 +119,20 @@ const authCommand: SlashCommand = { const mcpServerUrl = server.httpUrl || server.url; const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - serverName, - oauthConfig, - mcpServerUrl, - appEvents, - ); + await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl); - context.ui.addItem( - { - type: 'info', - text: `โœ… Successfully authenticated with MCP server '${serverName}'!`, - }, - Date.now(), - ); + context.ui.addItem({ + type: 'info', + text: `โœ… Successfully authenticated with MCP server '${serverName}'!`, + }); // Trigger tool re-discovery to pick up authenticated server const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { - context.ui.addItem( - { - type: 'info', - text: `Restarting MCP server '${serverName}'...`, - }, - Date.now(), - ); + context.ui.addItem({ + type: 'info', + text: `Restarting MCP server '${serverName}'...`, + }); await mcpClientManager.restartServer(serverName); } // Update the client with the new tools @@ -163,7 +156,7 @@ const authCommand: SlashCommand = { content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, }; } finally { - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); + coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener); } }, completion: async (context: CommandContext, partialArg: string) => { @@ -250,6 +243,14 @@ const listAction = async ( } } + // Get enablement state for all servers + const enablementManager = McpServerEnablementManager.getInstance(); + const enablementState: HistoryItemMcpStatus['enablementState'] = {}; + for (const serverName of serverNames) { + enablementState[serverName] = + await enablementManager.getDisplayState(serverName); + } + const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, servers: mcpServers, @@ -272,6 +273,7 @@ const listAction = async ( description: resource.description, })), authStatus, + enablementState, blockedServers: blockedMcpServers, discoveryInProgress, connectingServers, @@ -279,7 +281,7 @@ const listAction = async ( showSchema, }; - context.ui.addItem(mcpStatusItem, Date.now()); + context.ui.addItem(mcpStatusItem); }; const listCommand: SlashCommand = { @@ -335,13 +337,10 @@ const refreshCommand: SlashCommand = { }; } - context.ui.addItem( - { - type: 'info', - text: 'Restarting MCP servers...', - }, - Date.now(), - ); + context.ui.addItem({ + type: 'info', + text: 'Restarting MCP servers...', + }); await mcpClientManager.restart(); @@ -358,6 +357,143 @@ const refreshCommand: SlashCommand = { }, }; +async function handleEnableDisable( + context: CommandContext, + args: string, + enable: boolean, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const parts = args.trim().split(/\s+/); + const isSession = parts.includes('--session'); + const serverName = parts.filter((p) => p !== '--session')[0]; + const action = enable ? 'enable' : 'disable'; + + if (!serverName) { + return { + type: 'message', + messageType: 'error', + content: `Server name required. Usage: /mcp ${action} [--session]`, + }; + } + + const name = normalizeServerId(serverName); + + // Validate server exists + const servers = config.getMcpClientManager()?.getMcpServers() || {}; + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + return { + type: 'message', + messageType: 'error', + content: `Server '${serverName}' not found. Use /mcp list to see available servers.`, + }; + } + + const manager = McpServerEnablementManager.getInstance(); + + if (enable) { + const settings = loadSettings(); + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + return { + type: 'message', + messageType: 'error', + content: result.reason ?? 'Blocked by settings.', + }; + } + if (isSession) { + manager.clearSessionDisable(name); + } else { + await manager.enable(name); + } + if (result.blockType === 'admin') { + context.ui.addItem( + { + type: 'warning', + text: 'MCP disabled by admin. Will load when enabled.', + }, + Date.now(), + ); + } + } else { + if (isSession) { + manager.disableForSession(name); + } else { + await manager.disable(name); + } + } + + const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`; + + const mcpClientManager = config.getMcpClientManager(); + if (mcpClientManager) { + context.ui.addItem( + { type: 'info', text: 'Restarting MCP servers...' }, + Date.now(), + ); + await mcpClientManager.restart(); + } + if (config.getGeminiClient()?.isInitialized()) + await config.getGeminiClient().setTools(); + context.ui.reloadCommands(); + + return { type: 'message', messageType: 'info', content: msg }; +} + +async function getEnablementCompletion( + context: CommandContext, + partialArg: string, + showEnabled: boolean, +): Promise { + const { config } = context.services; + if (!config) return []; + const servers = Object.keys( + config.getMcpClientManager()?.getMcpServers() || {}, + ); + const manager = McpServerEnablementManager.getInstance(); + const results: string[] = []; + for (const n of servers) { + const state = await manager.getDisplayState(n); + if (state.enabled === showEnabled && n.startsWith(partialArg)) { + results.push(n); + } + } + return results; +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, true), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false), +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, false), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true), +}; + export const mcpCommand: SlashCommand = { name: 'mcp', description: 'Manage configured Model Context Protocol (MCP) servers', @@ -369,6 +505,8 @@ export const mcpCommand: SlashCommand = { schemaCommand, authCommand, refreshCommand, + enableCommand, + disableCommand, ], action: async (context: CommandContext) => listAction(context), }; diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 178a133e93..642e98569b 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -12,12 +12,14 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { - getErrorMessage, + refreshMemory, refreshServerHierarchicalMemory, SimpleExtensionLoader, type FileDiscoveryService, + showMemory, + addMemory, + listMemoryFiles, } from '@google/gemini-cli-core'; -import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -28,10 +30,31 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { if (error instanceof Error) return error.message; return String(error); }), + refreshMemory: vi.fn(async (config) => { + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + const memoryContent = config.getUserMemory() || ''; + const fileCount = config.getGeminiMdFileCount() || 0; + return { + type: 'message', + messageType: 'info', + content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + }; + } + return { + type: 'message', + messageType: 'info', + content: 'Memory refreshed successfully.', + }; + }), + showMemory: vi.fn(), + addMemory: vi.fn(), + listMemoryFiles: vi.fn(), refreshServerHierarchicalMemory: vi.fn(), }; }); +const mockRefreshMemory = refreshMemory as Mock; const mockRefreshServerHierarchicalMemory = refreshServerHierarchicalMemory as Mock; @@ -61,6 +84,22 @@ describe('memoryCommand', () => { mockGetUserMemory = vi.fn(); mockGetGeminiMdFileCount = vi.fn(); + vi.mocked(showMemory).mockImplementation((config) => { + const memoryContent = config.getUserMemory() || ''; + const fileCount = config.getGeminiMdFileCount() || 0; + let content; + if (memoryContent.length > 0) { + content = `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`; + } else { + content = 'Memory is currently empty.'; + } + return { + type: 'message', + messageType: 'info', + content, + }; + }); + mockContext = createMockCommandContext({ services: { config: { @@ -114,6 +153,20 @@ describe('memoryCommand', () => { beforeEach(() => { addCommand = getSubCommand('add'); + vi.mocked(addMemory).mockImplementation((args) => { + if (!args || args.trim() === '') { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /memory add ', + }; + } + return { + type: 'tool', + toolName: 'save_memory', + toolArgs: { fact: args.trim() }, + }; + }); mockContext = createMockCommandContext(); }); @@ -208,7 +261,7 @@ describe('memoryCommand', () => { } as unknown as LoadedSettings, }, }); - mockRefreshServerHierarchicalMemory.mockClear(); + mockRefreshMemory.mockClear(); }); it('should use ContextManager.refresh when JIT is enabled', async () => { @@ -239,12 +292,13 @@ describe('memoryCommand', () => { it('should display success message when memory is refreshed with content (Legacy)', async () => { if (!refreshCommand.action) throw new Error('Command has no action'); - const refreshResult: LoadServerHierarchicalMemoryResponse = { - memoryContent: 'new memory content', - fileCount: 2, - filePaths: ['/path/one/GEMINI.md', '/path/two/GEMINI.md'], + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: + 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', }; - mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult); + mockRefreshMemory.mockResolvedValue(successMessage); await refreshCommand.action(mockContext, ''); @@ -256,7 +310,7 @@ describe('memoryCommand', () => { expect.any(Number), ); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -270,12 +324,16 @@ describe('memoryCommand', () => { it('should display success message when memory is refreshed with no content', async () => { if (!refreshCommand.action) throw new Error('Command has no action'); - const refreshResult = { memoryContent: '', fileCount: 0, filePaths: [] }; - mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult); + const successMessage = { + type: 'message', + messageType: MessageType.INFO, + content: 'Memory refreshed successfully. No memory content found.', + }; + mockRefreshMemory.mockResolvedValue(successMessage); await refreshCommand.action(mockContext, ''); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -290,11 +348,11 @@ describe('memoryCommand', () => { if (!refreshCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); - mockRefreshServerHierarchicalMemory.mockRejectedValue(error); + mockRefreshMemory.mockRejectedValue(error); await refreshCommand.action(mockContext, ''); - expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce(); + expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled(); expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled(); @@ -306,8 +364,6 @@ describe('memoryCommand', () => { }, expect.any(Number), ); - - expect(getErrorMessage).toHaveBeenCalledWith(error); }); it('should not throw if config service is unavailable', async () => { @@ -329,7 +385,7 @@ describe('memoryCommand', () => { expect.any(Number), ); - expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); + expect(mockRefreshMemory).not.toHaveBeenCalled(); }); }); @@ -340,6 +396,21 @@ describe('memoryCommand', () => { beforeEach(() => { listCommand = getSubCommand('list'); mockGetGeminiMdfilePaths = vi.fn(); + vi.mocked(listMemoryFiles).mockImplementation((config) => { + const filePaths = config.getGeminiMdFilePaths() || []; + const fileCount = filePaths.length; + let content; + if (fileCount > 0) { + content = `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`; + } else { + content = 'No GEMINI.md files in use.'; + } + return { + type: 'message', + messageType: 'info', + content, + }; + }); mockContext = createMockCommandContext({ services: { config: { diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index d0df88f747..8f4bdaffbe 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -5,8 +5,10 @@ */ import { - getErrorMessage, - refreshServerHierarchicalMemory, + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; @@ -24,18 +26,14 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const memoryContent = context.services.config?.getUserMemory() || ''; - const fileCount = context.services.config?.getGeminiMdFileCount() || 0; - - const messageContent = - memoryContent.length > 0 - ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` - : 'Memory is currently empty.'; + const config = context.services.config; + if (!config) return; + const result = showMemory(config); context.ui.addItem( { type: MessageType.INFO, - text: messageContent, + text: result.content, }, Date.now(), ); @@ -47,12 +45,10 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, action: (context, args): SlashCommandActionReturn | void => { - if (!args || args.trim() === '') { - return { - type: 'message', - messageType: 'error', - content: 'Usage: /memory add ', - }; + const result = addMemory(args); + + if (result.type === 'message') { + return result; } context.ui.addItem( @@ -63,11 +59,7 @@ export const memoryCommand: SlashCommand = { Date.now(), ); - return { - type: 'tool', - toolName: 'save_memory', - toolArgs: { fact: args.trim() }, - }; + return result; }, }, { @@ -87,40 +79,21 @@ export const memoryCommand: SlashCommand = { try { const config = context.services.config; if (config) { - let memoryContent = ''; - let fileCount = 0; - - if (config.isJitContextEnabled()) { - await config.getContextManager()?.refresh(); - memoryContent = config.getUserMemory(); - fileCount = config.getGeminiMdFileCount(); - } else { - const result = await refreshServerHierarchicalMemory(config); - memoryContent = result.memoryContent; - fileCount = result.fileCount; - } - - await config.updateSystemInstructionIfInitialized(); - - const successMessage = - memoryContent.length > 0 - ? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).` - : 'Memory refreshed successfully. No memory content found.'; + const result = await refreshMemory(config); context.ui.addItem( { type: MessageType.INFO, - text: successMessage, + text: result.content, }, Date.now(), ); } } catch (error) { - const errorMessage = getErrorMessage(error); context.ui.addItem( { type: MessageType.ERROR, - text: `Error refreshing memory: ${errorMessage}`, + text: `Error refreshing memory: ${(error as Error).message}`, }, Date.now(), ); @@ -133,18 +106,14 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const filePaths = context.services.config?.getGeminiMdFilePaths() || []; - const fileCount = filePaths.length; - - const messageContent = - fileCount > 0 - ? `There are ${fileCount} GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}` - : 'No GEMINI.md files in use.'; + const config = context.services.config; + if (!config) return; + const result = listMemoryFiles(config); context.ui.addItem( { type: MessageType.INFO, - text: messageContent, + text: result.content, }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx new file mode 100644 index 0000000000..f3766d6355 --- /dev/null +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type SlashCommand, + type OpenCustomDialogActionReturn, +} from './types.js'; +import { TriageDuplicates } from '../components/triage/TriageDuplicates.js'; + +export const oncallCommand: SlashCommand = { + name: 'oncall', + description: 'Oncall related commands', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'dedup', + description: 'Triage issues labeled as status/possible-duplicate', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args): Promise => { + const { config } = context.services; + if (!config) { + throw new Error('Config not available'); + } + + let limit = 50; + if (args && args.trim().length > 0) { + const argArray = args.trim().split(/\s+/); + const parsedLimit = parseInt(argArray[0], 10); + if (!isNaN(parsedLimit) && parsedLimit > 0) { + limit = parsedLimit; + } + } + + return { + type: 'custom_dialog', + component: ( + context.ui.removeComponent()} + /> + ), + }; + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 1e72bce0ae..4f224201c9 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -62,7 +62,7 @@ describe('policiesCommand', () => { ); }); - it('should list active policies in correct format', async () => { + it('should list policies grouped by mode', async () => { const mockRules = [ { decision: PolicyDecision.DENY, @@ -72,6 +72,7 @@ describe('policiesCommand', () => { { decision: PolicyDecision.ALLOW, argsPattern: /safe/, + source: 'test.toml', }, { decision: PolicyDecision.ASK_USER, @@ -98,11 +99,20 @@ describe('policiesCommand', () => { const call = vi.mocked(mockContext.ui.addItem).mock.calls[0]; const content = (call[0] as { text: string }).text; + expect(content).toContain('### Normal Mode Policies'); expect(content).toContain( - '1. **DENY** tool: `dangerousTool` [Priority: 10]', + '### Auto Edit Mode Policies (combined with normal mode policies)', ); - expect(content).toContain('2. **ALLOW** all tools (args match: `safe`)'); - expect(content).toContain('3. **ASK_USER** all tools'); + expect(content).toContain( + '### Yolo Mode Policies (combined with normal mode policies)', + ); + expect(content).toContain( + '**DENY** tool: `dangerousTool` [Priority: 10]', + ); + expect(content).toContain( + '**ALLOW** all tools (args match: `safe`) [Source: test.toml]', + ); + expect(content).toContain('**ASK_USER** all tools'); }); }); }); diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index cc6136c3d5..ebfd57abaf 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -4,12 +4,47 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; +interface CategorizedRules { + normal: PolicyRule[]; + autoEdit: PolicyRule[]; + yolo: PolicyRule[]; +} + +const categorizeRulesByMode = ( + rules: readonly PolicyRule[], +): CategorizedRules => { + const result: CategorizedRules = { + normal: [], + autoEdit: [], + yolo: [], + }; + const ALL_MODES = Object.values(ApprovalMode); + rules.forEach((rule) => { + const modes = rule.modes?.length ? rule.modes : ALL_MODES; + const modeSet = new Set(modes); + if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule); + if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule); + if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule); + }); + return result; +}; + +const formatRule = (rule: PolicyRule, i: number) => + `${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` + + (rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') + + (rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') + + (rule.source ? ` [Source: ${rule.source}]` : ''); + +const formatSection = (title: string, rules: PolicyRule[]) => + `### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`; + const listPoliciesCommand: SlashCommand = { name: 'list', - description: 'List all active policies', + description: 'List all active policies grouped by mode', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { @@ -39,22 +74,25 @@ const listPoliciesCommand: SlashCommand = { return; } + const categorized = categorizeRulesByMode(rules); + const normalRulesSet = new Set(categorized.normal); + const uniqueAutoEdit = categorized.autoEdit.filter( + (rule) => !normalRulesSet.has(rule), + ); + const uniqueYolo = categorized.yolo.filter( + (rule) => !normalRulesSet.has(rule), + ); + let content = '**Active Policies**\n\n'; - rules.forEach((rule, index) => { - content += `${index + 1}. **${rule.decision.toUpperCase()}**`; - if (rule.toolName) { - content += ` tool: \`${rule.toolName}\``; - } else { - content += ` all tools`; - } - if (rule.argsPattern) { - content += ` (args match: \`${rule.argsPattern.source}\`)`; - } - if (rule.priority !== undefined) { - content += ` [Priority: ${rule.priority}]`; - } - content += '\n'; - }); + content += formatSection('Normal Mode Policies', categorized.normal); + content += formatSection( + 'Auto Edit Mode Policies (combined with normal mode policies)', + uniqueAutoEdit, + ); + content += formatSection( + 'Yolo Mode Policies (combined with normal mode policies)', + uniqueYolo, + ); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx new file mode 100644 index 0000000000..b0236845bc --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { rewindCommand } from './rewindCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { waitFor } from '../../test-utils/async.js'; +import { RewindOutcome } from '../components/RewindConfirmation.js'; +import { + type OpenCustomDialogActionReturn, + type CommandContext, +} from './types.js'; +import type { ReactElement } from 'react'; +import { coreEvents } from '@google/gemini-cli-core'; + +// Mock dependencies +const mockRewindTo = vi.fn(); +const mockRecordMessage = vi.fn(); +const mockSetHistory = vi.fn(); +const mockSendMessageStream = vi.fn(); +const mockGetChatRecordingService = vi.fn(); +const mockGetConversation = vi.fn(); +const mockRemoveComponent = vi.fn(); +const mockLoadHistory = vi.fn(); +const mockAddItem = vi.fn(); +const mockSetPendingItem = vi.fn(); +const mockResetContext = vi.fn(); +const mockSetInput = vi.fn(); +const mockRevertFileChanges = vi.fn(); +const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root'); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + ...actual.coreEvents, + emitFeedback: vi.fn(), + }, + }; +}); + +vi.mock('../components/RewindViewer.js', () => ({ + RewindViewer: () => null, +})); + +vi.mock('../hooks/useSessionBrowser.js', () => ({ + convertSessionToHistoryFormats: vi.fn().mockReturnValue({ + uiHistory: [ + { type: 'user', text: 'old user' }, + { type: 'gemini', text: 'old gemini' }, + ], + clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }], + }), +})); + +vi.mock('../utils/rewindFileOps.js', () => ({ + revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args), +})); + +interface RewindViewerProps { + onRewind: ( + messageId: string, + newText: string, + outcome: RewindOutcome, + ) => Promise; + conversation: unknown; + onExit: () => void; +} + +describe('rewindCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetConversation.mockReturnValue({ + messages: [{ id: 'msg-1', type: 'user', content: 'hello' }], + sessionId: 'test-session', + }); + + mockRewindTo.mockReturnValue({ + messages: [], // Mocked rewound messages + }); + + mockGetChatRecordingService.mockReturnValue({ + getConversation: mockGetConversation, + rewindTo: mockRewindTo, + recordMessage: mockRecordMessage, + }); + + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getChatRecordingService: mockGetChatRecordingService, + setHistory: mockSetHistory, + sendMessageStream: mockSendMessageStream, + }), + getSessionId: () => 'test-session-id', + getContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, + }, + }, + ui: { + removeComponent: mockRemoveComponent, + loadHistory: mockLoadHistory, + addItem: mockAddItem, + setPendingItem: mockSetPendingItem, + }, + }) as unknown as CommandContext; + }); + + it('should initialize successfully', async () => { + const result = await rewindCommand.action!(mockContext, ''); + expect(result).toHaveProperty('type', 'custom_dialog'); + }); + + it('should handle RewindOnly correctly', async () => { + // 1. Run the command to get the component + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + + // Access onRewind from props + const onRewind = component.props.onRewind; + expect(onRewind).toBeDefined(); + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + expect(mockSetHistory).toHaveBeenCalled(); + expect(mockResetContext).toHaveBeenCalled(); + expect(mockLoadHistory).toHaveBeenCalledWith( + [ + expect.objectContaining({ text: 'old user', id: 1 }), + expect.objectContaining({ text: 'old gemini', id: 2 }), + ], + 'New Prompt', + ); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + + // Verify setInput was NOT called directly (it's handled via loadHistory now) + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle RewindAndRevert correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert); + + await waitFor(() => { + expect(mockRevertFileChanges).toHaveBeenCalledWith( + mockGetConversation(), + 'msg-id-123', + ); + expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123'); + expect(mockLoadHistory).toHaveBeenCalledWith( + expect.any(Array), + 'New Prompt', + ); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle RevertOnly correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly); + + await waitFor(() => { + expect(mockRevertFileChanges).toHaveBeenCalledWith( + mockGetConversation(), + 'msg-id-123', + ); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'File changes reverted.', + ); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle Cancel correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel); + + await waitFor(() => { + expect(mockRevertFileChanges).not.toHaveBeenCalled(); + expect(mockRewindTo).not.toHaveBeenCalled(); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + expect(mockSetInput).not.toHaveBeenCalled(); + }); + + it('should handle onExit correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onExit = component.props.onExit; + + onExit(); + + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + + it('should handle rewind error correctly', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + mockRewindTo.mockImplementation(() => { + throw new Error('Rewind Failed'); + }); + + await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Rewind Failed', + ); + }); + }); + + it('should handle null conversation from rewindTo', async () => { + const result = (await rewindCommand.action!( + mockContext, + '', + )) as OpenCustomDialogActionReturn; + const component = result.component as ReactElement; + const onRewind = component.props.onRewind; + + mockRewindTo.mockReturnValue(null); + + await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly); + + await waitFor(() => { + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Could not fetch conversation file', + ); + expect(mockRemoveComponent).toHaveBeenCalled(); + }); + }); + + it('should fail if config is missing', () => { + const context = { services: {} } as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not found', + }); + }); + + it('should fail if client is not initialized', () => { + const context = createMockCommandContext({ + services: { + config: { getGeminiClient: () => undefined }, + }, + }) as unknown as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Client not initialized', + }); + }); + + it('should fail if recording service is unavailable', () => { + const context = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ getChatRecordingService: () => undefined }), + }, + }, + }) as unknown as CommandContext; + + const result = rewindCommand.action!(context, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Recording service unavailable', + }); + }); + + it('should return info if no conversation found', () => { + mockGetConversation.mockReturnValue(null); + + const result = rewindCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No conversation found.', + }); + }); + + it('should return info if no user interactions found', () => { + mockGetConversation.mockReturnValue({ + messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }], + sessionId: 'test-session', + }); + + const result = rewindCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Nothing to rewind to.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx new file mode 100644 index 0000000000..c9b0424842 --- /dev/null +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from './types.js'; +import { RewindViewer } from '../components/RewindViewer.js'; +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, +} from '@google/gemini-cli-core'; +import { coreEvents, debugLogger } from '@google/gemini-cli-core'; + +/** + * Helper function to handle the core logic of rewinding a conversation. + * This function encapsulates the steps needed to rewind the conversation, + * update the client and UI history, and clear the component. + * + * @param context The command context. + * @param client Gemini client + * @param recordingService The chat recording service. + * @param messageId The ID of the message to rewind to. + * @param newText The new text for the input field after rewinding. + */ +async function rewindConversation( + context: CommandContext, + client: GeminiClient, + recordingService: ChatRecordingService, + messageId: string, + newText: string, +) { + try { + const conversation = recordingService.rewindTo(messageId); + if (!conversation) { + const errorMsg = 'Could not fetch conversation file'; + debugLogger.error(errorMsg); + context.ui.removeComponent(); + coreEvents.emitFeedback('error', errorMsg); + return; + } + + // Convert to UI and Client formats + const { uiHistory, clientHistory } = convertSessionToHistoryFormats( + conversation.messages, + ); + + client.setHistory(clientHistory as Content[]); + + // Reset context manager as we are rewinding history + await context.services.config?.getContextManager()?.refresh(); + + // Update UI History + // We generate IDs based on index for the rewind history + const startId = 1; + const historyWithIds = uiHistory.map( + (item, idx) => + ({ + ...item, + id: startId + idx, + }) as HistoryItem, + ); + + // 1. Remove component FIRST to avoid flicker and clear the stage + context.ui.removeComponent(); + + // 2. Load the rewound history and set the input + context.ui.loadHistory(historyWithIds, newText); + } catch (error) { + // If an error occurs, we still want to remove the component if possible + context.ui.removeComponent(); + coreEvents.emitFeedback( + 'error', + error instanceof Error ? error.message : 'Unknown error during rewind', + ); + } +} + +export const rewindCommand: SlashCommand = { + name: 'rewind', + description: 'Jump back to a specific message and restart the conversation', + kind: CommandKind.BUILT_IN, + action: (context) => { + const config = context.services.config; + if (!config) + return { + type: 'message', + messageType: 'error', + content: 'Config not found', + }; + + const client = config.getGeminiClient(); + if (!client) + return { + type: 'message', + messageType: 'error', + content: 'Client not initialized', + }; + + const recordingService = client.getChatRecordingService(); + if (!recordingService) + return { + type: 'message', + messageType: 'error', + content: 'Recording service unavailable', + }; + + const conversation = recordingService.getConversation(); + if (!conversation) + return { + type: 'message', + messageType: 'info', + content: 'No conversation found.', + }; + + const hasUserInteractions = conversation.messages.some( + (msg) => msg.type === 'user', + ); + if (!hasUserInteractions) { + return { + type: 'message', + messageType: 'info', + content: 'Nothing to rewind to.', + }; + } + + return { + type: 'custom_dialog', + component: ( + { + context.ui.removeComponent(); + }} + onRewind={async (messageId, newText, outcome) => { + switch (outcome) { + case RewindOutcome.Cancel: + context.ui.removeComponent(); + return; + + case RewindOutcome.RevertOnly: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + context.ui.removeComponent(); + coreEvents.emitFeedback('info', 'File changes reverted.'); + return; + + case RewindOutcome.RewindAndRevert: + if (conversation) { + await revertFileChanges(conversation, messageId); + } + await rewindConversation( + context, + client, + recordingService, + messageId, + newText, + ); + return; + + case RewindOutcome.RewindOnly: + await rewindConversation( + context, + client, + recordingService, + messageId, + newText, + ); + return; + + default: + checkExhaustive(outcome); + } + }} + /> + ), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/shellsCommand.test.ts b/packages/cli/src/ui/commands/shellsCommand.test.ts new file mode 100644 index 0000000000..794d162d6e --- /dev/null +++ b/packages/cli/src/ui/commands/shellsCommand.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/commands/shellsCommand.ts b/packages/cli/src/ui/commands/shellsCommand.ts new file mode 100644 index 0000000000..80645bbf8e --- /dev/null +++ b/packages/cli/src/ui/commands/shellsCommand.ts @@ -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(); + }, +}; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 39339f8226..3a82639923 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -1,21 +1,36 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { skillsCommand } from './skillsCommand.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemSkillsList } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; -import type { Config } from '@google/gemini-cli-core'; -import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import type { Config, SkillDefinition } from '@google/gemini-cli-core'; +import { + SettingScope, + type LoadedSettings, + createTestMergedSettings, + type MergedSettings, +} from '../../config/settings.js'; + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isLoadableSettingScope: vi.fn((s) => s === 'User' || s === 'Workspace'), + }; +}); describe('skillsCommand', () => { let context: CommandContext; beforeEach(() => { + vi.useFakeTimers(); const skills = [ { name: 'skill1', @@ -35,15 +50,18 @@ describe('skillsCommand', () => { config: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), + getSkills: vi.fn().mockReturnValue(skills), + isAdminEnabled: vi.fn().mockReturnValue(true), getSkill: vi .fn() .mockImplementation( (name: string) => skills.find((s) => s.name === name) ?? null, ), }), + getContentGenerator: vi.fn(), } as unknown as Config, settings: { - merged: { skills: { disabled: [] } }, + merged: createTestMergedSettings({ skills: { disabled: [] } }), workspace: { path: '/workspace' }, setValue: vi.fn(), } as unknown as LoadedSettings, @@ -51,6 +69,11 @@ describe('skillsCommand', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('should add a SKILLS_LIST item to UI with descriptions by default', async () => { await skillsCommand.action!(context, ''); @@ -75,7 +98,6 @@ describe('skillsCommand', () => { ], showDescriptions: true, }), - expect.any(Number), ); }); @@ -104,7 +126,6 @@ describe('skillsCommand', () => { ], showDescriptions: true, }), - expect.any(Number), ); }); @@ -116,18 +137,88 @@ describe('skillsCommand', () => { expect.objectContaining({ showDescriptions: false, }), - expect.any(Number), ); }); + it('should filter built-in skills by default and show them with "all"', async () => { + const skillManager = context.services.config!.getSkillManager(); + const mockSkills = [ + { + name: 'regular', + description: 'desc1', + location: '/loc1', + body: 'body1', + }, + { + name: 'builtin', + description: 'desc2', + location: '/loc2', + body: 'body2', + isBuiltin: true, + }, + ]; + vi.mocked(skillManager.getAllSkills).mockReturnValue(mockSkills); + + const listCmd = skillsCommand.subCommands!.find((s) => s.name === 'list')!; + + // By default, only regular skills + await listCmd.action!(context, ''); + let lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(1); + expect(lastCall.skills[0].name).toBe('regular'); + + // With "all", show both + await listCmd.action!(context, 'all'); + lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(2); + expect(lastCall.skills.map((s) => s.name)).toContain('builtin'); + + // With "--all", show both + await listCmd.action!(context, '--all'); + lastCall = vi + .mocked(context.ui.addItem) + .mock.calls.at(-1)![0] as HistoryItemSkillsList; + expect(lastCall.skills).toHaveLength(2); + }); + describe('disable/enable', () => { beforeEach(() => { - context.services.settings.merged.skills = { disabled: [] }; + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { enabled: true, disabled: [] }, + }); ( context.services.settings as unknown as { workspace: { path: string } } ).workspace = { path: '/workspace', }; + + interface MockSettings { + user: { settings: unknown; path: string }; + workspace: { settings: unknown; path: string }; + forScope: unknown; + } + + const settings = context.services.settings as unknown as MockSettings; + + settings.forScope = vi.fn((scope) => { + if (scope === SettingScope.User) return settings.user; + if (scope === SettingScope.Workspace) return settings.workspace; + return { settings: {}, path: '' }; + }); + settings.user = { + settings: {}, + path: '/user/settings.json', + }; + settings.workspace = { + settings: {}, + path: '/workspace', + }; }); it('should disable a skill', async () => { @@ -144,9 +235,35 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: expect.stringContaining('Skill "skill1" disabled'), + text: 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace) settings. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should show reload guidance even if skill is already disabled', async () => { + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { enabled: true, disabled: ['skill1'] }, + }); + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + + await disableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" is already disabled. You can run "/skills reload" to refresh your current instance.', }), - expect.any(Number), ); }); @@ -154,7 +271,22 @@ describe('skillsCommand', () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; - context.services.settings.merged.skills = { disabled: ['skill1'] }; + ( + context.services.settings as unknown as { merged: MergedSettings } + ).merged = createTestMergedSettings({ + skills: { + enabled: true, + disabled: ['skill1'], + }, + }); + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + await enableCmd.action!(context, 'skill1'); expect(context.services.settings.setValue).toHaveBeenCalledWith( @@ -165,9 +297,47 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: expect.stringContaining('Skill "skill1" enabled'), + text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', + }), + ); + }); + + it('should enable a skill across multiple scopes', async () => { + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + ( + context.services.settings as unknown as { + user: { settings: { skills: { disabled: string[] } } }; + } + ).user.settings = { + skills: { disabled: ['skill1'] }, + }; + ( + context.services.settings as unknown as { + workspace: { settings: { skills: { disabled: string[] } } }; + } + ).workspace.settings = { + skills: { disabled: ['skill1'] }, + }; + + await enableCmd.action!(context, 'skill1'); + + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'skills.disabled', + [], + ); + expect(context.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'skills.disabled', + [], + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.', }), - expect.any(Number), ); }); @@ -185,6 +355,200 @@ describe('skillsCommand', () => { expect.any(Number), ); }); + + it('should show error if skills are disabled by admin during disable', async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); + + const disableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'disable', + )!; + await disableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + 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), + ); + }); + + it('should show error if skills are disabled by admin during enable', async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); + + const enableCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'enable', + )!; + await enableCmd.action!(context, 'skill1'); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + 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), + ); + }); + }); + + describe('reload', () => { + it('should reload skills successfully and show success message', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + // Make reload take some time so timer can fire + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + + // Initially, no pending item (flicker prevention) + expect(context.ui.setPendingItem).not.toHaveBeenCalled(); + + // Fast forward 100ms to trigger the pending item + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }), + ); + + // Fast forward another 100ms (reload complete), but pending item should stay + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null); + + // Fast forward to reach 500ms total + await vi.advanceTimersByTimeAsync(300); + await actionPromise; + + expect(reloadSkillsMock).toHaveBeenCalled(); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully.', + }), + ); + }); + + it('should show new skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + { name: 'skill2' }, + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill.', + }), + ); + }); + + it('should show removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 skill no longer available.', + }), + ); + }); + + it('should show both added and removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill2' }, // skill1 removed, skill3 added + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.', + }), + ); + }); + + it('should show error if configuration is missing', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + context.services.config = null; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }), + ); + }); + + it('should show error if reload fails', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const error = new Error('Reload failed'); + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((_, reject) => setTimeout(() => reject(error), 200)); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(400); + await actionPromise; + + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload skills: Reload failed', + }), + ); + }); }); describe('completions', () => { diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index e3cbc568a1..74372d2179 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -10,34 +10,47 @@ import { type SlashCommandActionReturn, CommandKind, } from './types.js'; -import { MessageType, type HistoryItemSkillsList } from '../types.js'; +import { + type HistoryItemInfo, + type HistoryItemSkillsList, + MessageType, +} from '../types.js'; +import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; + +import { getAdminErrorMessage } from '@google/gemini-cli-core'; +import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; import { SettingScope } from '../../config/settings.js'; async function listAction( context: CommandContext, args: string, ): Promise { - const subCommand = args.trim(); + const subArgs = args.trim().split(/\s+/); // Default to SHOWING descriptions. The user can hide them with 'nodesc'. let useShowDescriptions = true; - if (subCommand === 'nodesc') { - useShowDescriptions = false; + let showAll = false; + + for (const arg of subArgs) { + if (arg === 'nodesc' || arg === '--nodesc') { + useShowDescriptions = false; + } else if (arg === 'all' || arg === '--all') { + showAll = true; + } } const skillManager = context.services.config?.getSkillManager(); if (!skillManager) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Could not retrieve skill manager.', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve skill manager.', + }); return; } - const skills = skillManager.getAllSkills(); + const skills = showAll + ? skillManager.getAllSkills() + : skillManager.getAllSkills().filter((s) => !s.isBuiltin); const skillsListItem: HistoryItemSkillsList = { type: MessageType.SKILLS_LIST, @@ -47,11 +60,12 @@ async function listAction( disabled: skill.disabled, location: skill.location, body: skill.body, + isBuiltin: skill.isBuiltin, })), showDescriptions: useShowDescriptions, }; - context.ui.addItem(skillsListItem, Date.now()); + context.ui.addItem(skillsListItem); } async function disableAction( @@ -60,16 +74,27 @@ async function disableAction( ): Promise { const skillName = args.trim(); if (!skillName) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a skill name to disable.', + }); + return; + } + const skillManager = context.services.config?.getSkillManager(); + if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, - text: 'Please provide a skill name to disable.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); return; } - const skillManager = context.services.config?.getSkillManager(); + const skill = skillManager?.getSkill(skillName); if (!skill) { context.ui.addItem( @@ -82,32 +107,25 @@ async function disableAction( return; } - const currentDisabled = - context.services.settings.merged.skills?.disabled ?? []; - if (currentDisabled.includes(skillName)) { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Skill "${skillName}" is already disabled.`, - }, - Date.now(), - ); - return; - } - - const newDisabled = [...currentDisabled, skillName]; const scope = context.services.settings.workspace.path ? SettingScope.Workspace : SettingScope.User; - context.services.settings.setValue(scope, 'skills.disabled', newDisabled); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`, - }, - Date.now(), + const result = disableSkill(context.services.settings, skillName, scope); + + let feedback = renderSkillActionFeedback( + result, + (label, path) => `${label} (${path})`, ); + if (result.status === 'success' || result.status === 'no-op') { + feedback += + ' You can run "/skills reload" to refresh your current instance.'; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: feedback, + }); } async function enableAction( @@ -116,42 +134,129 @@ async function enableAction( ): Promise { const skillName = args.trim(); if (!skillName) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a skill name to enable.', + }); + return; + } + + const skillManager = context.services.config?.getSkillManager(); + if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, - text: 'Please provide a skill name to enable.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); return; } - const currentDisabled = - context.services.settings.merged.skills?.disabled ?? []; - if (!currentDisabled.includes(skillName)) { - context.ui.addItem( - { - type: MessageType.INFO, - text: `Skill "${skillName}" is not disabled.`, - }, - Date.now(), - ); - return; - } + const result = enableSkill(context.services.settings, skillName); - const newDisabled = currentDisabled.filter((name) => name !== skillName); - const scope = context.services.settings.workspace.path - ? SettingScope.Workspace - : SettingScope.User; - - context.services.settings.setValue(scope, 'skills.disabled', newDisabled); - context.ui.addItem( - { - type: MessageType.INFO, - text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`, - }, - Date.now(), + let feedback = renderSkillActionFeedback( + result, + (label, path) => `${label} (${path})`, ); + if (result.status === 'success' || result.status === 'no-op') { + feedback += + ' You can run "/skills reload" to refresh your current instance.'; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: feedback, + }); +} + +async function reloadAction( + context: CommandContext, +): Promise { + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + const skillManager = config.getSkillManager(); + const beforeNames = new Set(skillManager.getSkills().map((s) => s.name)); + + const startTime = Date.now(); + let pendingItemSet = false; + const pendingTimeout = setTimeout(() => { + context.ui.setPendingItem({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }); + pendingItemSet = true; + }, 100); + + try { + await config.reloadSkills(); + + clearTimeout(pendingTimeout); + if (pendingItemSet) { + // If we showed the pending item, make sure it stays for at least 500ms + // total to avoid a "flicker" where it appears and immediately disappears. + const elapsed = Date.now() - startTime; + const minVisibleDuration = 500; + if (elapsed < minVisibleDuration) { + await new Promise((resolve) => + setTimeout(resolve, minVisibleDuration - elapsed), + ); + } + context.ui.setPendingItem(null); + } + + const afterSkills = skillManager.getSkills(); + const afterNames = new Set(afterSkills.map((s) => s.name)); + + const added = afterSkills.filter((s) => !beforeNames.has(s.name)); + const removedCount = [...beforeNames].filter( + (name) => !afterNames.has(name), + ).length; + + let successText = 'Agent skills reloaded successfully.'; + const details: string[] = []; + + if (added.length > 0) { + details.push( + `${added.length} newly available skill${added.length > 1 ? 's' : ''}`, + ); + } + if (removedCount > 0) { + details.push( + `${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`, + ); + } + + if (details.length > 0) { + successText += ` ${details.join(' and ')}.`; + } + + context.ui.addItem({ + type: 'info', + text: successText, + icon: 'โœ“ ', + color: 'green', + } as HistoryItemInfo); + } catch (error) { + clearTimeout(pendingTimeout); + if (pendingItemSet) { + context.ui.setPendingItem(null); + } + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`, + }); + } } function disableCompletion( @@ -185,13 +290,14 @@ function enableCompletion( export const skillsCommand: SlashCommand = { name: 'skills', description: - 'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable | enable ]', + 'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable | enable | reload]', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ { name: 'list', - description: 'List available agent skills. Usage: /skills list [nodesc]', + description: + 'List available agent skills. Usage: /skills list [nodesc] [all]', kind: CommandKind.BUILT_IN, action: listAction, }, @@ -210,6 +316,13 @@ export const skillsCommand: SlashCommand = { action: enableAction, completion: enableCompletion, }, + { + name: 'reload', + description: + 'Reload the list of discovered skills. Usage: /skills reload', + kind: CommandKind.BUILT_IN, + action: reloadAction, + }, ], action: listAction, }; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 2a054ecc4d..f89c76caac 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -12,6 +12,17 @@ import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; import type { Config } from '@google/gemini-cli-core'; +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + UserAccountManager: vi.fn().mockImplementation(() => ({ + getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), + })), + }; +}); + describe('statsCommand', () => { let mockContext: CommandContext; const startTime = new Date('2025-07-14T10:00:00.000Z'); @@ -37,13 +48,13 @@ describe('statsCommand', () => { const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.STATS, - duration: expectedDuration, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.STATS, + duration: expectedDuration, + selectedAuthType: '', + tier: undefined, + userEmail: 'mock@example.com', + }); }); it('should fetch and display quota if config is available', async () => { @@ -51,8 +62,10 @@ describe('statsCommand', () => { const mockQuota = { buckets: [] }; const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota); + const mockGetUserTierName = vi.fn().mockReturnValue('Basic'); mockContext.services.config = { refreshUserQuota: mockRefreshUserQuota, + getUserTierName: mockGetUserTierName, } as unknown as Config; await statsCommand.action(mockContext, ''); @@ -61,8 +74,8 @@ describe('statsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ quotas: mockQuota, + tier: 'Basic', }), - expect.any(Number), ); }); @@ -75,12 +88,12 @@ describe('statsCommand', () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises modelSubCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.MODEL_STATS, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.MODEL_STATS, + selectedAuthType: '', + tier: undefined, + userEmail: 'mock@example.com', + }); }); it('should display tool stats when using the "tools" subcommand', () => { @@ -92,11 +105,8 @@ describe('statsCommand', () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises toolsSubCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.TOOL_STATS, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.TOOL_STATS, + }); }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 718da86f69..8d4466ba86 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -4,33 +4,53 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { HistoryItemStats } from '../types.js'; +import type { + HistoryItemStats, + HistoryItemModelStats, + HistoryItemToolStats, +} from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; +import { UserAccountManager } from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; +function getUserIdentity(context: CommandContext) { + const selectedAuthType = + context.services.settings.merged.security.auth.selectedType || ''; + + const userAccountManager = new UserAccountManager(); + const cachedAccount = userAccountManager.getCachedGoogleAccount(); + const userEmail = cachedAccount ?? undefined; + + const tier = context.services.config?.getUserTierName(); + + return { selectedAuthType, userEmail, tier }; +} + async function defaultSessionView(context: CommandContext) { const now = new Date(); const { sessionStartTime } = context.session.stats; if (!sessionStartTime) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Session start time is unavailable, cannot calculate stats.', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Session start time is unavailable, cannot calculate stats.', + }); return; } const wallDuration = now.getTime() - sessionStartTime.getTime(); + const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration), + selectedAuthType, + userEmail, + tier, }; if (context.services.config) { @@ -40,7 +60,7 @@ async function defaultSessionView(context: CommandContext) { } } - context.ui.addItem(statsItem, Date.now()); + context.ui.addItem(statsItem); } export const statsCommand: SlashCommand = { @@ -68,12 +88,13 @@ export const statsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: (context: CommandContext) => { - context.ui.addItem( - { - type: MessageType.MODEL_STATS, - }, - Date.now(), - ); + const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + context.ui.addItem({ + type: MessageType.MODEL_STATS, + selectedAuthType, + userEmail, + tier, + } as HistoryItemModelStats); }, }, { @@ -82,12 +103,9 @@ export const statsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: (context: CommandContext) => { - context.ui.addItem( - { - type: MessageType.TOOL_STATS, - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.TOOL_STATS, + } as HistoryItemToolStats); }, }, ], diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index d44be3f973..257e6ba167 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -40,13 +40,10 @@ describe('toolsCommand', () => { if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); }); it('should display "No tools available" when none are found', async () => { @@ -63,14 +60,11 @@ describe('toolsCommand', () => { if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.TOOLS_LIST, - tools: [], - showDescriptions: false, - }, - expect.any(Number), - ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith({ + type: MessageType.TOOLS_LIST, + tools: [], + showDescriptions: false, + }); }); it('should list tools without descriptions by default', async () => { diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index bbb86082f1..ff772c5cc8 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -27,13 +27,10 @@ export const toolsCommand: SlashCommand = { const toolRegistry = context.services.config?.getToolRegistry(); if (!toolRegistry) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', - }, - Date.now(), - ); + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); return; } @@ -51,6 +48,6 @@ export const toolsCommand: SlashCommand = { showDescriptions: useShowDescriptions, }; - context.ui.addItem(toolsListItem, Date.now()); + context.ui.addItem(toolsListItem); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2165ab377a..283cc9b6e1 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -15,6 +15,7 @@ import type { GitService, Logger, CommandActionReturn, + AgentDefinition, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -66,17 +67,24 @@ export interface CommandContext { * Loads a new set of history items, replacing the current history. * * @param history The array of history items to load. + * @param postLoadInput Optional text to set in the input buffer after loading history. */ - loadHistory: UseHistoryManagerReturn['loadHistory']; + loadHistory: (history: HistoryItem[], postLoadInput?: string) => void; /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleDebugProfiler: () => void; toggleVimEnabled: () => Promise; reloadCommands: () => void; + openAgentConfigDialog: ( + name: string, + displayName: string, + definition: AgentDefinition, + ) => void; extensionsUpdateState: Map; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; + toggleBackgroundShell: () => void; }; // Session-specific data session: { @@ -110,6 +118,7 @@ export interface OpenDialogActionReturn { | 'settings' | 'sessionBrowser' | 'model' + | 'agentConfig' | 'permissions'; } @@ -163,6 +172,7 @@ export enum CommandKind { BUILT_IN = 'built-in', FILE = 'file', MCP_PROMPT = 'mcp-prompt', + AGENT = 'agent', } // The standardized contract for any command in the system. diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index b6e5968e53..eab18ad089 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { AboutBox } from './AboutBox.js'; import { describe, it, expect, vi } from 'vitest'; @@ -25,7 +25,7 @@ describe('AboutBox', () => { }; it('renders with required props', () => { - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -33,24 +33,31 @@ describe('AboutBox', () => { expect(output).toContain('gemini-pro'); expect(output).toContain('default'); expect(output).toContain('macOS'); - expect(output).toContain('OAuth'); + expect(output).toContain('Logged in with Google'); }); it.each([ - ['userEmail', 'test@example.com', 'User Email'], ['gcpProject', 'my-project', 'GCP Project'], ['ideClient', 'vscode', 'IDE Client'], + ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); }); + it('renders Auth Method with email when userEmail is provided', () => { + const props = { ...defaultProps, userEmail: 'test@example.com' }; + const { lastFrame } = renderWithProviders(); + const output = lastFrame(); + expect(output).toContain('Logged in with Google (test@example.com)'); + }); + it('renders Auth Method correctly when not oauth', () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); const output = lastFrame(); expect(output).toContain('api-key'); }); diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index b14b814f03..ea5512b48d 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface AboutBoxProps { cliVersion: string; @@ -18,6 +19,7 @@ interface AboutBoxProps { gcpProject: string; ideClient: string; userEmail?: string; + tier?: string; } export const AboutBox: React.FC = ({ @@ -29,119 +31,131 @@ export const AboutBox: React.FC = ({ gcpProject, ideClient, userEmail, -}) => ( - - - - About Gemini CLI - - - - - - CLI Version + tier, +}) => { + const settings = useSettings(); + const showUserIdentity = settings.merged.ui.showUserIdentity; + + return ( + + + + About Gemini CLI - - {cliVersion} - - - {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( - Git Commit + CLI Version - {GIT_COMMIT_INFO} + {cliVersion} - )} - - - - Model - - - - {modelVersion} - - - - - - Sandbox - - - - {sandboxEnv} - - - - - - OS - - - - {osVersion} - - - - - - Auth Method - - - - - {selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType} - - - - {userEmail && ( + {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( + + + + Git Commit + + + + {GIT_COMMIT_INFO} + + + )} - User Email + Model - {userEmail} + {modelVersion} - )} - {gcpProject && ( - GCP Project + Sandbox - {gcpProject} + {sandboxEnv} - )} - {ideClient && ( - IDE Client + OS - {ideClient} + {osVersion} - )} - -); + {showUserIdentity && ( + + + + Auth Method + + + + + {selectedAuthType.startsWith('oauth') + ? userEmail + ? `Logged in with Google (${userEmail})` + : 'Logged in with Google' + : selectedAuthType} + + + + )} + {showUserIdentity && tier && ( + + + + Tier + + + + {tier} + + + )} + {gcpProject && ( + + + + GCP Project + + + + {gcpProject} + + + )} + {ideClient && ( + + + + IDE Client + + + + {ideClient} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx new file mode 100644 index 0000000000..479c6950ff --- /dev/null +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; + +const handleRestartMock = vi.fn(); + +describe('AdminSettingsChangedDialog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correctly', () => { + const { lastFrame } = renderWithProviders(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('restarts on "r" key press', async () => { + const { stdin } = renderWithProviders(, { + uiActions: { + handleRestart: handleRestartMock, + }, + }); + + act(() => { + stdin.write('r'); + }); + + expect(handleRestartMock).toHaveBeenCalled(); + }); + + it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { + const { stdin } = renderWithProviders(, { + uiActions: { + handleRestart: handleRestartMock, + }, + }); + + act(() => { + stdin.write(key); + }); + + expect(handleRestartMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx new file mode 100644 index 0000000000..b697dc17c4 --- /dev/null +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { Command, keyMatchers } from '../keyMatchers.js'; + +export const AdminSettingsChangedDialog = () => { + const { handleRestart } = useUIActions(); + + useKeypress( + (key) => { + if (keyMatchers[Command.RESTART_APP](key)) { + handleRestart(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const message = + 'Admin settings have changed. Please restart the session to apply new settings.'; + + return ( + + + {message} Press 'r' to restart, or 'Ctrl+C' twice to + exit. + + + ); +}; diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx new file mode 100644 index 0000000000..6aa04cfecd --- /dev/null +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { AgentConfigDialog } from './AgentConfigDialog.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import type { AgentDefinition } from '@google/gemini-cli-core'; + +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +enum TerminalKeys { + ENTER = '\u000D', + TAB = '\t', + UP_ARROW = '\u001B[A', + DOWN_ARROW = '\u001B[B', + ESCAPE = '\u001B', +} + +const createMockSettings = ( + userSettings = {}, + workspaceSettings = {}, +): LoadedSettings => { + const settings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {}, agents: {} }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: {}, + }, + path: '/system/settings.json', + }, + { + settings: {}, + originalSettings: {}, + path: '/system/system-defaults.json', + }, + { + settings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...userSettings, + }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...userSettings, + }, + path: '/user/settings.json', + }, + { + settings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...workspaceSettings, + }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...workspaceSettings, + }, + path: '/workspace/settings.json', + }, + true, + [], + ); + + // Mock setValue + settings.setValue = vi.fn(); + + return settings; +}; + +const createMockAgentDefinition = ( + overrides: Partial = {}, +): AgentDefinition => + ({ + name: 'test-agent', + displayName: 'Test Agent', + description: 'A test agent for testing', + kind: 'local', + modelConfig: { + model: 'inherit', + generateContentConfig: { + temperature: 1.0, + }, + }, + runConfig: { + maxTimeMinutes: 5, + maxTurns: 10, + }, + experimental: false, + ...overrides, + }) as AgentDefinition; + +describe('AgentConfigDialog', () => { + let mockOnClose: ReturnType; + let mockOnSave: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnClose = vi.fn(); + mockOnSave = vi.fn(); + }); + + const renderDialog = ( + settings: LoadedSettings, + definition: AgentDefinition = createMockAgentDefinition(), + ) => + render( + + + , + ); + + describe('rendering', () => { + it('should render the dialog with title', () => { + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings); + + expect(lastFrame()).toContain('Configure: Test Agent'); + }); + + it('should render all configuration fields', () => { + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings); + const frame = lastFrame(); + + expect(frame).toContain('Enabled'); + expect(frame).toContain('Model'); + expect(frame).toContain('Temperature'); + expect(frame).toContain('Top P'); + expect(frame).toContain('Top K'); + expect(frame).toContain('Max Output Tokens'); + expect(frame).toContain('Max Time (minutes)'); + expect(frame).toContain('Max Turns'); + }); + + it('should render scope selector', () => { + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings); + + expect(lastFrame()).toContain('Apply To'); + expect(lastFrame()).toContain('User Settings'); + expect(lastFrame()).toContain('Workspace Settings'); + }); + + it('should render help text', () => { + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings); + + expect(lastFrame()).toContain('Use Enter to select'); + expect(lastFrame()).toContain('Tab to change focus'); + expect(lastFrame()).toContain('Esc to close'); + }); + }); + + describe('keyboard navigation', () => { + it('should close dialog on Escape', async () => { + const settings = createMockSettings(); + const { stdin } = renderDialog(settings); + + await act(async () => { + stdin.write(TerminalKeys.ESCAPE); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should navigate down with arrow key', async () => { + const settings = createMockSettings(); + const { lastFrame, stdin } = renderDialog(settings); + + // Initially first item (Enabled) should be active + expect(lastFrame()).toContain('โ—'); + + // Press down arrow + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + + await waitFor(() => { + // Model field should now be highlighted + expect(lastFrame()).toContain('Model'); + }); + }); + + it('should switch focus with Tab', async () => { + const settings = createMockSettings(); + const { lastFrame, stdin } = renderDialog(settings); + + // Initially settings section is focused + expect(lastFrame()).toContain('> Configure: Test Agent'); + + // Press Tab to switch to scope selector + await act(async () => { + stdin.write(TerminalKeys.TAB); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('> Apply To'); + }); + }); + }); + + describe('boolean toggle', () => { + it('should toggle enabled field on Enter', async () => { + const settings = createMockSettings(); + const { stdin } = renderDialog(settings); + + // Press Enter to toggle the first field (Enabled) + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'agents.overrides.test-agent.enabled', + false, // Toggles from true (default) to false + ); + expect(mockOnSave).toHaveBeenCalled(); + }); + }); + }); + + describe('default values', () => { + it('should show values from agent definition as defaults', () => { + const definition = createMockAgentDefinition({ + modelConfig: { + model: 'gemini-2.0-flash', + generateContentConfig: { + temperature: 0.7, + }, + }, + runConfig: { + maxTimeMinutes: 10, + maxTurns: 20, + }, + }); + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings, definition); + const frame = lastFrame(); + + expect(frame).toContain('gemini-2.0-flash'); + expect(frame).toContain('0.7'); + expect(frame).toContain('10'); + expect(frame).toContain('20'); + }); + + it('should show experimental agents as disabled by default', () => { + const definition = createMockAgentDefinition({ + experimental: true, + }); + const settings = createMockSettings(); + const { lastFrame } = renderDialog(settings, definition); + + // Experimental agents default to disabled + expect(lastFrame()).toContain('false'); + }); + }); + + describe('existing overrides', () => { + it('should show existing override values with * indicator', () => { + const settings = createMockSettings({ + agents: { + overrides: { + 'test-agent': { + enabled: false, + modelConfig: { + model: 'custom-model', + }, + }, + }, + }, + }); + const { lastFrame } = renderDialog(settings); + const frame = lastFrame(); + + // Should show the overridden values + expect(frame).toContain('custom-model'); + expect(frame).toContain('false'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx new file mode 100644 index 0000000000..9226098bc7 --- /dev/null +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -0,0 +1,435 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core'; +import { getCachedStringWidth } from '../utils/textUtils.js'; +import { + BaseSettingsDialog, + type SettingsDialogItem, +} from './shared/BaseSettingsDialog.js'; + +/** + * Configuration field definition for agent settings + */ +interface AgentConfigField { + key: string; + label: string; + description: string; + type: 'boolean' | 'number' | 'string'; + path: string[]; // Path within AgentOverride, e.g., ['modelConfig', 'generateContentConfig', 'temperature'] + defaultValue: boolean | number | string | undefined; +} + +/** + * Agent configuration fields + */ +const AGENT_CONFIG_FIELDS: AgentConfigField[] = [ + { + key: 'enabled', + label: 'Enabled', + description: 'Enable or disable this agent', + type: 'boolean', + path: ['enabled'], + defaultValue: true, + }, + { + key: 'model', + label: 'Model', + description: "Model to use (e.g., 'gemini-2.0-flash' or 'inherit')", + type: 'string', + path: ['modelConfig', 'model'], + defaultValue: 'inherit', + }, + { + key: 'temperature', + label: 'Temperature', + description: 'Sampling temperature (0.0 to 2.0)', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'temperature'], + defaultValue: undefined, + }, + { + key: 'topP', + label: 'Top P', + description: 'Nucleus sampling parameter (0.0 to 1.0)', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'topP'], + defaultValue: undefined, + }, + { + key: 'topK', + label: 'Top K', + description: 'Top-K sampling parameter', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'topK'], + defaultValue: undefined, + }, + { + key: 'maxOutputTokens', + label: 'Max Output Tokens', + description: 'Maximum number of tokens to generate', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'maxOutputTokens'], + defaultValue: undefined, + }, + { + key: 'maxTimeMinutes', + label: 'Max Time (minutes)', + description: 'Maximum execution time in minutes', + type: 'number', + path: ['runConfig', 'maxTimeMinutes'], + defaultValue: undefined, + }, + { + key: 'maxTurns', + label: 'Max Turns', + description: 'Maximum number of conversational turns', + type: 'number', + path: ['runConfig', 'maxTurns'], + defaultValue: undefined, + }, +]; + +interface AgentConfigDialogProps { + agentName: string; + displayName: string; + definition: AgentDefinition; + settings: LoadedSettings; + onClose: () => void; + onSave?: () => void; +} + +/** + * Get a nested value from an object using a path array + */ +function getNestedValue( + obj: Record | undefined, + path: string[], +): unknown { + if (!obj) return undefined; + let current: unknown = obj; + for (const key of path) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +/** + * Set a nested value in an object using a path array, creating intermediate objects as needed + */ +function setNestedValue( + obj: Record, + path: string[], + value: unknown, +): Record { + const result = { ...obj }; + let current = result; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (current[key] === undefined || current[key] === null) { + current[key] = {}; + } else { + current[key] = { ...(current[key] as Record) }; + } + current = current[key] as Record; + } + + const finalKey = path[path.length - 1]; + if (value === undefined) { + delete current[finalKey]; + } else { + current[finalKey] = value; + } + + return result; +} + +/** + * Get the effective default value for a field from the agent definition + */ +function getFieldDefaultFromDefinition( + field: AgentConfigField, + definition: AgentDefinition, +): unknown { + if (definition.kind !== 'local') return field.defaultValue; + + if (field.key === 'enabled') { + return !definition.experimental; // Experimental agents default to disabled + } + if (field.key === 'model') { + return definition.modelConfig?.model ?? 'inherit'; + } + if (field.key === 'temperature') { + return definition.modelConfig?.generateContentConfig?.temperature; + } + if (field.key === 'topP') { + return definition.modelConfig?.generateContentConfig?.topP; + } + if (field.key === 'topK') { + return definition.modelConfig?.generateContentConfig?.topK; + } + if (field.key === 'maxOutputTokens') { + return definition.modelConfig?.generateContentConfig?.maxOutputTokens; + } + if (field.key === 'maxTimeMinutes') { + return definition.runConfig?.maxTimeMinutes; + } + if (field.key === 'maxTurns') { + return definition.runConfig?.maxTurns; + } + + return field.defaultValue; +} + +export function AgentConfigDialog({ + agentName, + displayName, + definition, + settings, + onClose, + onSave, +}: AgentConfigDialogProps): React.JSX.Element { + // Scope selector state (User by default) + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + + // Pending override state for the selected scope + const [pendingOverride, setPendingOverride] = useState(() => { + const scopeSettings = settings.forScope(selectedScope).settings; + const existingOverride = scopeSettings.agents?.overrides?.[agentName]; + return existingOverride ? structuredClone(existingOverride) : {}; + }); + + // Track which fields have been modified + const [modifiedFields, setModifiedFields] = useState>(new Set()); + + // Update pending override when scope changes + useEffect(() => { + const scopeSettings = settings.forScope(selectedScope).settings; + const existingOverride = scopeSettings.agents?.overrides?.[agentName]; + setPendingOverride( + existingOverride ? structuredClone(existingOverride) : {}, + ); + setModifiedFields(new Set()); + }, [selectedScope, settings, agentName]); + + /** + * Save a specific field value to settings + */ + const saveFieldValue = useCallback( + (fieldKey: string, path: string[], value: unknown) => { + // Guard against prototype pollution + if (['__proto__', 'constructor', 'prototype'].includes(agentName)) { + return; + } + // Build the full settings path for agent override + // e.g., agents.overrides..modelConfig.generateContentConfig.temperature + const settingsPath = ['agents', 'overrides', agentName, ...path].join( + '.', + ); + settings.setValue(selectedScope, settingsPath, value); + onSave?.(); + }, + [settings, selectedScope, agentName, onSave], + ); + + // Calculate max label width + const maxLabelWidth = useMemo(() => { + let max = 0; + for (const field of AGENT_CONFIG_FIELDS) { + const lWidth = getCachedStringWidth(field.label); + const dWidth = getCachedStringWidth(field.description); + max = Math.max(max, lWidth, dWidth); + } + return max; + }, []); + + // Generate items for BaseSettingsDialog + const items: SettingsDialogItem[] = useMemo( + () => + AGENT_CONFIG_FIELDS.map((field) => { + const currentValue = getNestedValue( + pendingOverride as Record, + field.path, + ); + const defaultValue = getFieldDefaultFromDefinition(field, definition); + const effectiveValue = + currentValue !== undefined ? currentValue : defaultValue; + + let displayValue: string; + if (field.type === 'boolean') { + displayValue = effectiveValue ? 'true' : 'false'; + } else if (effectiveValue !== undefined && effectiveValue !== null) { + displayValue = String(effectiveValue); + } else { + displayValue = '(default)'; + } + + // Add * if modified + const isModified = + modifiedFields.has(field.key) || currentValue !== undefined; + if (isModified && currentValue !== undefined) { + displayValue += '*'; + } + + // Get raw value for edit mode + const rawValue = + currentValue !== undefined ? currentValue : effectiveValue; + + return { + key: field.key, + label: field.label, + description: field.description, + type: field.type, + displayValue, + isGreyedOut: currentValue === undefined, + scopeMessage: undefined, + rawValue: rawValue as string | number | boolean | undefined, + }; + }), + [pendingOverride, definition, modifiedFields], + ); + + const maxItemsToShow = 8; + + // Handle scope changes + const handleScopeChange = useCallback((scope: LoadableSettingScope) => { + setSelectedScope(scope); + }, []); + + // Handle toggle for boolean fields + const handleItemToggle = useCallback( + (key: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field || field.type !== 'boolean') return; + + const currentValue = getNestedValue( + pendingOverride as Record, + field.path, + ); + const defaultValue = getFieldDefaultFromDefinition(field, definition); + const effectiveValue = + currentValue !== undefined ? currentValue : defaultValue; + const newValue = !effectiveValue; + + const newOverride = setNestedValue( + pendingOverride as Record, + field.path, + newValue, + ) as AgentOverride; + + setPendingOverride(newOverride); + setModifiedFields((prev) => new Set(prev).add(key)); + + // Save the field value to settings + saveFieldValue(field.key, field.path, newValue); + }, + [pendingOverride, definition, saveFieldValue], + ); + + // Handle edit commit for string/number fields + const handleEditCommit = useCallback( + (key: string, newValue: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field) return; + + let parsed: string | number | undefined; + if (field.type === 'number') { + if (newValue.trim() === '') { + // Empty means clear the override + parsed = undefined; + } else { + const numParsed = Number(newValue.trim()); + if (Number.isNaN(numParsed)) { + // Invalid number; don't save + return; + } + parsed = numParsed; + } + } else { + // For strings, empty means clear the override + parsed = newValue.trim() === '' ? undefined : newValue; + } + + // Update pending override locally + const newOverride = setNestedValue( + pendingOverride as Record, + field.path, + parsed, + ) as AgentOverride; + + setPendingOverride(newOverride); + setModifiedFields((prev) => new Set(prev).add(key)); + + // Save the field value to settings + saveFieldValue(field.key, field.path, parsed); + }, + [pendingOverride, saveFieldValue], + ); + + // Handle clear/reset - reset to default value (removes override) + const handleItemClear = useCallback( + (key: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field) return; + + // Remove the override (set to undefined) + const newOverride = setNestedValue( + pendingOverride as Record, + field.path, + undefined, + ) as AgentOverride; + + setPendingOverride(newOverride); + setModifiedFields((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + + // Save as undefined to remove the override + saveFieldValue(field.key, field.path, undefined); + }, + [pendingOverride, saveFieldValue], + ); + + // Footer content + const footerContent = + modifiedFields.size > 0 ? ( + Changes saved automatically. + ) : null; + + return ( + + ); +} diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index c487357081..68b662df7b 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -4,23 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { + renderWithProviders, + persistentStateMock, +} from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js'; import { ToolCallStatus } from '../types.js'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { Text } from 'ink'; -import { renderWithProviders } from '../../test-utils/render.js'; -import type { Config } from '@google/gemini-cli-core'; vi.mock('../utils/terminalSetup.js', () => ({ getTerminalProgram: () => null, })); -vi.mock('../contexts/AppContext.js', () => ({ - useAppContext: () => ({ - version: '0.10.0', - }), -})); +vi.mock('../contexts/AppContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useAppContext: () => ({ + version: '0.10.0', + }), + }; +}); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -82,21 +89,10 @@ const mockPendingHistoryItems: HistoryItemWithoutId[] = [ }, ]; -const mockConfig = { - getScreenReader: () => false, - getEnableInteractiveShell: () => false, - getModel: () => 'gemini-pro', - getTargetDir: () => '/tmp', - getDebugMode: () => false, - getGeminiMdFileCount: () => 0, - getExperiments: () => ({ - flags: {}, - experimentIds: [], - }), - getPreviewFeatures: () => false, -} as unknown as Config; - describe('AlternateBufferQuittingDisplay', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); const baseUIState = { terminalWidth: 80, mainAreaWidth: 80, @@ -111,6 +107,7 @@ describe('AlternateBufferQuittingDisplay', () => { }; it('renders with active and pending tool messages', () => { + persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame } = renderWithProviders( , { @@ -119,13 +116,13 @@ describe('AlternateBufferQuittingDisplay', () => { history: mockHistory, pendingHistoryItems: mockPendingHistoryItems, }, - config: mockConfig, }, ); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); }); it('renders with empty history and no pending items', () => { + persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame } = renderWithProviders( , { @@ -134,13 +131,13 @@ describe('AlternateBufferQuittingDisplay', () => { history: [], pendingHistoryItems: [], }, - config: mockConfig, }, ); expect(lastFrame()).toMatchSnapshot('empty'); }); it('renders with history but no pending items', () => { + persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame } = renderWithProviders( , { @@ -149,13 +146,13 @@ describe('AlternateBufferQuittingDisplay', () => { history: mockHistory, pendingHistoryItems: [], }, - config: mockConfig, }, ); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); }); it('renders with pending items but no history', () => { + persistentStateMock.setData({ tipsShown: 0 }); const { lastFrame } = renderWithProviders( , { @@ -164,13 +161,52 @@ describe('AlternateBufferQuittingDisplay', () => { history: [], pendingHistoryItems: mockPendingHistoryItems, }, - config: mockConfig, }, ); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); }); + it('renders with a tool awaiting confirmation', () => { + persistentStateMock.setData({ tipsShown: 0 }); + const pendingHistoryItems: HistoryItemWithoutId[] = [ + { + type: 'tool_group', + tools: [ + { + callId: 'call4', + name: 'confirming_tool', + description: 'Confirming tool description', + status: ToolCallStatus.Confirming, + resultDisplay: undefined, + confirmationDetails: { + type: 'info', + title: 'Confirm Tool', + prompt: 'Confirm this action?', + onConfirm: async () => {}, + }, + }, + ], + }, + ]; + const { lastFrame } = renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: [], + pendingHistoryItems, + }, + }, + ); + const output = lastFrame(); + expect(output).toContain('Action Required (was prompted):'); + expect(output).toContain('confirming_tool'); + expect(output).toContain('Confirming tool description'); + expect(output).toMatchSnapshot('with_confirming_tool'); + }); + it('renders with user and gemini messages', () => { + persistentStateMock.setData({ tipsShown: 0 }); const history: HistoryItem[] = [ { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, @@ -183,7 +219,6 @@ describe('AlternateBufferQuittingDisplay', () => { history, pendingHistoryItems: [], }, - config: mockConfig, }, ); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx index 0defa735e4..fec35d46c3 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -4,17 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box } from 'ink'; +import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { AppHeader } from './AppHeader.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { QuittingDisplay } from './QuittingDisplay.js'; import { useAppContext } from '../contexts/AppContext.js'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; +import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; +import { theme } from '../semantic-colors.js'; export const AlternateBufferQuittingDisplay = () => { const { version } = useAppContext(); const uiState = useUIState(); + const config = useConfig(); + + const confirmingTool = useConfirmingTool(); + const showPromptedTool = + config.isEventDrivenSchedulerEnabled() && confirmingTool !== null; // We render the entire chat history and header here to ensure that the // conversation history is visible to the user after the app quits and the @@ -52,6 +61,25 @@ export const AlternateBufferQuittingDisplay = () => { embeddedShellFocused={uiState.embeddedShellFocused} /> ))} + {showPromptedTool && ( + + + Action Required (was prompted): + + + + + + + )} ); diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 74388c816a..ba276533ca 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -4,31 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderWithProviders } from '../../test-utils/render.js'; +import { + renderWithProviders, + persistentStateMock, +} from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { makeFakeConfig } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; -const persistentStateMock = vi.hoisted(() => ({ - get: vi.fn(), - set: vi.fn(), -})); - -vi.mock('../../utils/persistentState.js', () => ({ - persistentState: persistentStateMock, -})); - vi.mock('../utils/terminalSetup.js', () => ({ getTerminalProgram: () => null, })); describe('', () => { - beforeEach(() => { - vi.clearAllMocks(); - persistentStateMock.get.mockReturnValue({}); - }); - it('should render the banner with default text', () => { const mockConfig = makeFakeConfig(); const uiState = { @@ -42,7 +31,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).toContain('This is the default banner'); @@ -63,7 +55,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).toContain('There are capacity issues'); @@ -83,7 +78,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).not.toContain('Banner'); @@ -104,7 +102,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).toContain('This is the default banner'); @@ -124,7 +125,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).not.toContain('This is the default banner'); @@ -133,7 +137,6 @@ describe('', () => { }); it('should not render the default banner if shown count is 5 or more', () => { - persistentStateMock.get.mockReturnValue(5); const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -143,9 +146,21 @@ describe('', () => { }, }; + persistentStateMock.setData({ + defaultBannerShownCount: { + [crypto + .createHash('sha256') + .update(uiState.bannerData.defaultText) + .digest('hex')]: 5, + }, + }); + const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).not.toContain('This is the default banner'); @@ -154,7 +169,6 @@ describe('', () => { }); it('should increment the version count when default banner is displayed', () => { - persistentStateMock.get.mockReturnValue({}); const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -164,6 +178,10 @@ describe('', () => { }, }; + // Set tipsShown to 10 or more to prevent Tips from incrementing its count + // and interfering with the expected persistentState.set call. + persistentStateMock.setData({ tipsShown: 10 }); + const { unmount } = renderWithProviders(, { config: mockConfig, uiState, @@ -194,10 +212,87 @@ describe('', () => { const { lastFrame, unmount } = renderWithProviders( , - { config: mockConfig, uiState }, + { + config: mockConfig, + uiState, + }, ); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); }); + + it('should render Tips when tipsShown is less than 10', () => { + const mockConfig = makeFakeConfig(); + const uiState = { + history: [], + bannerData: { + defaultText: 'First line\\nSecond line', + warningText: '', + }, + bannerVisible: true, + }; + + persistentStateMock.setData({ tipsShown: 5 }); + + const { lastFrame, unmount } = renderWithProviders( + , + { + config: mockConfig, + uiState, + }, + ); + + expect(lastFrame()).toContain('Tips'); + expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); + unmount(); + }); + + it('should NOT render Tips when tipsShown is 10 or more', () => { + const mockConfig = makeFakeConfig(); + + persistentStateMock.setData({ tipsShown: 10 }); + + const { lastFrame, unmount } = renderWithProviders( + , + { + config: mockConfig, + }, + ); + + expect(lastFrame()).not.toContain('Tips'); + unmount(); + }); + + it('should show tips until they have been shown 10 times (persistence flow)', () => { + persistentStateMock.setData({ tipsShown: 9 }); + + const mockConfig = makeFakeConfig(); + const uiState = { + history: [], + bannerData: { + defaultText: 'First line\\nSecond line', + warningText: '', + }, + bannerVisible: true, + }; + + // First session + const session1 = renderWithProviders(, { + config: mockConfig, + uiState, + }); + + expect(session1.lastFrame()).toContain('Tips'); + expect(persistentStateMock.get('tipsShown')).toBe(10); + session1.unmount(); + + // Second session - state is persisted in the fake + const session2 = renderWithProviders(, { + config: mockConfig, + }); + + expect(session2.lastFrame()).not.toContain('Tips'); + session2.unmount(); + }); }); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index c404c0e9f9..77042c6e3a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -12,6 +12,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; +import { useTips } from '../hooks/useTips.js'; interface AppHeaderProps { version: string; @@ -20,27 +21,27 @@ interface AppHeaderProps { export const AppHeader = ({ version }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState(); + const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); const { bannerText } = useBanner(bannerData, config); + const { showTips } = useTips(); return ( - {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( + {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( <>
{bannerVisible && bannerText && ( )} )} - {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( - - )} + {!(settings.merged.ui.hideTips || config.getScreenReader()) && + showTips && } ); }; diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx similarity index 57% rename from packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx rename to packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index d71d49d2f1..a5ddf5ac34 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -5,23 +5,32 @@ */ import { render } from '../../test-utils/render.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { describe, it, expect } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; -describe('AutoAcceptIndicator', () => { +describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).toContain('accepting edits'); - expect(output).toContain('(shift + tab to toggle)'); + expect(output).toContain('(shift + tab to cycle)'); + }); + + it('renders correctly for PLAN mode', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('plan mode'); + expect(output).toContain('(shift + tab to cycle)'); }); it('renders correctly for YOLO mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).toContain('YOLO mode'); @@ -30,7 +39,7 @@ describe('AutoAcceptIndicator', () => { it('renders nothing for DEFAULT mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).not.toContain('accepting edits'); diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx similarity index 74% rename from packages/cli/src/ui/components/AutoAcceptIndicator.tsx rename to packages/cli/src/ui/components/ApprovalModeIndicator.tsx index c72d366bb1..875cb0d84b 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -9,11 +9,11 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; -interface AutoAcceptIndicatorProps { +interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; } -export const AutoAcceptIndicator: React.FC = ({ +export const ApprovalModeIndicator: React.FC = ({ approvalMode, }) => { let textColor = ''; @@ -24,7 +24,12 @@ export const AutoAcceptIndicator: React.FC = ({ case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; textContent = 'accepting edits'; - subText = ' (shift + tab to toggle)'; + subText = ' (shift + tab to cycle)'; + break; + case ApprovalMode.PLAN: + textColor = theme.status.success; + textContent = 'plan mode'; + subText = ' (shift + tab to cycle)'; break; case ApprovalMode.YOLO: textColor = theme.status.error; diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index e1cb5e058b..79eb522c80 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -36,42 +36,3 @@ export const tinyAsciiLogo = ` โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ `; - -export const shortAsciiLogoIde = ` - โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘ - โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ - โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ - โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ -`; - -export const longAsciiLogoIde = ` - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘ - โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ -`; - -export const tinyAsciiLogoIde = ` - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘ โ–‘โ–‘โ–‘ - โ–‘โ–‘โ–‘ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–‘ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ -`; diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx new file mode 100644 index 0000000000..645321dfc0 --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -0,0 +1,949 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +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'; + +// Helper to write to stdin with proper act() wrapping +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('AskUserDialog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const authQuestion: Question[] = [ + { + question: 'Which authentication method should we use?', + header: 'Auth', + options: [ + { label: 'OAuth 2.0', description: 'Industry standard, supports SSO' }, + { label: 'JWT tokens', description: 'Stateless, good for APIs' }, + ], + multiSelect: false, + }, + ]; + + it('renders question and options', () => { + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + describe.each([ + { + name: 'Single Select', + questions: authQuestion, + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\r'); + }, + expectedSubmit: { '0': 'OAuth 2.0' }, + }, + { + name: 'Multi-select', + questions: [ + { + question: 'Which features?', + header: 'Features', + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\r'); // Toggle TS + writeKey(stdin, '\x1b[B'); // Down + writeKey(stdin, '\r'); // Toggle ESLint + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'Text Input', + questions: [ + { + question: 'Name?', + header: 'Name', + type: QuestionType.TEXT, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + for (const char of 'test-app') { + writeKey(stdin, char); + } + writeKey(stdin, '\r'); + }, + expectedSubmit: { '0': 'test-app' }, + }, + ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { + it(`submits correct values for ${name}`, async () => { + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + actions(stdin); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); + }); + }); + }); + + it('handles custom option in single select with inline typing', async () => { + const onSubmit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Move down to custom option + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\x1b[B'); + + await waitFor(() => { + expect(lastFrame()).toContain('Enter a custom value'); + }); + + // Type directly (inline) + for (const char of 'API Key') { + writeKey(stdin, char); + } + + await waitFor(() => { + expect(lastFrame()).toContain('API Key'); + }); + + // Press Enter to submit the custom value + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); + }); + }); + + it('shows scroll arrows when options exceed available height', 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( + , + ); + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Type a character without navigating down + writeKey(stdin, 'A'); + + await waitFor(() => { + // Should show the custom input with 'A' + // Placeholder is hidden when text is present + expect(lastFrame()).toContain('A'); + expect(lastFrame()).toContain('3. A'); + }); + + // Continue typing + writeKey(stdin, 'P'); + writeKey(stdin, 'I'); + + await waitFor(() => { + expect(lastFrame()).toContain('API'); + }); + }); + + it('shows progress header for multiple questions', () => { + const multiQuestions: Question[] = [ + { + question: 'Which database should we use?', + header: 'Database', + options: [ + { label: 'PostgreSQL', description: 'Relational database' }, + { label: 'MongoDB', description: 'Document database' }, + ], + multiSelect: false, + }, + { + question: 'Which ORM do you prefer?', + header: 'ORM', + options: [ + { label: 'Prisma', description: 'Type-safe ORM' }, + { label: 'Drizzle', description: 'Lightweight ORM' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('hides progress header for single question', () => { + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows keyboard hints', () => { + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('navigates between questions with arrow keys', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which testing framework?', + header: 'Testing', + options: [{ label: 'Vitest', description: 'Fast unit testing' }], + multiSelect: false, + }, + { + question: 'Which CI provider?', + header: 'CI', + options: [ + { label: 'GitHub Actions', description: 'Built into GitHub' }, + ], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toContain('Which testing framework?'); + + writeKey(stdin, '\x1b[C'); // Right arrow + + await waitFor(() => { + expect(lastFrame()).toContain('Which CI provider?'); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow + + await waitFor(() => { + expect(lastFrame()).toContain('Which testing framework?'); + }); + }); + + it('preserves answers when navigating back', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which package manager?', + header: 'Package', + options: [{ label: 'pnpm', description: 'Fast, disk efficient' }], + multiSelect: false, + }, + { + question: 'Which bundler?', + header: 'Bundler', + options: [{ label: 'Vite', description: 'Next generation bundler' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Answer first question (should auto-advance) + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(lastFrame()).toContain('Which bundler?'); + }); + + // Navigate back + writeKey(stdin, '\x1b[D'); + + await waitFor(() => { + expect(lastFrame()).toContain('Which package manager?'); + }); + + // Navigate forward + writeKey(stdin, '\x1b[C'); + + await waitFor(() => { + expect(lastFrame()).toContain('Which bundler?'); + }); + + // Answer second question + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(lastFrame()).toContain('Review your answers:'); + }); + + // Submit from Review + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); + }); + }); + + it('shows Review tab in progress header for multiple questions', () => { + const multiQuestions: Question[] = [ + { + question: 'Which framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'Component library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + { + question: 'Which styling?', + header: 'Styling', + options: [ + { label: 'Tailwind', description: 'Utility-first CSS' }, + { label: 'CSS Modules', description: 'Scoped styles' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('allows navigating to Review tab and back', async () => { + const multiQuestions: Question[] = [ + { + question: 'Create tests?', + header: 'Tests', + options: [{ label: 'Yes', description: 'Generate test files' }], + multiSelect: false, + }, + { + question: 'Add documentation?', + header: 'Docs', + options: [{ label: 'Yes', description: 'Generate JSDoc comments' }], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + writeKey(stdin, '\x1b[C'); // Right arrow + + await waitFor(() => { + expect(lastFrame()).toContain('Add documentation?'); + }); + + writeKey(stdin, '\x1b[C'); // Right arrow to Review + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow back + + await waitFor(() => { + expect(lastFrame()).toContain('Add documentation?'); + }); + }); + + it('shows warning for unanswered questions on Review tab', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which license?', + header: 'License', + options: [{ label: 'MIT', description: 'Permissive license' }], + multiSelect: false, + }, + { + question: 'Include README?', + header: 'README', + options: [{ label: 'Yes', description: 'Generate README.md' }], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Navigate directly to Review tab without answering + writeKey(stdin, '\x1b[C'); + writeKey(stdin, '\x1b[C'); + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('submits with unanswered questions when user confirms on Review', async () => { + const multiQuestions: Question[] = [ + { + question: 'Target Node version?', + header: 'Node', + options: [{ label: 'Node 20', description: 'LTS version' }], + multiSelect: false, + }, + { + question: 'Enable strict mode?', + header: 'Strict', + options: [{ label: 'Yes', description: 'Strict TypeScript' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + // Answer only first question + writeKey(stdin, '\r'); + // Navigate to Review tab + writeKey(stdin, '\x1b[C'); + // Submit + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); + }); + }); + + describe('Text type questions', () => { + it('renders text input for type: "text"', () => { + const textQuestion: Question[] = [ + { + question: 'What should we name this component?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'e.g., UserProfileCard', + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows default placeholder when none provided', () => { + const textQuestion: Question[] = [ + { + question: 'Enter the database connection string:', + header: 'Database', + type: QuestionType.TEXT, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('supports backspace in text mode', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the function name:', + header: 'Function', + type: QuestionType.TEXT, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'abc') { + writeKey(stdin, char); + } + + await waitFor(() => { + expect(lastFrame()).toContain('abc'); + }); + + writeKey(stdin, '\x7f'); // Backspace + + await waitFor(() => { + expect(lastFrame()).toContain('ab'); + expect(lastFrame()).not.toContain('abc'); + }); + }); + + it('shows correct keyboard hints for text type', () => { + const textQuestion: Question[] = [ + { + question: 'Enter the variable name:', + header: 'Variable', + type: QuestionType.TEXT, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('preserves text answer when navigating between questions', async () => { + const mixedQuestions: Question[] = [ + { + question: 'What should we name this hook?', + header: 'Hook', + type: QuestionType.TEXT, + }, + { + question: 'Should it be async?', + header: 'Async', + options: [ + { label: 'Yes', description: 'Use async/await' }, + { label: 'No', description: 'Synchronous hook' }, + ], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'useAuth') { + writeKey(stdin, char); + } + + writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active + + await waitFor(() => { + expect(lastFrame()).toContain('Should it be async?'); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input + + await waitFor(() => { + expect(lastFrame()).toContain('useAuth'); + }); + }); + + it('handles mixed text and choice questions', async () => { + const mixedQuestions: Question[] = [ + { + question: 'What should we name this component?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'Enter component name', + }, + { + question: 'Which styling approach?', + header: 'Style', + options: [ + { label: 'CSS Modules', description: 'Scoped CSS' }, + { label: 'Tailwind', description: 'Utility classes' }, + ], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'DataTable') { + writeKey(stdin, char); + } + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(lastFrame()).toContain('Which styling approach?'); + }); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(lastFrame()).toContain('Review your answers:'); + expect(lastFrame()).toContain('Name'); + expect(lastFrame()).toContain('DataTable'); + expect(lastFrame()).toContain('Style'); + expect(lastFrame()).toContain('CSS Modules'); + }); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': 'DataTable', + '1': 'CSS Modules', + }); + }); + }); + + it('does not submit empty text', () => { + const textQuestion: Question[] = [ + { + question: 'Enter the class name:', + header: 'Class', + type: QuestionType.TEXT, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = renderWithProviders( + , + { width: 120 }, + ); + + writeKey(stdin, '\r'); + + // onSubmit should not be called for empty text + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('clears text on Ctrl+C', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the class name:', + header: 'Class', + type: QuestionType.TEXT, + }, + ]; + + const onCancel = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'SomeText') { + writeKey(stdin, char); + } + + await waitFor(() => { + expect(lastFrame()).toContain('SomeText'); + }); + + // Send Ctrl+C + writeKey(stdin, '\x03'); // Ctrl+C + + await waitFor(() => { + // Text should be cleared + expect(lastFrame()).not.toContain('SomeText'); + expect(lastFrame()).toContain('>'); + }); + + // Should NOT call onCancel (dialog should stay open) + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('allows immediate arrow navigation after switching away from text input', async () => { + const multiQuestions: Question[] = [ + { + question: 'Choice Q?', + header: 'Choice', + options: [{ label: 'Option 1', description: '' }], + multiSelect: false, + }, + { + question: 'Text Q?', + header: 'Text', + type: QuestionType.TEXT, + }, + ]; + + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // 1. Move to Text Q (Right arrow works for Choice Q) + writeKey(stdin, '\x1b[C'); + await waitFor(() => { + expect(lastFrame()).toContain('Text Q?'); + }); + + // 2. Type something in Text Q to make isEditingCustomOption true + writeKey(stdin, 'a'); + await waitFor(() => { + expect(lastFrame()).toContain('a'); + }); + + // 3. Move back to Choice Q (Left arrow works because cursor is at left edge) + // When typing 'a', cursor is at index 1. + // We need to move cursor to index 0 first for Left arrow to work for navigation. + writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0 + await waitFor(() => { + expect(lastFrame()).toContain('Text Q?'); + }); + + writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation + await waitFor(() => { + expect(lastFrame()).toContain('Choice Q?'); + }); + + // 4. Immediately try Right arrow to go back to Text Q + writeKey(stdin, '\x1b[C'); + await waitFor(() => { + expect(lastFrame()).toContain('Text Q?'); + }); + }); + + it('handles rapid sequential answers correctly (stale closure protection)', async () => { + const multiQuestions: Question[] = [ + { + question: 'Question 1?', + header: 'Q1', + options: [{ label: 'A1', description: '' }], + multiSelect: false, + }, + { + question: 'Question 2?', + header: 'Q2', + options: [{ label: 'A2', description: '' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // Answer Q1 and Q2 sequentialy + act(() => { + stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance + }); + await waitFor(() => { + expect(lastFrame()).toContain('Question 2?'); + }); + + act(() => { + stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review + }); + await waitFor(() => { + expect(lastFrame()).toContain('Review your answers:'); + }); + + act(() => { + stdin.write('\r'); // Submit from Review + }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': 'A1', + '1': 'A2', + }); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx new file mode 100644 index 0000000000..e2892feade --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -0,0 +1,1074 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import type { Question } from '@google/gemini-cli-core'; +import { BaseSelectionList } from './shared/BaseSelectionList.js'; +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 { TextInput } from './shared/TextInput.js'; +import { useTextBuffer } from './shared/text-buffer.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; +import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; +import { DialogFooter } from './shared/DialogFooter.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; + +interface AskUserDialogState { + answers: { [key: string]: string }; + isEditingCustomOption: boolean; + submitted: boolean; +} + +type AskUserDialogAction = + | { + type: 'SET_ANSWER'; + payload: { + index: number; + answer: string; + }; + } + | { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } } + | { type: 'SUBMIT' }; + +const initialState: AskUserDialogState = { + answers: {}, + isEditingCustomOption: false, + submitted: false, +}; + +function askUserDialogReducerLogic( + state: AskUserDialogState, + action: AskUserDialogAction, +): AskUserDialogState { + if (state.submitted) { + return state; + } + + switch (action.type) { + case 'SET_ANSWER': { + const { index, answer } = action.payload; + const hasAnswer = + answer !== undefined && answer !== null && answer.trim() !== ''; + const newAnswers = { ...state.answers }; + + if (hasAnswer) { + newAnswers[index] = answer; + } else { + delete newAnswers[index]; + } + + return { + ...state, + answers: newAnswers, + }; + } + case 'SET_EDITING_CUSTOM': { + if (state.isEditingCustomOption === action.payload.isEditing) { + return state; + } + return { + ...state, + isEditingCustomOption: action.payload.isEditing, + }; + } + case 'SUBMIT': { + return { + ...state, + submitted: true, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +/** + * Props for the AskUserDialog component. + */ +interface AskUserDialogProps { + /** + * The list of questions to ask the user. + */ + questions: Question[]; + /** + * Callback fired when the user submits their answers. + * Returns a map of question index to answer string. + */ + onSubmit: (answers: { [questionIndex: string]: string }) => void; + /** + * Callback fired when the user cancels the dialog (e.g. via Escape). + */ + onCancel: () => void; + /** + * Optional callback to notify parent when text input is active. + * 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 { + questions: Question[]; + answers: { [key: string]: string }; + onSubmit: () => void; + progressHeader?: React.ReactNode; +} + +const ReviewView: React.FC = ({ + questions, + answers, + onSubmit, + progressHeader, +}) => { + const unansweredCount = questions.length - Object.keys(answers).length; + const hasUnanswered = unansweredCount > 0; + + // Handle Enter to submit + useKeypress( + (key: Key) => { + if (keyMatchers[Command.RETURN](key)) { + onSubmit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + return ( + + {progressHeader} + + + Review your answers: + + + + {hasUnanswered && ( + + + โš  You have {unansweredCount} unanswered question + {unansweredCount > 1 ? 's' : ''} + + + )} + + + {questions.map((q, i) => ( + + {q.header} + โ†’ + + {answers[i] || '(not answered)'} + + + ))} + + + + ); +}; + +// ============== Text Question View ============== + +interface TextQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + availableWidth: number; + availableHeight: number; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const TextQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + availableWidth, + availableHeight, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const prefix = '> '; + const horizontalPadding = 1; // 1 for cursor + const bufferWidth = + availableWidth - getCachedStringWidth(prefix) - horizontalPadding; + + const buffer = useTextBuffer({ + initialText: initialAnswer, + viewport: { width: Math.max(1, bufferWidth), height: 1 }, + singleLine: true, + isValidPath: () => false, + }); + + const { text: textValue } = buffer; + + // Sync state change with parent - only when it actually changes + const lastTextValueRef = useRef(textValue); + useEffect(() => { + if (textValue !== lastTextValueRef.current) { + onSelectionChange?.(textValue); + lastTextValueRef.current = textValue; + } + }, [textValue, onSelectionChange]); + + // Handle Ctrl+C to clear all text + const handleExtraKeys = useCallback( + (key: Key) => { + if (keyMatchers[Command.QUIT](key)) { + if (textValue === '') { + return false; + } + buffer.setText(''); + return true; + } + return false; + }, + [buffer, textValue], + ); + + useKeypress(handleExtraKeys, { isActive: true, priority: true }); + + const handleSubmit = useCallback( + (val: string) => { + if (val.trim()) { + onAnswer(val.trim()); + } + }, + [onAnswer], + ); + + // Notify parent that we're in text input mode (for Ctrl+C handling) + useEffect(() => { + onEditingCustomOption?.(true); + return () => { + onEditingCustomOption?.(false); + }; + }, [onEditingCustomOption]); + + 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 = Math.max(1, availableHeight - overhead); + + return ( + + {progressHeader} + + + + {question.question} + + + + + + {'> '} + + + + {keyboardHints} + + ); +}; + +// ============== Choice Question View ============== + +interface OptionItem { + key: string; + label: string; + description: string; + type: 'option' | 'other' | 'done'; + index: number; +} + +interface ChoiceQuestionState { + selectedIndices: Set; + isCustomOptionSelected: boolean; + isCustomOptionFocused: boolean; +} + +type ChoiceQuestionAction = + | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } + | { + type: 'SET_CUSTOM_SELECTED'; + payload: { selected: boolean; multiSelect: boolean }; + } + | { type: 'TOGGLE_CUSTOM_SELECTED'; payload: { multiSelect: boolean } } + | { type: 'SET_CUSTOM_FOCUSED'; payload: { focused: boolean } }; + +function choiceQuestionReducer( + state: ChoiceQuestionState, + action: ChoiceQuestionAction, +): ChoiceQuestionState { + switch (action.type) { + case 'TOGGLE_INDEX': { + const { index, multiSelect } = action.payload; + const newIndices = new Set(multiSelect ? state.selectedIndices : []); + if (newIndices.has(index)) { + newIndices.delete(index); + } else { + newIndices.add(index); + } + return { + ...state, + selectedIndices: newIndices, + // In single select, selecting an option deselects custom + isCustomOptionSelected: multiSelect + ? state.isCustomOptionSelected + : false, + }; + } + case 'SET_CUSTOM_SELECTED': { + const { selected, multiSelect } = action.payload; + return { + ...state, + isCustomOptionSelected: selected, + // In single-select, selecting custom deselects others + selectedIndices: multiSelect ? state.selectedIndices : new Set(), + }; + } + case 'TOGGLE_CUSTOM_SELECTED': { + const { multiSelect } = action.payload; + if (!multiSelect) return state; + + return { + ...state, + isCustomOptionSelected: !state.isCustomOptionSelected, + }; + } + case 'SET_CUSTOM_FOCUSED': { + return { + ...state, + isCustomOptionFocused: action.payload.focused, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +interface ChoiceQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + availableWidth: number; + availableHeight: number; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const ChoiceQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + availableWidth, + availableHeight, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const numOptions = + (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); + const numLen = String(numOptions).length; + const radioWidth = 2; // "โ— " + const numberWidth = numLen + 2; // e.g., "1. " + 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 horizontalPadding = + radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding; + + const bufferWidth = availableWidth - horizontalPadding; + + const questionOptions = useMemo( + () => question.options ?? [], + [question.options], + ); + + // Initialize state from initialAnswer if returning to a previously answered question + const initialReducerState = useMemo((): ChoiceQuestionState => { + if (!initialAnswer) { + return { + selectedIndices: new Set(), + isCustomOptionSelected: false, + isCustomOptionFocused: false, + }; + } + + // Check if initialAnswer matches any option labels + const selectedIndices = new Set(); + let isCustomOptionSelected = false; + + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + answers.forEach((answer) => { + const index = questionOptions.findIndex((opt) => opt.label === answer); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + }); + } else { + const index = questionOptions.findIndex( + (opt) => opt.label === initialAnswer, + ); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + } + + return { + selectedIndices, + isCustomOptionSelected, + isCustomOptionFocused: false, + }; + }, [initialAnswer, questionOptions, question.multiSelect]); + + const [state, dispatch] = useReducer( + choiceQuestionReducer, + initialReducerState, + ); + const { selectedIndices, isCustomOptionSelected, isCustomOptionFocused } = + state; + + const initialCustomText = useMemo(() => { + if (!initialAnswer) return ''; + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + const custom = answers.find( + (a) => !questionOptions.some((opt) => opt.label === a), + ); + return custom || ''; + } else { + const isPredefined = questionOptions.some( + (opt) => opt.label === initialAnswer, + ); + return isPredefined ? '' : initialAnswer; + } + }, [initialAnswer, questionOptions, question.multiSelect]); + + const customBuffer = useTextBuffer({ + initialText: initialCustomText, + viewport: { width: Math.max(1, bufferWidth), height: 1 }, + singleLine: true, + isValidPath: () => false, + }); + + const customOptionText = customBuffer.text; + + // Helper to build answer string from selections + const buildAnswerString = useCallback( + ( + indices: Set, + includeCustomOption: boolean, + customOption: string, + ) => { + const answers: string[] = []; + questionOptions.forEach((opt, i) => { + if (indices.has(i)) { + answers.push(opt.label); + } + }); + if (includeCustomOption && customOption.trim()) { + answers.push(customOption.trim()); + } + return answers.join(', '); + }, + [questionOptions], + ); + + // Synchronize selection changes with parent - only when it actually changes + const lastBuiltAnswerRef = useRef(''); + useEffect(() => { + const newAnswer = buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ); + if (newAnswer !== lastBuiltAnswerRef.current) { + onSelectionChange?.(newAnswer); + lastBuiltAnswerRef.current = newAnswer; + } + }, [ + selectedIndices, + isCustomOptionSelected, + customOptionText, + buildAnswerString, + onSelectionChange, + ]); + + // Handle "Type-to-Jump" and Ctrl+C for custom buffer + const handleExtraKeys = useCallback( + (key: Key) => { + // If focusing custom option, handle Ctrl+C + if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { + if (customOptionText === '') { + return false; + } + customBuffer.setText(''); + return true; + } + + // Don't jump if a navigation or selection key is pressed + if ( + keyMatchers[Command.DIALOG_NAVIGATION_UP](key) || + keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) || + keyMatchers[Command.DIALOG_NEXT](key) || + keyMatchers[Command.DIALOG_PREV](key) || + keyMatchers[Command.MOVE_LEFT](key) || + keyMatchers[Command.MOVE_RIGHT](key) || + keyMatchers[Command.RETURN](key) || + keyMatchers[Command.ESCAPE](key) || + keyMatchers[Command.QUIT](key) + ) { + return false; + } + + // Check if it's a numeric quick selection key (if numbers are shown) + const isNumeric = /^[0-9]$/.test(key.sequence); + if (isNumeric) { + return false; + } + + // Type-to-jump: if printable characters are typed and not focused, jump to custom + const isPrintable = + key.sequence && + !key.ctrl && + !key.alt && + (key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32); + + if (isPrintable && !isCustomOptionFocused) { + dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } }); + onEditingCustomOption?.(true); + // For IME or multi-char sequences, we want to capture the whole thing. + // If it's a single char, we start the buffer with it. + customBuffer.setText(key.sequence); + return true; + } + return false; + }, + [ + isCustomOptionFocused, + customBuffer, + onEditingCustomOption, + customOptionText, + ], + ); + + useKeypress(handleExtraKeys, { isActive: true, priority: true }); + + const selectionItems = useMemo((): Array> => { + const list: Array> = questionOptions.map( + (opt, i) => { + const item: OptionItem = { + key: `opt-${i}`, + label: opt.label, + description: opt.description, + type: 'option', + index: i, + }; + return { key: item.key, value: item }; + }, + ); + + // Only add custom option for choice type, not yesno + if (question.type !== 'yesno') { + const otherItem: OptionItem = { + key: 'other', + label: customOptionText || '', + description: '', + type: 'other', + index: list.length, + }; + list.push({ key: 'other', value: otherItem }); + } + + if (question.multiSelect) { + const doneItem: OptionItem = { + key: 'done', + label: 'Done', + description: 'Finish selection', + type: 'done', + index: list.length, + }; + list.push({ key: doneItem.key, value: doneItem, hideNumber: true }); + } + + return list; + }, [questionOptions, question.multiSelect, question.type, customOptionText]); + + const handleHighlight = useCallback( + (itemValue: OptionItem) => { + const nowFocusingCustomOption = itemValue.type === 'other'; + dispatch({ + type: 'SET_CUSTOM_FOCUSED', + payload: { focused: nowFocusingCustomOption }, + }); + // Notify parent when we start/stop focusing custom option (so navigation can resume) + onEditingCustomOption?.(nowFocusingCustomOption); + }, + [onEditingCustomOption], + ); + + const handleSelect = useCallback( + (itemValue: OptionItem) => { + if (question.multiSelect) { + if (itemValue.type === 'option') { + dispatch({ + type: 'TOGGLE_INDEX', + payload: { index: itemValue.index, multiSelect: true }, + }); + } else if (itemValue.type === 'other') { + dispatch({ + type: 'TOGGLE_CUSTOM_SELECTED', + payload: { multiSelect: true }, + }); + } else if (itemValue.type === 'done') { + // Done just triggers navigation, selections already saved via useEffect + onAnswer( + buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ), + ); + } + } else { + if (itemValue.type === 'option') { + onAnswer(itemValue.label); + } else if (itemValue.type === 'other') { + // In single select, selecting other submits it if it has text + if (customOptionText.trim()) { + onAnswer(customOptionText.trim()); + } + } + } + }, + [ + question.multiSelect, + selectedIndices, + isCustomOptionSelected, + customOptionText, + onAnswer, + buildAnswerString, + ], + ); + + // Auto-select custom option when typing in it + useEffect(() => { + if (customOptionText.trim() && !isCustomOptionSelected) { + dispatch({ + type: 'SET_CUSTOM_SELECTED', + payload: { selected: true, multiSelect: !!question.multiSelect }, + }); + } + }, [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 = Math.max(1, availableHeight - overhead); + const questionHeight = Math.min(3, Math.max(1, listHeight - 4)); + const maxItemsToShow = Math.max( + 1, + Math.floor((listHeight - questionHeight) / 2), + ); + + return ( + + {progressHeader} + + + + {question.question} + {question.multiSelect && ( + + {' '} + (Select all that apply) + + )} + + + + + + items={selectionItems} + onSelect={handleSelect} + onHighlight={handleHighlight} + focusKey={isCustomOptionFocused ? 'other' : undefined} + maxItemsToShow={maxItemsToShow} + showScrollArrows={true} + renderItem={(item, context) => { + const optionItem = item.value; + const isChecked = + selectedIndices.has(optionItem.index) || + (optionItem.type === 'other' && isCustomOptionSelected); + const showCheck = + question.multiSelect && + (optionItem.type === 'option' || optionItem.type === 'other'); + + // Render inline text input for custom option + if (optionItem.type === 'other') { + const placeholder = 'Enter a custom value'; + return ( + + {showCheck && ( + + [{isChecked ? 'x' : ' '}] + + )} + + handleSelect(optionItem)} + /> + {isChecked && !question.multiSelect && ( + โœ“ + )} + + ); + } + + // Determine label color: checked (previously answered) uses success, selected uses accent, else primary + const labelColor = + isChecked && !question.multiSelect + ? theme.status.success + : context.isSelected + ? context.titleColor + : theme.text.primary; + + return ( + + + {showCheck && ( + + [{isChecked ? 'x' : ' '}] + + )} + + {' '} + {optionItem.label} + + {isChecked && !question.multiSelect && ( + โœ“ + )} + + {optionItem.description && ( + + {' '} + {optionItem.description} + + )} + + ); + }} + /> + {keyboardHints} + + ); +}; + +export const AskUserDialog: React.FC = ({ + questions, + onSubmit, + onCancel, + onActiveTextInputChange, + width, + availableHeight, +}) => { + const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); + const { answers, isEditingCustomOption, submitted } = state; + + const reviewTabIndex = questions.length; + const tabCount = + questions.length > 1 ? questions.length + 1 : questions.length; + + const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({ + tabCount, + isActive: !submitted && questions.length > 1, + enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually + enableTabKey: false, // We'll handle tab manually to match existing behavior + }); + + const currentQuestionIndex = currentIndex; + + const handleEditingCustomOption = useCallback((isEditing: boolean) => { + dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } }); + }, []); + + useEffect(() => { + onActiveTextInputChange?.(isEditingCustomOption); + return () => { + onActiveTextInputChange?.(false); + }; + }, [isEditingCustomOption, onActiveTextInputChange]); + + const handleCancel = useCallback( + (key: Key) => { + if (submitted) return false; + if (keyMatchers[Command.ESCAPE](key)) { + 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; + }, + [onCancel, submitted, isEditingCustomOption], + ); + + useKeypress(handleCancel, { + isActive: !submitted, + }); + + const isOnReviewTab = currentQuestionIndex === reviewTabIndex; + + const handleNavigation = useCallback( + (key: Key) => { + if (submitted || questions.length <= 1) return false; + + const isNextKey = keyMatchers[Command.DIALOG_NEXT](key); + const isPrevKey = keyMatchers[Command.DIALOG_PREV](key); + + const isRight = keyMatchers[Command.MOVE_RIGHT](key); + const isLeft = keyMatchers[Command.MOVE_LEFT](key); + + // Tab keys always trigger navigation. + // Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge). + const shouldGoNext = isNextKey || isRight; + const shouldGoPrev = isPrevKey || isLeft; + + if (shouldGoNext) { + goToNextTab(); + return true; + } else if (shouldGoPrev) { + goToPrevTab(); + return true; + } + return false; + }, + [questions.length, submitted, goToNextTab, goToPrevTab], + ); + + useKeypress(handleNavigation, { + isActive: questions.length > 1 && !submitted, + }); + + useEffect(() => { + if (submitted) { + onSubmit(answers); + } + }, [submitted, answers, onSubmit]); + + const handleAnswer = useCallback( + (answer: string) => { + if (submitted) return; + + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + }, + }); + + if (questions.length > 1) { + goToNextTab(); + } else { + dispatch({ type: 'SUBMIT' }); + } + }, + [currentQuestionIndex, questions.length, submitted, goToNextTab], + ); + + const handleReviewSubmit = useCallback(() => { + if (submitted) return; + dispatch({ type: 'SUBMIT' }); + }, [submitted]); + + const handleSelectionChange = useCallback( + (answer: string) => { + if (submitted) return; + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + }, + }); + }, + [submitted, currentQuestionIndex], + ); + + const answeredIndices = useMemo( + () => new Set(Object.keys(answers).map(Number)), + [answers], + ); + + const currentQuestion = questions[currentQuestionIndex]; + + const effectiveQuestion = useMemo(() => { + if (currentQuestion?.type === 'yesno') { + return { + ...currentQuestion, + options: [ + { label: 'Yes', description: '' }, + { label: 'No', description: '' }, + ], + multiSelect: false, + }; + } + return currentQuestion; + }, [currentQuestion]); + + const tabs = useMemo((): Tab[] => { + const questionTabs: Tab[] = questions.map((q, i) => ({ + key: String(i), + header: q.header, + })); + if (questions.length > 1) { + questionTabs.push({ + key: 'review', + header: 'Review', + isSpecial: true, + }); + } + return questionTabs; + }, [questions]); + + const progressHeader = + questions.length > 1 ? ( + + ) : null; + + if (isOnReviewTab) { + return ( + + + + ); + } + + if (!currentQuestion) return null; + + const keyboardHints = ( + 1 + ? currentQuestion.type === 'text' || isEditingCustomOption + ? 'Tab/Shift+Tab to switch questions' + : 'โ†/โ†’ to switch questions' + : currentQuestion.type === 'text' || isEditingCustomOption + ? undefined + : 'โ†‘/โ†“ to navigate' + } + /> + ); + + const questionView = + currentQuestion.type === 'text' ? ( + + ) : ( + + ); + + return ( + + {questionView} + + ); +}; diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx new file mode 100644 index 0000000000..e5060af391 --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -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(); + 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) => { + 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; + }) => ( + + {data.map((item: BackgroundShell, index: number) => ( + {renderItem({ item, index })} + ))} + + ), + ), +})); + +const createMockKey = (overrides: Partial): Key => ({ + name: '', + ctrl: false, + alt: false, + cmd: false, + shift: false, + insertable: false, + sequence: '', + ...overrides, +}); + +describe('', () => { + const mockShells = new Map(); + 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( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders tabs for multiple shells', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('highlights the focused state', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('resizes the PTY on mount and when dimensions change', async () => { + const { rerender } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( + shell1.pid, + 76, + 21, + ); + + rerender( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('unfocuses the shell when Shift+Tab is pressed', async () => { + render( + + + , + ); + 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( + + + , + ); + await act(async () => { + await delay(0); + }); + + act(() => { + simulateKey({ name: 'tab' }); + }); + + expect(mockHandleWarning).toHaveBeenCalledWith( + 'Press Shift+Tab to focus out.', + ); + expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx new file mode 100644 index 0000000000..e0e63f636a --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -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; + 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( + activeShell?.output || '', + ); + const [highlightedPid, setHighlightedPid] = useState( + activePid, + ); + const outputRef = useRef>(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( + + {label} + , + ); + 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( + + {overflowLabel} + , + ); + } + } + + return tabs; + }; + + const renderProcessList = () => { + const maxCommandLength = Math.max( + 0, + width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10, + ); + + const items: Array> = 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 ( + + + + {`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`} + + + + = 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 {item.label}; + + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); + + return ( + + {truncatedCommand} (PID: {shell.pid}) + {shell.status === 'exited' ? ( + + {' '} + (Exit Code: {shell.exitCode}) + + ) : null} + + ); + }} + /> + + + ); + }; + + const renderOutput = () => { + const lines = typeof output === 'string' ? output.split('\n') : output; + + return ( + { + if (typeof line === 'string') { + return {line}; + } + return ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + ); + }} + estimatedItemHeight={() => 1} + keyExtractor={(_, index) => index.toString()} + hasFocus={isFocused} + initialScrollIndex={SCROLL_TO_ITEM_END} + /> + ); + }; + + return ( + + + + {renderTabs()} + + {' '} + (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} + + + {helpText} + + + {isListOpenProp ? renderProcessList() : renderOutput()} + + + ); +}; diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx new file mode 100644 index 0000000000..f91f6fe2dc --- /dev/null +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { AskUserDialog } from './AskUserDialog.js'; +import type { Question } from '@google/gemini-cli-core'; + +describe('Key Bubbling Regression', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const choiceQuestion: Question[] = [ + { + question: 'Choice Q?', + header: 'Choice', + options: [ + { label: 'Option 1', description: '' }, + { label: 'Option 2', description: '' }, + ], + multiSelect: false, + }, + ]; + + it('does not navigate when pressing "j" or "k" in a focused text input', async () => { + const { stdin, lastFrame } = renderWithProviders( + , + { width: 120 }, + ); + + // 1. Move down to "Enter a custom value" (3rd item) + act(() => { + stdin.write('\x1b[B'); // Down arrow to Option 2 + }); + act(() => { + stdin.write('\x1b[B'); // Down arrow to Custom + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Enter a custom value'); + }); + + // 2. Type "j" + act(() => { + stdin.write('j'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('j'); + // Verify we are still focusing the custom option (3rd item in list) + expect(lastFrame()).toMatch(/โ— 3\.\s+j/); + }); + + // 3. Type "k" + act(() => { + stdin.write('k'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('jk'); + expect(lastFrame()).toMatch(/โ— 3\.\s+jk/); + }); + }); +}); diff --git a/packages/cli/src/ui/components/Checklist.test.tsx b/packages/cli/src/ui/components/Checklist.test.tsx new file mode 100644 index 0000000000..ba1f0e4813 --- /dev/null +++ b/packages/cli/src/ui/components/Checklist.test.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { Checklist } from './Checklist.js'; +import type { ChecklistItemData } from './ChecklistItem.js'; + +describe('', () => { + const items: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'in_progress', label: 'Task 2' }, + { status: 'pending', label: 'Task 3' }, + { status: 'cancelled', label: 'Task 4' }, + ]; + + it('renders nothing when list is empty', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders nothing when collapsed and no active items', () => { + const inactiveItems: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'cancelled', label: 'Task 2' }, + ]; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders summary view correctly (collapsed)', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders expanded view correctly', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders summary view without in-progress item if none exists', () => { + const pendingItems: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'pending', label: 'Task 2' }, + ]; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/Checklist.tsx b/packages/cli/src/ui/components/Checklist.tsx new file mode 100644 index 0000000000..cfbd4268fd --- /dev/null +++ b/packages/cli/src/ui/components/Checklist.tsx @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useMemo } from 'react'; +import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; + +export interface ChecklistProps { + title: string; + items: ChecklistItemData[]; + isExpanded: boolean; + toggleHint?: string; +} + +const ChecklistTitleDisplay: React.FC<{ + title: string; + items: ChecklistItemData[]; + toggleHint?: string; +}> = ({ title, items, toggleHint }) => { + const score = useMemo(() => { + let total = 0; + let completed = 0; + for (const item of items) { + if (item.status !== 'cancelled') { + total += 1; + if (item.status === 'completed') { + completed += 1; + } + } + } + return `${completed}/${total} completed`; + }, [items]); + + return ( + + + {title} + + + {score} + {toggleHint ? ` (${toggleHint})` : ''} + + + ); +}; + +const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({ + items, +}) => ( + + {items.map((item, index) => ( + + ))} + +); + +export const Checklist: React.FC = ({ + title, + items, + isExpanded, + toggleHint, +}) => { + const inProgress: ChecklistItemData | null = useMemo( + () => items.find((item) => item.status === 'in_progress') || null, + [items], + ); + + const hasActiveItems = useMemo( + () => + items.some( + (item) => item.status === 'pending' || item.status === 'in_progress', + ), + [items], + ); + + if (items.length === 0 || (!isExpanded && !hasActiveItems)) { + return null; + } + + return ( + + {isExpanded ? ( + + + + + ) : ( + + + + + {inProgress && ( + + + + )} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx new file mode 100644 index 0000000000..7d52f07ae6 --- /dev/null +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; +import { Box } from 'ink'; + +describe('', () => { + it.each([ + { status: 'pending', label: 'Do this' }, + { status: 'in_progress', label: 'Doing this' }, + { status: 'completed', label: 'Done this' }, + { status: 'cancelled', label: 'Skipped this' }, + ] as ChecklistItemData[])('renders %s item correctly', (item) => { + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('truncates long text when wrap="truncate"', () => { + const item: ChecklistItemData = { + status: 'in_progress', + label: + 'This is a very long text that should be truncated because the wrap prop is set to truncate', + }; + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('wraps long text by default', () => { + const item: ChecklistItemData = { + status: 'in_progress', + label: + 'This is a very long text that should wrap because the default behavior is wrapping', + }; + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx new file mode 100644 index 0000000000..922cec97e1 --- /dev/null +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { checkExhaustive } from '../../utils/checks.js'; + +export type ChecklistStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'cancelled'; + +export interface ChecklistItemData { + status: ChecklistStatus; + label: string; +} + +const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ + status, +}) => { + switch (status) { + case 'completed': + return ( + + โœ“ + + ); + case 'in_progress': + return ( + + ยป + + ); + case 'pending': + return ( + + โ˜ + + ); + case 'cancelled': + return ( + + โœ— + + ); + default: + checkExhaustive(status); + } +}; + +export interface ChecklistItemProps { + item: ChecklistItemData; + wrap?: 'truncate'; + role?: 'listitem'; +} + +export const ChecklistItem: React.FC = ({ + item, + wrap, + role: ariaRole, +}) => { + const textColor = (() => { + switch (item.status) { + case 'in_progress': + return theme.text.accent; + case 'completed': + case 'cancelled': + return theme.text.secondary; + case 'pending': + return theme.text.primary; + default: + checkExhaustive(item.status); + } + })(); + const strikethrough = item.status === 'cancelled'; + + return ( + + + + + {item.label} + + + + ); +}; diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx index bbea23ab5d..76522c41c1 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { + renderWithProviders, + createMockSettings, +} from '../../test-utils/render.js'; import { CliSpinner } from './CliSpinner.js'; import { debugState } from '../debug.js'; import { describe, it, expect, beforeEach } from 'vitest'; @@ -16,9 +19,15 @@ describe('', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { unmount } = render(); + const { unmount } = renderWithProviders(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); }); + + it('should not render when showSpinner is false', () => { + const settings = createMockSettings({ ui: { showSpinner: false } }); + const { lastFrame } = renderWithProviders(, { settings }); + expect(lastFrame()).toBe(''); + }); }); diff --git a/packages/cli/src/ui/components/CliSpinner.tsx b/packages/cli/src/ui/components/CliSpinner.tsx index 6795bf2670..66cb7a0281 100644 --- a/packages/cli/src/ui/components/CliSpinner.tsx +++ b/packages/cli/src/ui/components/CliSpinner.tsx @@ -7,16 +7,27 @@ import Spinner from 'ink-spinner'; import { type ComponentProps, useEffect } from 'react'; import { debugState } from '../debug.js'; +import { useSettings } from '../contexts/SettingsContext.js'; export type SpinnerProps = ComponentProps; export const CliSpinner = (props: SpinnerProps) => { + const settings = useSettings(); + const shouldShow = settings.merged.ui?.showSpinner !== false; + useEffect(() => { - debugState.debugNumAnimatedComponents++; - return () => { - debugState.debugNumAnimatedComponents--; - }; - }, []); + if (shouldShow) { + debugState.debugNumAnimatedComponents++; + return () => { + debugState.debugNumAnimatedComponents--; + }; + } + return undefined; + }, [shouldShow]); + + if (!shouldShow) { + return null; + } return ; }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index ef97c56201..4e2ad6464f 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -19,11 +19,12 @@ import { SettingsContext } from '../contexts/SettingsContext.js'; vi.mock('../contexts/VimModeContext.js', () => ({ useVimMode: vi.fn(() => ({ vimEnabled: false, - vimMode: 'NORMAL', + vimMode: 'INSERT', })), })); 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', () => ({ @@ -36,8 +37,12 @@ vi.mock('./ContextSummaryDisplay.js', () => ({ ContextSummaryDisplay: () => ContextSummaryDisplay, })); -vi.mock('./AutoAcceptIndicator.js', () => ({ - AutoAcceptIndicator: () => AutoAcceptIndicator, +vi.mock('./HookStatusDisplay.js', () => ({ + HookStatusDisplay: () => HookStatusDisplay, +})); + +vi.mock('./ApprovalModeIndicator.js', () => ({ + ApprovalModeIndicator: () => ApprovalModeIndicator, })); vi.mock('./ShellModeIndicator.js', () => ({ @@ -49,7 +54,9 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({ })); vi.mock('./InputPrompt.js', () => ({ - InputPrompt: () => InputPrompt, + InputPrompt: ({ placeholder }: { placeholder?: string }) => ( + InputPrompt: {placeholder} + ), calculatePromptWidths: vi.fn(() => ({ inputWidth: 80, suggestionsWidth: 40, @@ -90,12 +97,12 @@ const createMockUIState = (overrides: Partial = {}): UIState => ({ streamingState: null, contextFileNames: [], - showAutoAcceptIndicator: ApprovalMode.DEFAULT, + showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], showErrorDetails: false, constrainHeight: false, isInputActive: true, - buffer: '', + buffer: { text: '' }, inputWidth: 80, suggestionsWidth: 40, userMessages: [], @@ -125,6 +132,9 @@ const createMockUIState = (overrides: Partial = {}): UIState => errorCount: 0, nightly: false, isTrustedFolder: true, + activeHooks: [], + isBackgroundShellVisible: false, + embeddedShellFocused: false, ...overrides, }) as UIState; @@ -149,6 +159,7 @@ const createMockConfig = (overrides = {}) => ({ }), getSkillManager: () => ({ getSkills: () => [], + getDisplayableSkills: () => [], }), getMcpClientManager: () => ({ getMcpServers: () => ({}), @@ -157,13 +168,20 @@ const createMockConfig = (overrides = {}) => ({ ...overrides, }); -const createMockSettings = (merged = {}) => ({ - merged: { - hideFooter: false, - showMemoryUsage: false, - ...merged, - }, -}); +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 = ( @@ -269,7 +287,7 @@ describe('Composer', () => { thought: { subject: 'Hidden', description: 'Should not show' }, }); const config = createMockConfig({ - getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })), + getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })), }); const { lastFrame } = renderComposer(uiState, undefined, config); @@ -294,6 +312,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', () => { @@ -341,6 +385,17 @@ describe('Composer', () => { expect(lastFrame()).toContain('ContextSummaryDisplay'); }); + it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => { + const uiState = createMockUIState({ + activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }], + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('HookStatusDisplay'); + expect(lastFrame()).not.toContain('ContextSummaryDisplay'); + }); + it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => { const uiState = createMockUIState({ ctrlCPressedOnce: true, @@ -364,11 +419,12 @@ describe('Composer', () => { it('shows escape prompt when showEscapePrompt is true', () => { const uiState = createMockUIState({ showEscapePrompt: true, + history: [{ id: 1, type: 'user', text: 'test' }], }); const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('Press Esc again to clear'); + expect(lastFrame()).toContain('Press Esc again to rewind'); }); }); @@ -393,15 +449,15 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('InputPrompt'); }); - it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => { + it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => { const uiState = createMockUIState({ - showAutoAcceptIndicator: ApprovalMode.YOLO, + showApprovalModeIndicator: ApprovalMode.YOLO, shellModeActive: false, }); const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('AutoAcceptIndicator'); + expect(lastFrame()).toContain('ApprovalModeIndicator'); }); it('shows ShellModeIndicator when shell mode is active', () => { @@ -461,4 +517,40 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); }); }); + + describe('Vim Mode Placeholders', () => { + it('shows correct placeholder in INSERT mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'INSERT', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'Esc' for NORMAL mode.", + ); + }); + + it('shows correct placeholder in NORMAL mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'NORMAL', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'i' for INSERT mode.", + ); + }); + }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 11685a4435..d366516a94 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,10 +5,10 @@ */ import { useState } from 'react'; -import { Box, Text, useIsScreenReaderEnabled } from 'ink'; +import { Box, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; -import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { StatusDisplay } from './StatusDisplay.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; @@ -17,7 +17,6 @@ import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; -import { theme } from '../semantic-colors.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -30,20 +29,20 @@ import { StreamingState } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; -export const Composer = () => { +export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); const settings = useSettings(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const uiState = useUIState(); const uiActions = useUIActions(); - const { vimEnabled } = useVimMode(); + const { vimEnabled, vimMode } = useVimMode(); const terminalWidth = process.stdout.columns; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); const [suggestionsVisible, setSuggestionsVisible] = useState(false); const isAlternateBuffer = useAlternateBuffer(); - const { contextFileNames, showAutoAcceptIndicator } = uiState; + const { showApprovalModeIndicator } = uiState; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; @@ -51,20 +50,20 @@ export const Composer = () => { return ( - {!uiState.embeddedShellFocused && ( + {(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && ( { /> )} - {(!uiState.slashCommands || !uiState.isConfigInitialized) && ( - + {(!uiState.slashCommands || + !uiState.isConfigInitialized || + uiState.isResuming) && ( + )} @@ -83,52 +86,19 @@ export const Composer = () => { - {process.env['GEMINI_SYSTEM_MD'] && ( - |โŒโ– _โ– | - )} - {uiState.ctrlCPressedOnce ? ( - - Press Ctrl+C again to exit. - - ) : uiState.warningMessage ? ( - {uiState.warningMessage} - ) : uiState.ctrlDPressedOnce ? ( - - Press Ctrl+D again to exit. - - ) : uiState.showEscapePrompt ? ( - Press Esc again to clear. - ) : uiState.queueErrorMessage ? ( - {uiState.queueErrorMessage} - ) : ( - !settings.merged.ui?.hideContextSummary && - !hideContextSummary && ( - - ) - )} + - {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && + {showApprovalModeIndicator !== ApprovalMode.DEFAULT && !uiState.shellModeActive && ( - + )} {uiState.shellModeActive && } {!uiState.renderMarkdown && } @@ -143,7 +113,7 @@ export const Composer = () => { maxHeight={ uiState.constrainHeight ? debugConsoleMaxHeight : undefined } - width={uiState.mainAreaWidth} + width={uiState.terminalWidth} hasFocus={uiState.showErrorDetails} /> @@ -165,15 +135,17 @@ export const Composer = () => { commandContext={uiState.commandContext} shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} - approvalMode={showAutoAcceptIndicator} + approvalMode={showApprovalModeIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} - focus={true} + focus={isFocused} vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} popAllMessages={uiActions.popAllMessages} placeholder={ vimEnabled - ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." + ? vimMode === 'INSERT' + ? " Press 'Esc' for NORMAL mode." + : " Press 'i' for INSERT mode." : uiState.shellModeActive ? ' Type your shell command' : ' Type your message or @path/to/file' @@ -185,7 +157,7 @@ export const Composer = () => { /> )} - {!settings.merged.ui?.hideFooter && !isScreenReaderEnabled &&