mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0203ab0a1c | |||
| cdf5c265d8 | |||
| d0272a6436 | |||
| d14205a4ce | |||
| 20ca623cb8 | |||
| 7ea1654706 | |||
| 4cde103712 | |||
| ee635bb0e9 | |||
| 7181242f69 | |||
| 44abbdf56e | |||
| 152806379c | |||
| 03efe65dd5 | |||
| 9e3a48a3b6 | |||
| e064cfe043 | |||
| 63a6211fe0 | |||
| c2a17ae257 | |||
| c1297436b9 | |||
| 3d1c2e849c | |||
| cfac19e772 | |||
| ad28dc83c3 | |||
| bcd0acae4f | |||
| 1755678cf9 | |||
| 886025f6b9 | |||
| 7cdfaaa6bd | |||
| ea4dd5ae35 | |||
| 8df9a80cc4 | |||
| e3baad48c8 | |||
| 0ca2e4e2ae | |||
| 30b4dcecbc | |||
| 954835123f |
@@ -2,8 +2,8 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -85,25 +85,17 @@ accessible.
|
||||
|
||||
- **Callouts**: Use GitHub-flavored markdown alerts to highlight important
|
||||
information. To ensure the formatting is preserved by `npm run format`, place
|
||||
an empty line, then a prettier ignore comment directly before the callout
|
||||
block. Use `<!-- prettier-ignore -->` for standard Markdown files (`.md`) and
|
||||
`{/* prettier-ignore */}` for MDX files (`.mdx`). The callout type (`[!TYPE]`)
|
||||
should be on the first line, followed by a newline, and then the content, with
|
||||
each subsequent line of content starting with `>`. Available types are `NOTE`,
|
||||
`TIP`, `IMPORTANT`, `WARNING`, and `CAUTION`.
|
||||
an empty line, then the `<!-- prettier-ignore -->` comment directly before
|
||||
the callout block. The callout type (`[!TYPE]`) should be on the first line,
|
||||
followed by a newline, and then the content, with each subsequent line of
|
||||
content starting with `>`. Available types are `NOTE`, `TIP`, `IMPORTANT`,
|
||||
`WARNING`, and `CAUTION`.
|
||||
|
||||
Example (.md):
|
||||
Example:
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an example of a multi-line note that will be preserved
|
||||
> by Prettier.
|
||||
|
||||
Example (.mdx):
|
||||
|
||||
{/* prettier-ignore */}
|
||||
> [!NOTE]
|
||||
> This is an example of a multi-line note that will be preserved
|
||||
> by Prettier.
|
||||
|
||||
### Links
|
||||
@@ -126,7 +118,6 @@ accessible.
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development.
|
||||
(Note: Use `{/* prettier-ignore */}` if editing an `.mdx` file.)
|
||||
|
||||
- **Headings:** Use hierarchical headings to support the user journey.
|
||||
- **Procedures:**
|
||||
|
||||
@@ -28,7 +28,6 @@ runs:
|
||||
- name: 'Run Tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
echo "::group::Build"
|
||||
|
||||
@@ -98,7 +98,6 @@ runs:
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
|
||||
# We must diable CI mode here because it interferes with interactive tests.
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
|
||||
@@ -167,7 +167,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
VERBOSE: 'true'
|
||||
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
|
||||
@@ -213,7 +212,6 @@ jobs:
|
||||
if: "${{runner.os != 'Windows'}}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
@@ -290,7 +288,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
|
||||
@@ -102,12 +102,6 @@ jobs:
|
||||
- name: 'Run yamllint'
|
||||
run: 'node scripts/lint.js --yamllint'
|
||||
|
||||
- name: 'Build project for typecheck'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Run typecheck'
|
||||
run: 'npm run typecheck'
|
||||
|
||||
- name: 'Run Prettier'
|
||||
run: 'node scripts/lint.js --prettier'
|
||||
|
||||
@@ -179,7 +173,6 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli"
|
||||
@@ -268,7 +261,6 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
|
||||
@@ -432,7 +424,6 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
@@ -106,7 +105,6 @@ jobs:
|
||||
if: "runner.os != 'Windows'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
SANDBOX: 'sandbox:none'
|
||||
@@ -161,7 +159,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
|
||||
@@ -141,7 +141,6 @@ jobs:
|
||||
if: "github.event_name != 'pull_request'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
echo "Running integration tests with binary..."
|
||||
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
|
||||
|
||||
@@ -22,4 +22,3 @@ Thumbs.db
|
||||
.pytest_cache
|
||||
**/SKILL.md
|
||||
packages/sdk/test-data/*.json
|
||||
*.mdx
|
||||
|
||||
@@ -371,8 +371,6 @@ for planned features and priorities.
|
||||
|
||||
## 📖 Resources
|
||||
|
||||
- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
|
||||
Learn the basics.
|
||||
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
|
||||
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
|
||||
notable updates.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.38.2
|
||||
# Latest stable release: v0.38.1
|
||||
|
||||
Released: April 17, 2026
|
||||
Released: April 15, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,9 +29,6 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
|
||||
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
|
||||
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
|
||||
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
|
||||
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
|
||||
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
|
||||
@@ -271,4 +268,4 @@ npm install -g @google/gemini-cli
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# Auto Memory
|
||||
|
||||
Auto Memory is an experimental feature that mines your past Gemini CLI sessions
|
||||
in the background and turns recurring workflows into reusable
|
||||
[Agent Skills](./skills.md). You review, accept, or discard each extracted skill
|
||||
before it becomes available to future sessions.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
Every session you run with Gemini CLI is recorded locally as a transcript. Auto
|
||||
Memory scans those transcripts for procedural patterns that recur across
|
||||
sessions, then drafts each pattern as a `SKILL.md` file in a project-local
|
||||
inbox. You inspect the draft, decide whether it captures real expertise, and
|
||||
promote it to your global or workspace skills directory if you want it.
|
||||
|
||||
You'll use Auto Memory when you want to:
|
||||
|
||||
- **Capture team workflows** that you find yourself walking the agent through
|
||||
more than once.
|
||||
- **Codify hard-won fixes** for project-specific landmines so future sessions
|
||||
avoid them.
|
||||
- **Bootstrap a skills library** without writing every `SKILL.md` by hand.
|
||||
|
||||
Auto Memory complements—but does not replace—the
|
||||
[`save_memory` tool](../tools/memory.md), which captures single facts into
|
||||
`GEMINI.md`. Auto Memory captures multi-step procedures into skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gemini CLI installed and authenticated.
|
||||
- At least 10 user messages across recent, idle sessions in the project. Auto
|
||||
Memory ignores active or trivial sessions.
|
||||
|
||||
## How to enable Auto Memory
|
||||
|
||||
Auto Memory is off by default. Enable it in your settings file:
|
||||
|
||||
1. Open your global settings file at `~/.gemini/settings.json`. If you only
|
||||
want Auto Memory in one project, edit `.gemini/settings.json` in that
|
||||
project instead.
|
||||
|
||||
2. Add the experimental flag:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"autoMemory": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Restart Gemini CLI. The flag requires a restart because the extraction
|
||||
service starts during session boot.
|
||||
|
||||
## How Auto Memory works
|
||||
|
||||
Auto Memory runs as a background task on session startup. It does not block the
|
||||
UI, consume your interactive turns, or surface tool prompts.
|
||||
|
||||
1. **Eligibility scan.** The service indexes recent sessions from
|
||||
`~/.gemini/tmp/<project>/chats/`. Sessions are eligible only if they have
|
||||
been idle for at least three hours and contain at least 10 user messages.
|
||||
2. **Lock acquisition.** A lock file in the project's memory directory
|
||||
coordinates across multiple CLI instances so extraction runs at most once at
|
||||
a time.
|
||||
3. **Sub-agent extraction.** A specialized sub-agent (named `confucius`)
|
||||
reviews the session index, reads any sessions that look like they contain
|
||||
repeated procedural workflows, and drafts new `SKILL.md` files. Its
|
||||
instructions tell it to default to creating zero skills unless the evidence
|
||||
is strong, so most runs produce no inbox items.
|
||||
4. **Patch validation.** If the sub-agent proposes edits to skills outside the
|
||||
inbox (for example, an existing global skill), it writes a unified diff
|
||||
`.patch` file. Auto Memory dry-runs each patch and discards any that do not
|
||||
apply cleanly.
|
||||
5. **Notification.** When a run produces new skills or patches, Gemini CLI
|
||||
surfaces an inline message telling you how many items are waiting.
|
||||
|
||||
## How to review extracted skills
|
||||
|
||||
Use the `/memory inbox` slash command to open the inbox dialog at any time:
|
||||
|
||||
**Command:** `/memory inbox`
|
||||
|
||||
The dialog lists each draft skill with its name, description, and source
|
||||
sessions. From there you can:
|
||||
|
||||
- **Read** the full `SKILL.md` body before deciding.
|
||||
- **Promote** a skill to your user (`~/.gemini/skills/`) or workspace
|
||||
(`.gemini/skills/`) directory.
|
||||
- **Discard** a skill you do not want.
|
||||
- **Apply** or reject a `.patch` proposal against an existing skill.
|
||||
|
||||
Promoted skills become discoverable in the next session and follow the standard
|
||||
[skill discovery precedence](./skills.md#skill-discovery-tiers).
|
||||
|
||||
## How to disable Auto Memory
|
||||
|
||||
To turn off background extraction, set the flag back to `false` in your settings
|
||||
file and restart Gemini CLI:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"autoMemory": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disabling the flag stops the background service immediately on the next session
|
||||
start. Existing inbox items remain on disk; you can either drain them with
|
||||
`/memory inbox` first or remove the project memory directory manually.
|
||||
|
||||
## Data and privacy
|
||||
|
||||
- Auto Memory only reads session files that already exist locally on your
|
||||
machine. Nothing is uploaded to Gemini outside the normal API calls the
|
||||
extraction sub-agent makes during its run.
|
||||
- The sub-agent is instructed to redact secrets, tokens, and credentials it
|
||||
encounters and to never copy large tool outputs verbatim.
|
||||
- Drafted skills live in your project's memory directory until you promote or
|
||||
discard them. They are not automatically loaded into any session.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The sub-agent runs on a preview Gemini Flash model. Extraction quality depends
|
||||
on the model's ability to recognize durable patterns versus one-off incidents.
|
||||
- Auto Memory does not extract skills from the current session. It only
|
||||
considers sessions that have been idle for three hours or more.
|
||||
- Inbox items are stored per project. Skills extracted in one workspace are not
|
||||
visible from another until you promote them to the user-scope skills
|
||||
directory.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Learn how skills are discovered and activated in [Agent Skills](./skills.md).
|
||||
- Explore the [memory management tutorial](./tutorials/memory-management.md) for
|
||||
the complementary `save_memory` and `GEMINI.md` workflows.
|
||||
- Review the experimental settings catalog in
|
||||
[Settings](./settings.md#experimental).
|
||||
@@ -52,7 +52,6 @@ These commands are available within the interactive REPL.
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
|
||||
| `--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.** |
|
||||
|
||||
@@ -507,7 +507,7 @@ events. For more information, see the [telemetry documentation](./telemetry.md).
|
||||
You can enforce a specific authentication method for all users by setting the
|
||||
`security.auth.enforcedType` in the system-level `settings.json` file. This
|
||||
prevents users from choosing a different authentication method. See the
|
||||
[Authentication docs](../get-started/authentication.mdx) for more details.
|
||||
[Authentication docs](../get-started/authentication.md) for more details.
|
||||
|
||||
**Example:** Enforce the use of Google login for all users.
|
||||
|
||||
|
||||
@@ -130,9 +130,7 @@ These are the only allowed tools:
|
||||
[`cli_help`](../core/subagents.md#cli-help-agent)
|
||||
- **Interaction:** [`ask_user`](../tools/ask-user.md)
|
||||
- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
|
||||
example, `github_read_issue`, `postgres_read_schema`) and core
|
||||
[MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`,
|
||||
`read_mcp_resource`) are allowed.
|
||||
example, `github_read_issue`, `postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):**
|
||||
[`write_file`](../tools/file-system.md#3-write_file-writefile) and
|
||||
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
|
||||
@@ -331,6 +329,7 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
|
||||
#!/usr/bin/env bash
|
||||
# Extract the plan filename from the tool input JSON
|
||||
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
|
||||
plan_filename=$(basename -- "$plan_filename")
|
||||
|
||||
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
|
||||
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
|
||||
@@ -359,7 +358,7 @@ To register this `AfterTool` hook, add it to your `settings.json`:
|
||||
{
|
||||
"name": "archive-plan",
|
||||
"type": "command",
|
||||
"command": "~/.gemini/hooks/archive-plan.sh"
|
||||
"command": "./.gemini/hooks/archive-plan.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+25
-30
@@ -24,22 +24,20 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Terminal Notifications | `general.enableNotifications` | Enable terminal run-event notifications for action-required prompts and session completion. | `false` |
|
||||
| Terminal Notification Method | `general.notificationMethod` | How to send terminal notifications. | `"auto"` |
|
||||
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
|
||||
| Topic & Update Narration | `general.topicUpdateNarration` | Enable the Topic & Update communication model for reduced chattiness and structured progress reporting. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
|
||||
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -161,20 +159,17 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ project-specific behavior or create a customized persona.
|
||||
|
||||
You can set the environment variable temporarily in your shell, or persist it
|
||||
via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.mdx#persisting-environment-variables).
|
||||
[Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
@@ -51,7 +51,7 @@ error with: `missing system prompt file '<path>'`.
|
||||
- Create `.gemini/system.md`, then add to `.gemini/.env`:
|
||||
- `GEMINI_SYSTEM_MD=1`
|
||||
- Use a custom file under your home directory:
|
||||
- `GEMINI_SYSTEM_MD=~/prompts/system.md gemini`
|
||||
- `GEMINI_SYSTEM_MD=~/prompts/SYSTEM.md gemini`
|
||||
|
||||
## UI indicator
|
||||
|
||||
@@ -102,17 +102,17 @@ safety and workflow rules.
|
||||
|
||||
This creates the file and writes the current built‑in system prompt to it.
|
||||
|
||||
## Best practices: system.md vs GEMINI.md
|
||||
## Best practices: SYSTEM.md vs GEMINI.md
|
||||
|
||||
- system.md (firmware):
|
||||
- SYSTEM.md (firmware):
|
||||
- Non‑negotiable operational rules: safety, tool‑use protocols, approvals, and
|
||||
mechanics that keep the CLI reliable.
|
||||
- Stable across tasks and projects (or per project when needed).
|
||||
- GEMINI.md (strategy):
|
||||
- Persona, goals, methodologies, and project/domain context.
|
||||
- Evolves per task; relies on system.md for safe execution.
|
||||
- Evolves per task; relies on SYSTEM.md for safe execution.
|
||||
|
||||
Keep system.md minimal but complete for safety and tool operation. Keep
|
||||
Keep SYSTEM.md minimal but complete for safety and tool operation. Keep
|
||||
GEMINI.md focused on high‑level guidance and project specifics.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
+11
-18
@@ -35,18 +35,17 @@ The observability system provides:
|
||||
You control telemetry behavior through the `.gemini/settings.json` file.
|
||||
Environment variables can override these settings.
|
||||
|
||||
| Setting | Environment Variable | Description | Values | Default |
|
||||
| -------------- | --------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
|
||||
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
|
||||
| `traces` | `GEMINI_TELEMETRY_TRACES_ENABLED` | Enable detailed attribute tracing | `true`/`false` | `false` |
|
||||
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
|
||||
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
|
||||
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
|
||||
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
|
||||
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
|
||||
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
|
||||
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
|
||||
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
|
||||
| Setting | Environment Variable | Description | Values | Default |
|
||||
| -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
|
||||
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
|
||||
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
|
||||
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
|
||||
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
|
||||
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
|
||||
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
|
||||
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
|
||||
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
|
||||
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
|
||||
|
||||
**Note on boolean environment variables:** For boolean settings like `enabled`,
|
||||
setting the environment variable to `true` or `1` enables the feature.
|
||||
@@ -1236,12 +1235,6 @@ These metrics follow standard [OpenTelemetry GenAI semantic conventions].
|
||||
Traces provide an "under-the-hood" view of agent and backend operations. Use
|
||||
traces to debug tool interactions and optimize performance.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> Detailed trace attributes (like full prompts and tool outputs) are disabled by default
|
||||
> to minimize overhead. You must explicitly set `telemetry.traces` to `true` (or set
|
||||
> `GEMINI_TELEMETRY_TRACES_ENABLED=true`) to capture them.
|
||||
|
||||
Every trace captures rich metadata via standard span attributes.
|
||||
|
||||
<details open>
|
||||
|
||||
@@ -100,34 +100,6 @@ protect you. In this mode, the following features are disabled:
|
||||
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
|
||||
workspace.
|
||||
|
||||
## Headless and automated environments
|
||||
|
||||
When running Gemini CLI in a headless environment (for example, a CI/CD
|
||||
pipeline) where interactive prompts are not possible, the trust dialog cannot be
|
||||
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
|
||||
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
|
||||
|
||||
To proceed in these environments, you can bypass the trust check using one of
|
||||
the following methods:
|
||||
|
||||
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
|
||||
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
|
||||
environment variable.
|
||||
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
## Overriding the trust file location
|
||||
|
||||
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
|
||||
need to store this file in a different location, you can set the
|
||||
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
|
||||
file path.
|
||||
|
||||
## Managing your trust settings
|
||||
|
||||
If you need to change a decision or see all your settings, you have a couple of
|
||||
|
||||
@@ -124,5 +124,3 @@ immediately. Force a reload with:
|
||||
- Explore the [Command reference](../../reference/commands.md) for more
|
||||
`/memory` options.
|
||||
- Read the technical spec for [Project context](../../cli/gemini-md.md).
|
||||
- Try the experimental [Auto Memory](../auto-memory.md) feature to extract
|
||||
reusable skills from your past sessions automatically.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
# Gemini CLI authentication setup
|
||||
|
||||
To use Gemini CLI, you'll need to authenticate with Google. This guide helps you
|
||||
quickly find the best way to sign in based on your account type and how you're
|
||||
using the CLI.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!TIP]
|
||||
> Looking for a high-level comparison of all available subscriptions?
|
||||
> To compare features and find the right quota for your needs, see our
|
||||
@@ -24,7 +23,7 @@ Select the authentication method that matches your situation in the table below:
|
||||
| Organization users with a company, school, or Google Workspace account | [Sign in with Google](#login-google) | [Yes](#set-gcp) |
|
||||
| AI Studio user with a Gemini API key | [Use Gemini API Key](#gemini-api) | No |
|
||||
| Google Cloud Vertex AI user | [Vertex AI](#vertex-ai) | [Yes](#set-gcp) |
|
||||
| [Headless mode](#headless) | [Use Gemini API Key](#gemini-api) or<br /> [Vertex AI](#vertex-ai) | No (for Gemini API Key)<br /> [Yes](#set-gcp) (for Vertex AI) |
|
||||
| [Headless mode](#headless) | [Use Gemini API Key](#gemini-api) or<br> [Vertex AI](#vertex-ai) | No (for Gemini API Key)<br> [Yes](#set-gcp) (for Vertex AI) |
|
||||
|
||||
### What is my Google account type?
|
||||
|
||||
@@ -85,24 +84,19 @@ To authenticate and use Gemini CLI with a Gemini API key:
|
||||
|
||||
2. Set the `GEMINI_API_KEY` environment variable to your key. For example:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
# Replace YOUR_GEMINI_API_KEY with the key from AI Studio
|
||||
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
```bash
|
||||
# Replace YOUR_GEMINI_API_KEY with the key from AI Studio
|
||||
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
# Replace YOUR_GEMINI_API_KEY with the key from AI Studio
|
||||
$env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
# Replace YOUR_GEMINI_API_KEY with the key from AI Studio
|
||||
$env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
|
||||
```
|
||||
|
||||
To make this setting persistent, see
|
||||
[Persisting Environment Variables](#persisting-vars).
|
||||
@@ -115,6 +109,7 @@ To authenticate and use Gemini CLI with a Gemini API key:
|
||||
|
||||
4. Select **Use Gemini API key**.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> Treat API keys, especially for services like Gemini, as sensitive
|
||||
> credentials. Protect them to prevent unauthorized access and potential misuse
|
||||
@@ -136,26 +131,21 @@ or the location where you want to run your jobs.
|
||||
|
||||
For example:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
```bash
|
||||
# Replace with your project ID and desired location (for example, us-central1)
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"
|
||||
```
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
```powershell
|
||||
# Replace with your project ID and desired location (for example, us-central1)
|
||||
$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
To make any Vertex AI environment variable settings persistent, see
|
||||
[Persisting Environment Variables](#persisting-vars).
|
||||
@@ -167,22 +157,17 @@ Consider this authentication method if you have Google Cloud CLI installed.
|
||||
If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset
|
||||
them to use ADC.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
```bash
|
||||
unset GOOGLE_API_KEY GEMINI_API_KEY
|
||||
```
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
```powershell
|
||||
Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
1. Verify you have a Google Cloud project and Vertex AI API is enabled.
|
||||
|
||||
@@ -210,22 +195,17 @@ pipelines, or if your organization restricts user-based ADC or API key creation.
|
||||
If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset
|
||||
them:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
```bash
|
||||
unset GOOGLE_API_KEY GEMINI_API_KEY
|
||||
```
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
```powershell
|
||||
Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete)
|
||||
and download the provided JSON file. Assign the "Vertex AI User" role to the
|
||||
@@ -234,24 +214,19 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
2. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the JSON
|
||||
file's absolute path. For example:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
# Replace /path/to/your/keyfile.json with the actual path
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json"
|
||||
```
|
||||
```bash
|
||||
# Replace /path/to/your/keyfile.json with the actual path
|
||||
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
# Replace C:\path\to\your\keyfile.json with the actual path
|
||||
$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
# Replace C:\path\to\your\keyfile.json with the actual path
|
||||
$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json"
|
||||
```
|
||||
|
||||
3. [Configure your Google Cloud Project](#set-gcp).
|
||||
|
||||
@@ -263,6 +238,7 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
|
||||
5. Select **Vertex AI**.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> Protect your service account key file as it gives access to
|
||||
> your resources.
|
||||
@@ -274,24 +250,19 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
|
||||
2. Set the `GOOGLE_API_KEY` environment variable:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
# Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key
|
||||
export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
```bash
|
||||
# Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key
|
||||
export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
# Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key
|
||||
$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
# Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key
|
||||
$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
|
||||
```
|
||||
|
||||
If you see errors like `"API keys are not supported by this API..."`, your
|
||||
organization might restrict API key usage for this service. Try the other
|
||||
@@ -309,6 +280,7 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore
|
||||
|
||||
## Set your Google Cloud project <a id="set-gcp"></a>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!IMPORTANT]
|
||||
> Most individual Google accounts (free and paid) don't require a
|
||||
> Google Cloud project for authentication.
|
||||
@@ -336,24 +308,19 @@ To configure Gemini CLI to use a Google Cloud project, do the following:
|
||||
|
||||
For example, to set the `GOOGLE_CLOUD_PROJECT_ID` variable:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
# Replace YOUR_PROJECT_ID with your actual Google Cloud project ID
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
```bash
|
||||
# Replace YOUR_PROJECT_ID with your actual Google Cloud project ID
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
# Replace YOUR_PROJECT_ID with your actual Google Cloud project ID
|
||||
$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
# Replace YOUR_PROJECT_ID with your actual Google Cloud project ID
|
||||
$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
|
||||
To make this setting persistent, see
|
||||
[Persisting Environment Variables](#persisting-vars).
|
||||
@@ -366,29 +333,21 @@ persist them with the following methods:
|
||||
1. **Add your environment variables to your shell configuration file:** Append
|
||||
the environment variable commands to your shell's startup file.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`):
|
||||
|
||||
(for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`):
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
**Windows (PowerShell)** (for example, `$PROFILE`):
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
|
||||
(for example, `$PROFILE`):
|
||||
|
||||
```powershell
|
||||
Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"'
|
||||
. $PROFILE
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"'
|
||||
. $PROFILE
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> Be aware that when you export API keys or service account
|
||||
> paths in your shell configuration file, any process launched from that
|
||||
@@ -402,30 +361,25 @@ persist them with the following methods:
|
||||
|
||||
Example for user-wide settings:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="macOS/Linux">
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gemini
|
||||
cat >> ~/.gemini/.env <<'EOF'
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
# Add other variables like GEMINI_API_KEY as needed
|
||||
EOF
|
||||
```
|
||||
```bash
|
||||
mkdir -p ~/.gemini
|
||||
cat >> ~/.gemini/.env <<'EOF'
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
# Add other variables like GEMINI_API_KEY as needed
|
||||
EOF
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows (PowerShell)">
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini"
|
||||
@"
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
# Add other variables like GEMINI_API_KEY as needed
|
||||
"@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini"
|
||||
@"
|
||||
GOOGLE_CLOUD_PROJECT="your-project-id"
|
||||
# Add other variables like GEMINI_API_KEY as needed
|
||||
"@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append
|
||||
```
|
||||
|
||||
Variables are loaded from the first file found, not merged.
|
||||
|
||||
@@ -24,8 +24,7 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
|
||||
gemini
|
||||
```
|
||||
|
||||
For more installation options, see
|
||||
[Gemini CLI Installation](./installation.mdx).
|
||||
For more installation options, see [Gemini CLI Installation](./installation.md).
|
||||
|
||||
## Authenticate
|
||||
|
||||
@@ -47,7 +46,7 @@ cases, you can log in with your existing Google account:
|
||||
|
||||
Certain account types may require you to configure a Google Cloud project. For
|
||||
more information, including other authentication methods, see
|
||||
[Gemini CLI Authentication Setup](./authentication.mdx).
|
||||
[Gemini CLI Authentication Setup](./authentication.md).
|
||||
|
||||
## Configure
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Gemini CLI installation, execution, and releases
|
||||
|
||||
This document provides an overview of Gemini CLI's system requirements,
|
||||
installation methods, and release types.
|
||||
|
||||
## Recommended system specifications
|
||||
|
||||
- **Operating System:**
|
||||
- macOS 15+
|
||||
- Windows 11 24H2+
|
||||
- Ubuntu 20.04+
|
||||
- **Hardware:**
|
||||
- "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits)
|
||||
- "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context)
|
||||
- **Runtime:** Node.js 20.0.0+
|
||||
- **Shell:** Bash, Zsh, or PowerShell
|
||||
- **Location:**
|
||||
[Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- **Internet connection required**
|
||||
|
||||
## Install Gemini CLI
|
||||
|
||||
We recommend most users install Gemini CLI using one of the following
|
||||
installation methods:
|
||||
|
||||
- npm
|
||||
- Homebrew
|
||||
- MacPorts
|
||||
- Anaconda
|
||||
|
||||
Note that Gemini CLI comes pre-installed on
|
||||
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
|
||||
[**Cloud Workstations**](https://cloud.google.com/workstations).
|
||||
|
||||
### Install globally with npm
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
### Install globally with Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Run Gemini CLI
|
||||
|
||||
For most users, we recommend running Gemini CLI with the `gemini` command:
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
For a list of options and additional commands, see the
|
||||
[CLI cheatsheet](../cli/cli-reference.md).
|
||||
|
||||
You can also run Gemini CLI using one of the following advanced methods:
|
||||
|
||||
- Run instantly with npx. You can run Gemini CLI without permanent installation.
|
||||
- In a sandbox. This method offers increased security and isolation.
|
||||
- From the source. This is recommended for contributors to the project.
|
||||
|
||||
### Run instantly with npx
|
||||
|
||||
```bash
|
||||
# Using npx (no installation required)
|
||||
npx @google/gemini-cli
|
||||
```
|
||||
|
||||
You can also execute the CLI directly from the main branch on GitHub, which is
|
||||
helpful for testing features still in development:
|
||||
|
||||
```bash
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
```
|
||||
|
||||
### 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"
|
||||
```
|
||||
|
||||
### 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 mode (React optimizations):** This method runs the CLI with React
|
||||
production mode enabled, which is useful for testing performance without
|
||||
development overhead.
|
||||
```bash
|
||||
# From the root of the repository
|
||||
npm run start:prod
|
||||
```
|
||||
- **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
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Gemini CLI has three release channels: nightly, preview, and stable. For most
|
||||
users, we recommend the stable release, which is the default installation.
|
||||
|
||||
### Stable
|
||||
|
||||
New stable releases are published each week. The stable release is the promotion
|
||||
of last week's `preview` release along with any bug fixes. The stable release
|
||||
uses `latest` tag, but omitting the tag also installs the latest stable release
|
||||
by default:
|
||||
|
||||
```bash
|
||||
# Both commands install the latest stable release.
|
||||
npm install -g @google/gemini-cli
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
### Preview
|
||||
|
||||
New preview releases will be published each week. These releases are not fully
|
||||
vetted and may contain regressions or other outstanding issues. Try out the
|
||||
preview release by using the `preview` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
### Nightly
|
||||
|
||||
Nightly releases are published every day. The nightly release includes all
|
||||
changes from the main branch at time of release. It should be assumed there are
|
||||
pending validations and issues. You can help test the latest changes by
|
||||
installing with the `nightly` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
# Gemini CLI installation, execution, and releases
|
||||
|
||||
This document provides an overview of Gemini CLI's system requirements,
|
||||
installation methods, and release types.
|
||||
|
||||
## Recommended system specifications
|
||||
|
||||
- **Operating System:**
|
||||
- macOS 15+
|
||||
- Windows 11 24H2+
|
||||
- Ubuntu 20.04+
|
||||
- **Hardware:**
|
||||
- "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits)
|
||||
- "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context)
|
||||
- **Runtime:** Node.js 20.0.0+
|
||||
- **Shell:** Bash, Zsh, or PowerShell
|
||||
- **Location:**
|
||||
[Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- **Internet connection required**
|
||||
|
||||
## Install Gemini CLI
|
||||
|
||||
We recommend most users install Gemini CLI using one of the following
|
||||
installation methods. Note that Gemini CLI comes pre-installed on
|
||||
[**Cloud Shell**](https://docs.cloud.google.com/shell/docs) and
|
||||
[**Cloud Workstations**](https://cloud.google.com/workstations).
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="npm">
|
||||
|
||||
Install globally with npm:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Homebrew (macOS/Linux)">
|
||||
|
||||
Install globally with Homebrew:
|
||||
|
||||
```bash
|
||||
brew install gemini-cli
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="MacPorts (macOS)">
|
||||
|
||||
Install globally with MacPorts:
|
||||
|
||||
```bash
|
||||
sudo port install gemini-cli
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Anaconda">
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Run Gemini CLI
|
||||
|
||||
For most users, we recommend running Gemini CLI with the `gemini` command:
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
For a list of options and additional commands, see the
|
||||
[CLI cheatsheet](../cli/cli-reference.md).
|
||||
|
||||
You can also run Gemini CLI using one of the following advanced methods:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="npx">
|
||||
|
||||
Run instantly with npx. You can run Gemini CLI without permanent installation.
|
||||
|
||||
```bash
|
||||
# Using npx (no installation required)
|
||||
npx @google/gemini-cli
|
||||
```
|
||||
|
||||
You can also execute the CLI directly from the main branch on GitHub, which is
|
||||
helpful for testing features still in development:
|
||||
|
||||
```bash
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Docker/Podman Sandbox">
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="From source">
|
||||
|
||||
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 mode (React optimizations):** This method runs the CLI with React
|
||||
production mode enabled, which is useful for testing performance without
|
||||
development overhead.
|
||||
```bash
|
||||
# From the root of the repository
|
||||
npm run start:prod
|
||||
```
|
||||
- **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
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Releases
|
||||
|
||||
Gemini CLI has three release channels: stable, preview, and nightly. For most
|
||||
users, we recommend the stable release, which is the default installation.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Stable">
|
||||
|
||||
Stable releases are published each week. A stable release is created from the
|
||||
previous week's preview release along with any bug fixes. The stable release
|
||||
uses the `latest` tag. Omitting the tag also installs the latest stable
|
||||
release by default.
|
||||
|
||||
```bash
|
||||
# Both commands install the latest stable release.
|
||||
npm install -g @google/gemini-cli
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Preview">
|
||||
|
||||
New preview releases will be published each week. These releases are not fully
|
||||
vetted and may contain regressions or other outstanding issues. Try out the
|
||||
preview release by using the `preview` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Nightly">
|
||||
|
||||
Nightly releases are published every day. The nightly release includes all
|
||||
changes from the main branch at time of release. It should be assumed there are
|
||||
pending validations and issues. You can help test the latest changes by
|
||||
installing with the `nightly` tag:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
+2
-2
@@ -15,9 +15,9 @@ npm install -g @google/gemini-cli
|
||||
Jump in to Gemini CLI.
|
||||
|
||||
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
|
||||
- **[Installation](./get-started/installation.mdx):** How to install Gemini CLI
|
||||
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
|
||||
on your system.
|
||||
- **[Authentication](./get-started/authentication.mdx):** Setup instructions for
|
||||
- **[Authentication](./get-started/authentication.md):** Setup instructions for
|
||||
personal and enterprise accounts.
|
||||
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
commands and options.
|
||||
|
||||
+12
-132
@@ -134,15 +134,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.enableNotifications`** (boolean):
|
||||
- **Description:** Enable terminal run-event notifications for action-required
|
||||
prompts and session completion.
|
||||
- **Description:** Enable run-event notifications for action-required prompts
|
||||
and session completion.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.notificationMethod`** (enum):
|
||||
- **Description:** How to send terminal notifications.
|
||||
- **Default:** `"auto"`
|
||||
- **Values:** `"auto"`, `"osc9"`, `"osc777"`, `"bell"`
|
||||
|
||||
- **`general.checkpointing.enabled`** (boolean):
|
||||
- **Description:** Enable session checkpointing for recovery
|
||||
- **Default:** `false`
|
||||
@@ -198,11 +193,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Minimum retention period (safety limit, defaults to "1d")
|
||||
- **Default:** `"1d"`
|
||||
|
||||
- **`general.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the Topic & Update communication model for reduced
|
||||
chattiness and structured progress reporting.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `output`
|
||||
|
||||
- **`output.format`** (enum):
|
||||
@@ -436,20 +426,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"ask"`
|
||||
- **Values:** `"ask"`, `"always"`, `"never"`
|
||||
|
||||
- **`billing.vertexAi.requestType`** (enum):
|
||||
- **Description:** Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI
|
||||
requests.
|
||||
- **Default:** `undefined`
|
||||
- **Values:** `"dedicated"`, `"shared"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`billing.vertexAi.sharedRequestType`** (enum):
|
||||
- **Description:** Sets the X-Vertex-AI-LLM-Shared-Request-Type header for
|
||||
Vertex AI requests.
|
||||
- **Default:** `undefined`
|
||||
- **Values:** `"priority"`, `"flex"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `model`
|
||||
|
||||
- **`model.name`** (string):
|
||||
@@ -563,18 +539,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash-lite"
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-31b-it"
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-26b-a4b-it"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -846,28 +810,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"displayName": "gemma-4-31b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"displayName": "gemma-4-26b-a4b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
@@ -938,12 +880,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
```json
|
||||
{
|
||||
"gemma-4-31b-it": {
|
||||
"default": "gemma-4-31b-it"
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"default": "gemma-4-26b-a4b-it"
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
"default": "gemini-3.1-pro-preview",
|
||||
"contexts": [
|
||||
@@ -1413,12 +1349,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.fileFiltering.enableFileWatcher`** (boolean):
|
||||
- **Description:** Enable file watcher updates for @ file suggestions
|
||||
(experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
|
||||
- **Description:** Enable recursive file search functionality when completing
|
||||
@ references in the prompt.
|
||||
@@ -1507,12 +1437,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.confirmationRequired`** (array):
|
||||
- **Description:** Tool names that always require user confirmation. Takes
|
||||
precedence over allowed tools and core tool allowlists.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.exclude`** (array):
|
||||
- **Description:** Tool names to exclude from discovery.
|
||||
- **Default:** `undefined`
|
||||
@@ -1686,11 +1610,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.gemma`** (boolean):
|
||||
- **Description:** Enable access to Gemma 4 models (experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
|
||||
- **Description:** Enable non-interactive agent sessions.
|
||||
- **Default:** `false`
|
||||
@@ -1739,10 +1658,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.jitContext`** (boolean):
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading. Defaults to
|
||||
true; set to false to opt out and load all GEMINI.md files into the system
|
||||
instruction up-front.
|
||||
- **Default:** `true`
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
@@ -1784,18 +1701,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.autoStartServer`** (boolean):
|
||||
- **Description:** Automatically start the LiteRT-LM server when Gemini CLI
|
||||
starts and the Gemma router is enabled.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.binaryPath`** (string):
|
||||
- **Description:** Custom path to the LiteRT-LM binary. Leave empty to use the
|
||||
default location (~/.gemini/bin/litert/).
|
||||
- **Default:** `""`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.classifier.host`** (string):
|
||||
- **Description:** The host of the classifier.
|
||||
- **Default:** `"http://localhost:9379"`
|
||||
@@ -1807,22 +1712,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"gemma3-1b-gpu-custom"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.memoryV2`** (boolean):
|
||||
- **Description:** Disable the built-in save_memory tool and let the main
|
||||
agent persist project context by editing markdown files directly with
|
||||
edit/write_file. Route facts across four tiers: team-shared conventions go
|
||||
to project GEMINI.md files, project-specific personal notes go to the
|
||||
per-project private memory folder (MEMORY.md as index + sibling .md files
|
||||
for detail), and cross-project personal preferences go to the global
|
||||
~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit
|
||||
— settings, credentials, etc. remain off-limits). Set to false to fall back
|
||||
to the legacy save_memory tool.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
- **Description:** Automatically extract reusable skills from past sessions in
|
||||
the background. Review results with /memory inbox.
|
||||
- **`experimental.memoryManager`** (boolean):
|
||||
- **Description:** Replace the built-in save_memory tool with a memory manager
|
||||
subagent that supports adding, removing, de-duplicating, and organizing
|
||||
memories.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -1837,7 +1730,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Deprecated: Use general.topicUpdateNarration instead.
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `skills`
|
||||
@@ -2077,8 +1971,6 @@ see [Telemetry](../cli/telemetry.md).
|
||||
|
||||
- **Properties:**
|
||||
- **`enabled`** (boolean): Whether or not telemetry is enabled.
|
||||
- **`traces`** (boolean): Whether detailed traces with large attributes (like
|
||||
tool outputs and file reads) are captured. Defaults to `false`.
|
||||
- **`target`** (string): The destination for collected telemetry. Supported
|
||||
values are `local` and `gcp`.
|
||||
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
|
||||
@@ -2178,7 +2070,7 @@ within your user's home folder.
|
||||
Environment variables are a common way to configure applications, especially for
|
||||
sensitive information like API keys or for settings that might change between
|
||||
environments. For authentication setup, see the
|
||||
[Authentication documentation](../get-started/authentication.mdx) which covers
|
||||
[Authentication documentation](../get-started/authentication.md) which covers
|
||||
all available authentication methods.
|
||||
|
||||
The CLI automatically loads environment variables from an `.env` file. The
|
||||
@@ -2199,7 +2091,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- **`GEMINI_API_KEY`**:
|
||||
- Your API key for the Gemini API.
|
||||
- One of several available
|
||||
[authentication methods](../get-started/authentication.mdx).
|
||||
[authentication methods](../get-started/authentication.md).
|
||||
- Set this in your shell profile (for example, `~/.bashrc`, `~/.zshrc`) or an
|
||||
`.env` file.
|
||||
- **`GEMINI_MODEL`**:
|
||||
@@ -2207,14 +2099,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
|
||||
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
|
||||
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
|
||||
- If set to `"true"`, trusts the current workspace for the duration of the
|
||||
session, bypassing the folder trust check.
|
||||
- Useful for headless environments (for example, CI/CD pipelines).
|
||||
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
|
||||
- Overrides the default location for the `trustedFolders.json` file.
|
||||
- Useful if you want to store this configuration in a custom location instead
|
||||
of the default `~/.gemini/`.
|
||||
- **`GEMINI_CLI_IDE_PID`**:
|
||||
- Manually specifies the PID of the IDE process to use for integration. This
|
||||
is useful when running Gemini CLI in a standalone terminal while still
|
||||
@@ -2287,10 +2171,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Set to `true` or `1` to enable telemetry. Any other value is treated as
|
||||
disabling it.
|
||||
- Overrides the `telemetry.enabled` setting.
|
||||
- **`GEMINI_TELEMETRY_TRACES_ENABLED`**:
|
||||
- Set to `true` or `1` to enable detailed tracing with large attributes. Any
|
||||
other value is treated as disabling it.
|
||||
- Overrides the `telemetry.traces` setting.
|
||||
- **`GEMINI_TELEMETRY_TARGET`**:
|
||||
- Sets the telemetry target (`local` or `gcp`).
|
||||
- Overrides the `telemetry.target` setting.
|
||||
|
||||
@@ -120,12 +120,6 @@ There are three possible decisions a rule can enforce:
|
||||
|
||||
### Priority system and tiers
|
||||
|
||||
> [!WARNING] The **Workspace** tier (project-level policies) is currently
|
||||
> non-functional. Defining policies in a workspace's `.gemini/policies`
|
||||
> directory will not have any effect. See
|
||||
> [issue #18186](https://github.com/google-gemini/gemini-cli/issues/18186). Use
|
||||
> User or Admin policies instead.
|
||||
|
||||
The policy engine uses a sophisticated priority system to resolve conflicts when
|
||||
multiple rules match a single tool call. The core principle is simple: **the
|
||||
rule with the highest priority wins**.
|
||||
@@ -133,13 +127,13 @@ rule with the highest priority wins**.
|
||||
To provide a clear hierarchy, policies are organized into three tiers. Each tier
|
||||
has a designated number that forms the base of the final priority calculation.
|
||||
|
||||
| Tier | Base | Description |
|
||||
| :-------- | :--- | :-------------------------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with Gemini CLI. |
|
||||
| Extension | 2 | Policies defined in extensions. |
|
||||
| Workspace | 3 | **(Currently disabled)** Policies defined in the current workspace's configuration directory. |
|
||||
| User | 4 | Custom policies defined by the user. |
|
||||
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
|
||||
| Tier | Base | Description |
|
||||
| :-------- | :--- | :-------------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with Gemini CLI. |
|
||||
| Extension | 2 | Policies defined in extensions. |
|
||||
| Workspace | 3 | Policies defined in the current workspace's configuration directory. |
|
||||
| User | 4 | Custom policies defined by the user. |
|
||||
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
|
||||
|
||||
Within a TOML policy file, you assign a priority value from **0 to 999**. The
|
||||
engine transforms this into a final priority using the following formula:
|
||||
@@ -220,11 +214,11 @@ User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :------------------------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :---------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
|
||||
@@ -92,28 +92,6 @@ each tool.
|
||||
| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. |
|
||||
| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. |
|
||||
|
||||
### Task Tracker (Experimental)
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development. Enable via `experimental.taskTracker`.
|
||||
|
||||
| Tool | Kind | Description |
|
||||
| :---------------------------------------------- | :------ | :-------------------------------------------------------------------------- |
|
||||
| [`tracker_create_task`](../tools/tracker.md) | `Other` | Creates a new task in the experimental tracker. |
|
||||
| [`tracker_update_task`](../tools/tracker.md) | `Other` | Updates an existing task's status, description, or dependencies. |
|
||||
| [`tracker_get_task`](../tools/tracker.md) | `Other` | Retrieves the full details of a specific task. |
|
||||
| [`tracker_list_tasks`](../tools/tracker.md) | `Other` | Lists tasks in the tracker, optionally filtered by status, type, or parent. |
|
||||
| [`tracker_add_dependency`](../tools/tracker.md) | `Other` | Adds a dependency between two tasks, ensuring topological execution. |
|
||||
| [`tracker_visualize`](../tools/tracker.md) | `Other` | Renders an ASCII tree visualization of the current task graph. |
|
||||
|
||||
### MCP
|
||||
|
||||
| Tool | Kind | Description |
|
||||
| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- |
|
||||
| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. |
|
||||
| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. |
|
||||
|
||||
### Memory
|
||||
|
||||
| Tool | Kind | Description |
|
||||
|
||||
+1
-13
@@ -96,11 +96,6 @@
|
||||
]
|
||||
},
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Auto Memory",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/cli/auto-memory"
|
||||
},
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{
|
||||
@@ -127,14 +122,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "MCP servers",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Resource tools", "slug": "docs/tools/mcp-resources" }
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# MCP resource tools
|
||||
|
||||
MCP resource tools let Gemini CLI discover and retrieve data from contextual
|
||||
resources exposed by Model Context Protocol (MCP) servers.
|
||||
|
||||
## 1. `list_mcp_resources` (ListMcpResources)
|
||||
|
||||
`list_mcp_resources` retrieves a list of all available resources from connected
|
||||
MCP servers. This is primarily a discovery tool that helps the model understand
|
||||
what external data sources are available for reference.
|
||||
|
||||
- **Tool name:** `list_mcp_resources`
|
||||
- **Display name:** List MCP Resources
|
||||
- **Kind:** `Search`
|
||||
- **File:** `list-mcp-resources.ts`
|
||||
- **Parameters:**
|
||||
- `serverName` (string, optional): An optional filter to list resources from a
|
||||
specific server.
|
||||
- **Behavior:**
|
||||
- Iterates through all connected MCP servers.
|
||||
- Fetches the list of resources each server exposes.
|
||||
- Formats the results into a plain-text list of URIs and descriptions.
|
||||
- **Output (`llmContent`):** A formatted list of available resources, including
|
||||
their URI, server name, and optional description.
|
||||
- **Confirmation:** No. This is a read-only discovery tool.
|
||||
|
||||
## 2. `read_mcp_resource` (ReadMcpResource)
|
||||
|
||||
`read_mcp_resource` retrieves the content of a specific resource identified by
|
||||
its URI.
|
||||
|
||||
- **Tool name:** `read_mcp_resource`
|
||||
- **Display name:** Read MCP Resource
|
||||
- **Kind:** `Read`
|
||||
- **File:** `read-mcp-resource.ts`
|
||||
- **Parameters:**
|
||||
- `uri` (string, required): The URI of the MCP resource to read.
|
||||
- **Behavior:**
|
||||
- Locates the resource and its associated server by URI.
|
||||
- Calls the server's `resources/read` method.
|
||||
- Processes the response, extracting text or binary data.
|
||||
- **Output (`llmContent`):** The content of the resource. For binary data, it
|
||||
returns a placeholder indicating the data type.
|
||||
- **Confirmation:** No. This is a read-only retrieval tool.
|
||||
@@ -64,8 +64,7 @@ Gemini CLI supports three MCP transport types:
|
||||
|
||||
Some MCP servers expose contextual “resources” in addition to the tools and
|
||||
prompts. Gemini CLI discovers these automatically and gives you the possibility
|
||||
to reference them in the chat. For more information on the tools used to
|
||||
interact with these resources, see [MCP resource tools](mcp-resources.md).
|
||||
to reference them in the chat.
|
||||
|
||||
### Discovery and listing
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Tracker tools (`tracker_*`)
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development.
|
||||
|
||||
The `tracker_*` tools allow the Gemini agent to maintain an internal, persistent
|
||||
graph of tasks and dependencies for multi-step requests. This suite of tools
|
||||
provides a more robust and granular way to manage execution plans than the
|
||||
legacy `write_todos` tool.
|
||||
|
||||
## Technical reference
|
||||
|
||||
The agent uses these tools to manage its execution plan, decompose complex goals
|
||||
into actionable sub-tasks, and provide real-time progress updates to the CLI
|
||||
interface. The task state is stored in the `.gemini/tmp/tracker/<session-id>`
|
||||
directory, allowing the agent to manage its plan for the current session.
|
||||
|
||||
### Available Tools
|
||||
|
||||
- `tracker_create_task`: Creates a new task in the tracker. You can specify a
|
||||
title, description, and task type (`epic`, `task`, `bug`).
|
||||
- `tracker_update_task`: Updates an existing task's status (`open`,
|
||||
`in_progress`, `blocked`, `closed`), description, or dependencies.
|
||||
- `tracker_get_task`: Retrieves the full details of a specific task by its
|
||||
6-character hex ID.
|
||||
- `tracker_list_tasks`: Lists tasks in the tracker, optionally filtered by
|
||||
status, type, or parent ID.
|
||||
- `tracker_add_dependency`: Adds a dependency between two tasks, ensuring
|
||||
topological execution.
|
||||
- `tracker_visualize`: Renders an ASCII tree visualization of the current task
|
||||
graph.
|
||||
|
||||
## Technical behavior
|
||||
|
||||
- **Interface:** Updates the progress indicator and task tree above the CLI
|
||||
input prompt.
|
||||
- **Persistence:** Task state is saved automatically to the
|
||||
`.gemini/tmp/tracker/<session-id>` directory. Task states are session-specific
|
||||
and do not persist across different sessions.
|
||||
- **Dependencies:** Tasks can depend on other tasks, forming a directed acyclic
|
||||
graph (DAG). The agent must resolve dependencies before starting blocked
|
||||
tasks.
|
||||
- **Interaction:** Users can view the current state of the tracker by asking the
|
||||
agent to visualize it, or by running `gemini-cli` commands if implemented.
|
||||
|
||||
## Use cases
|
||||
|
||||
- Coordinating multi-file refactoring projects.
|
||||
- Breaking down a mission into a hierarchy of epics and tasks for better
|
||||
visibility.
|
||||
- Tracking bugs and feature requests directly within the context of an active
|
||||
codebase.
|
||||
- Providing visibility into the agent's current focus and remaining work.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Follow the [Task planning tutorial](../cli/tutorials/task-planning.md) for
|
||||
usage details and migration from the legacy todo list.
|
||||
- Learn about [Session management](../cli/session-management.md) for context on
|
||||
persistent state.
|
||||
@@ -11,8 +11,6 @@ import path from 'node:path';
|
||||
|
||||
describe('Background Process Monitoring', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should naturally use read output tool to find token',
|
||||
prompt:
|
||||
"Run the script using 'bash generate_token.sh'. It will emit a token after a short delay and continue running. Find the token and tell me what it is.",
|
||||
@@ -52,8 +50,6 @@ sleep 100
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should naturally use list tool to verify multiple processes',
|
||||
prompt:
|
||||
"Start three background processes that run 'sleep 100', 'sleep 200', and 'sleep 300' respectively. Verify that all three are currently running.",
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+2
-50
@@ -298,14 +298,12 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'I agree with your strategy. Please enter plan mode and draft the plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
|
||||
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
@@ -335,7 +333,7 @@ describe('plan_mode', () => {
|
||||
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but got error: ${(planWrite?.toolRequest as any).error}`,
|
||||
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
@@ -343,8 +341,6 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not exit plan mode or draft before informal agreement',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -376,48 +372,4 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should handle nested plan directories correctly',
|
||||
suiteName: 'plan_mode',
|
||||
suiteType: 'behavioral',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Then, exit plan mode',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeCalls = toolLogs.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToNestedPath = writeCalls.some((log) => {
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
if (!args.file_path) return false;
|
||||
// In plan mode, paths can be passed as relative (architecture/frontend-v2.md)
|
||||
// or they might be resolved as absolute by the tool depending on the exact mock state.
|
||||
// We strictly ensure it ends exactly with the expected nested path and doesn't contain extra nesting.
|
||||
const normalizedPath = args.file_path.replace(/\\/g, '/');
|
||||
return (
|
||||
normalizedPath === 'architecture/frontend-v2.md' ||
|
||||
normalizedPath.endsWith('/plans/architecture/frontend-v2.md')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
wroteToNestedPath,
|
||||
'Expected model to successfully target the nested plan file path',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+21
-346
@@ -18,11 +18,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
@@ -45,11 +40,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -71,11 +61,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -98,11 +83,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -128,11 +108,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -154,11 +129,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -181,11 +151,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingDbSchemaLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -215,11 +180,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -242,11 +202,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingBuildArtifactLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -276,11 +231,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingMainEntryPointAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -309,11 +259,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -338,7 +283,7 @@ describe('save_memory', () => {
|
||||
name: proactiveMemoryFromLongSession,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -396,75 +341,29 @@ describe('save_memory', () => {
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2, the agent persists memories by
|
||||
// editing markdown files directly with write_file or replace — not via
|
||||
// a save_memory subagent. The user said "I always prefer Vitest over
|
||||
// Jest for testing in all my projects" — that matches the new
|
||||
// cross-project cue phrase ("across all my projects"), so under the
|
||||
// 4-tier model the correct destination is the global personal memory
|
||||
// file (~/.gemini/GEMINI.md). It must NOT land in a committed project
|
||||
// GEMINI.md (that tier is for team conventions) or the per-project
|
||||
// private memory folder (that tier is for project-specific personal
|
||||
// notes). The chat history mixes this durable preference with
|
||||
// transient debugging chatter, so the eval also verifies the agent
|
||||
// picks out the persistent fact among the noise.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteVitestToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
/vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wroteVitestToGlobal,
|
||||
'Expected the cross-project Vitest preference to be written to the global personal memory file (~/.gemini/GEMINI.md) via write_file or replace',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
/vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Cross-project Vitest preference must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToPrivateProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) && /vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateProject,
|
||||
'Cross-project Vitest preference must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesTeamConventionsToProjectGemini =
|
||||
'Agent routes team-shared project conventions to ./GEMINI.md';
|
||||
const memoryManagerRoutingPreferences =
|
||||
'Agent routes global and project preferences to memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesTeamConventionsToProjectGemini,
|
||||
name: memoryManagerRoutingPreferences,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -473,7 +372,7 @@ describe('save_memory', () => {
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this project, the team always runs tests with `npm run test` — please remember that as our project convention.',
|
||||
text: 'I always use dark mode in all my editors and terminals.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
@@ -481,9 +380,7 @@ describe('save_memory', () => {
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Got it, I will keep `npm run test` in mind for tests.' },
|
||||
],
|
||||
content: [{ text: 'Got it, I will keep that in mind!' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
@@ -507,238 +404,16 @@ describe('save_memory', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2, the prompt enforces an explicit
|
||||
// one-tier-per-fact rule: team-shared project conventions (the team's
|
||||
// test command, project-wide indentation rules) belong in the
|
||||
// committed project-root ./GEMINI.md and must NOT be mirrored or
|
||||
// cross-referenced into the private project memory folder
|
||||
// (~/.gemini/tmp/<hash>/memory/). The global ~/.gemini/GEMINI.md must
|
||||
// never be touched in this mode either.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToProjectRoot = (factPattern: RegExp) =>
|
||||
writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
factPattern.test(args)
|
||||
);
|
||||
});
|
||||
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wroteToProjectRoot(/npm run test/i),
|
||||
'Expected the team test-command convention to be written to the project-root ./GEMINI.md',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent',
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wroteToProjectRoot(/2[- ]space/i),
|
||||
'Expected the project-wide "2-space indentation" convention to be written to the project-root ./GEMINI.md',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToPrivateMemory = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
|
||||
(/npm run test/i.test(args) || /2[- ]space/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateMemory,
|
||||
'Team-shared project conventions must NOT be mirrored into the private project memory folder (~/.gemini/tmp/<hash>/memory/) — each fact lives in exactly one tier.',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToGlobal,
|
||||
'Project preferences must NOT be written to the global ~/.gemini/GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesUserProject =
|
||||
'Agent routes personal-to-user project notes to user-project memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesUserProject,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
prompt: `Please remember my personal local dev setup for THIS project's Postgres database. This is private to my machine — do NOT commit it to the repo.
|
||||
|
||||
Connection details:
|
||||
- Host: localhost
|
||||
- Port: 6543 (non-standard, I run multiple Postgres instances)
|
||||
- Database: myproj_dev
|
||||
- User: sandy_local
|
||||
- Password: read from the SANDY_PG_LOCAL_PASS env var in my shell
|
||||
|
||||
How I start it locally:
|
||||
1. Run \`brew services start postgresql@15\` to bring the server up.
|
||||
2. Run \`./scripts/seed-local-db.sh\` from the repo root to load my personal seed data.
|
||||
3. Verify with \`psql -h localhost -p 6543 -U sandy_local myproj_dev -c '\\dt'\`.
|
||||
|
||||
Quirks to remember:
|
||||
- The migrations runner sometimes hangs on my machine if I forget step 1; kill it with Ctrl+C and rerun.
|
||||
- I keep an extra \`scratch\` schema for ad-hoc experiments — never reference it from project code.`,
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2 with the Private Project Memory bullet
|
||||
// surfaced in the prompt, a fact that is project-specific AND
|
||||
// personal-to-the-user (must not be committed) should land in the
|
||||
// private project memory folder under ~/.gemini/tmp/<hash>/memory/. The
|
||||
// detailed note should be written to a sibling markdown file, with
|
||||
// MEMORY.md updated as the index. It must NOT go to committed
|
||||
// ./GEMINI.md or the global ~/.gemini/GEMINI.md.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteUserProjectDetail = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\/(?!MEMORY\.md)[^"]+\.md/i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wroteUserProjectDetail,
|
||||
'Expected the personal-to-user project note to be written to a private project memory detail file (~/.gemini/tmp/<hash>/memory/*.md)',
|
||||
).toBe(true);
|
||||
|
||||
const wroteUserProjectIndex = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return /\.gemini\/tmp\/[^/]+\/memory\/MEMORY\.md/i.test(args);
|
||||
});
|
||||
expect(
|
||||
wroteUserProjectIndex,
|
||||
'Expected the personal-to-user project note to update the private project memory index (~/.gemini/tmp/<hash>/memory/MEMORY.md)',
|
||||
).toBe(true);
|
||||
|
||||
// Defensive: should NOT have written this private note to the
|
||||
// committed project GEMINI.md or the global GEMINI.md.
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Personal-to-user note must NOT be written to the committed project GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToGlobal,
|
||||
'Personal-to-user project note must NOT be written to the global ~/.gemini/GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesCrossProjectToGlobal =
|
||||
'Agent routes cross-project personal preferences to ~/.gemini/GEMINI.md';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesCrossProjectToGlobal,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please remember this about me in general: across all my projects I always prefer Prettier with single quotes and trailing commas, and I always prefer tabs over spaces for indentation. These are my personal coding-style defaults that follow me into every workspace.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2 with the Global Personal Memory
|
||||
// tier surfaced in the prompt, a fact that explicitly applies to the
|
||||
// user "across all my projects" / "in every workspace" must land in
|
||||
// the global ~/.gemini/GEMINI.md (the cross-project tier). It must
|
||||
// NOT be mirrored into a committed project-root ./GEMINI.md (that
|
||||
// tier is for team-shared conventions) or into the per-project
|
||||
// private memory folder (that tier is for project-specific personal
|
||||
// notes). Each fact lives in exactly one tier across all four tiers.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToGlobal = (factPattern: RegExp) =>
|
||||
writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
factPattern.test(args)
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wroteToGlobal(/Prettier/i),
|
||||
'Expected the cross-project Prettier preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wroteToGlobal(/tabs/i),
|
||||
'Expected the cross-project "tabs over spaces" preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
(/Prettier/i.test(args) || /tabs/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Cross-project personal preferences must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToPrivateProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
|
||||
(/Prettier/i.test(args) || /tabs/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateProject,
|
||||
'Cross-project personal preferences must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,12 +149,12 @@ async function readSkillBodies(skillsDir: string): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
|
||||
* - experimentalMemoryManager: enables the memory extraction pipeline.
|
||||
* - approvalMode: YOLO auto-approves tool calls (write_file, read_file) so the
|
||||
* background agent can execute without interactive confirmation.
|
||||
*/
|
||||
const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
experimentalAutoMemory: true,
|
||||
experimentalMemoryManager: true,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import {
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
describe('subtask delegation eval test cases', () => {
|
||||
@@ -19,8 +22,6 @@ describe('subtask delegation eval test cases', () => {
|
||||
* 3. Documenting (doc expert)
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -89,8 +90,6 @@ You are the doc expert. Document the provided implementation clearly.`,
|
||||
* to multiple subagents in parallel using the task tracker to manage state.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate independent subtasks to specialists using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -172,7 +172,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -119,8 +119,6 @@ describe('tracker_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should correctly identify the task tracker storage location from the system prompt',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@google/gemini-cli-core": ["../packages/core/index.ts"],
|
||||
"@google/gemini-cli": ["../packages/cli/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["logs"],
|
||||
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Reproduction: Agent uses Object.create() for cloning/delegation',
|
||||
prompt:
|
||||
'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.',
|
||||
|
||||
@@ -21,8 +21,6 @@ describe('update_topic_behavior', () => {
|
||||
* more than 1/4 turns.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should be used at start, end and middle for complex tasks',
|
||||
prompt: `Create a simple users REST API using Express.
|
||||
1. Initialize a new npm project and install express.
|
||||
@@ -43,7 +41,7 @@ describe('update_topic_behavior', () => {
|
||||
2,
|
||||
),
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
general: {
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
@@ -119,15 +117,13 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should NOT be used for informational coding tasks (Obvious)',
|
||||
approvalMode: 'default',
|
||||
prompt:
|
||||
'Explain the difference between Map and Object in JavaScript and provide a performance-focused code snippet for each.',
|
||||
files: {
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
general: {
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
@@ -146,8 +142,6 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should NOT be used for surgical symbol searches (Grey Area)',
|
||||
approvalMode: 'default',
|
||||
prompt:
|
||||
@@ -156,7 +150,7 @@ describe('update_topic_behavior', () => {
|
||||
'packages/core/src/tools/tool-names.ts':
|
||||
"export const UPDATE_TOPIC_TOOL_NAME = 'update_topic';",
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
general: {
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
@@ -175,8 +169,6 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should be used for medium complexity multi-step tasks',
|
||||
prompt:
|
||||
'Refactor the `users-api` project. Move the routing logic from src/app.ts into a new file src/routes.ts, and update app.ts to use the new routes file.',
|
||||
@@ -204,7 +196,7 @@ app.post('/users', (req, res) => {
|
||||
export default app;
|
||||
`,
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
general: {
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
@@ -220,9 +212,7 @@ export default app;
|
||||
expect(topicCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify it actually did the refactoring to ensure it didn't just fail immediately
|
||||
expect(fs.existsSync(path.join(rig.testDir!, 'src/routes.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,8 +224,6 @@ export default app;
|
||||
* the prompt change that improves the behavior.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should not be called twice in a row',
|
||||
prompt: `
|
||||
We need to build a C compiler.
|
||||
@@ -249,7 +237,7 @@ export default app;
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'test-project' }),
|
||||
'.gemini/settings.json': JSON.stringify({
|
||||
general: {
|
||||
experimental: {
|
||||
topicUpdateNarration: true,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -70,7 +70,6 @@ describe('ACP telemetry', () => {
|
||||
GEMINI_API_KEY: 'fake-key',
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_TELEMETRY_TRACES_ENABLED: 'true',
|
||||
GEMINI_TELEMETRY_TARGET: 'local',
|
||||
GEMINI_TELEMETRY_OUTFILE: telemetryPath,
|
||||
},
|
||||
|
||||
@@ -39,11 +39,7 @@ describe('web-fetch rate limiting', () => {
|
||||
const rateLimitedCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'web_fetch' &&
|
||||
(
|
||||
('error' in log.toolRequest
|
||||
? (log.toolRequest as unknown as Record<string, string>)['error']
|
||||
: '') as string
|
||||
)?.includes('Rate limit exceeded'),
|
||||
log.toolRequest.error?.includes('Rate limit exceeded'),
|
||||
);
|
||||
|
||||
expect(rateLimitedCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -8,16 +8,7 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Skip on macOS: every interactive test in this file is chronically flaky
|
||||
// because the captured pty buffer contains the CLI's startup escape
|
||||
// sequences (`q4;?m...true color warning`) instead of the streamed output,
|
||||
// causing `expectText(...)` to time out. Reproducible across unrelated
|
||||
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
|
||||
// gates for #25753 (24743605639, 24747624513) — different tests in the
|
||||
// same describe fail on different runs. Not specific to any model.
|
||||
const skipOnDarwin = process.platform === 'darwin';
|
||||
|
||||
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
|
||||
describe('Interactive Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -134,9 +134,7 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
// Trim to tolerate models that idiomatically append a trailing newline.
|
||||
// This test is about path-with-spaces handling, not whitespace fidelity.
|
||||
expect(newFileContent.trim()).toBe('hello');
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -164,8 +164,7 @@ describe.skipIf(skipFlaky)(
|
||||
);
|
||||
expect(blockHook).toBeDefined();
|
||||
expect(
|
||||
(blockHook?.hookCall.stdout || '') +
|
||||
(blockHook?.hookCall.stderr || ''),
|
||||
blockHook?.hookCall.stdout + blockHook?.hookCall.stderr,
|
||||
).toContain(blockMsg);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* @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 { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('mcp-resources-integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should list mcp resources', async () => {
|
||||
await rig.setup('mcp-list-resources-test', {
|
||||
settings: {
|
||||
model: {
|
||||
name: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
fakeResponsesPath: join(__dirname, 'mcp-list-resources.responses'),
|
||||
});
|
||||
|
||||
// Workaround for ProjectRegistry save issue
|
||||
const userGeminiDir = join(rig.homeDir!, '.gemini');
|
||||
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
|
||||
|
||||
// Add a dummy server to get setup done
|
||||
rig.addTestMcpServer('resource-server', {
|
||||
name: 'resource-server',
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// Overwrite the script with resource support
|
||||
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
|
||||
const scriptContent = `
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1',
|
||||
mimeType: 'text/plain',
|
||||
description: 'A test resource',
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const output = await rig.run({
|
||||
args: 'List all available MCP resources.',
|
||||
env: { GEMINI_API_KEY: 'dummy' },
|
||||
});
|
||||
|
||||
const foundCall = await rig.waitForToolCall('list_mcp_resources');
|
||||
expect(foundCall).toBeTruthy();
|
||||
expect(output).toContain('test://resource1');
|
||||
}, 60000);
|
||||
|
||||
it('should read mcp resource', async () => {
|
||||
await rig.setup('mcp-read-resource-test', {
|
||||
settings: {
|
||||
model: {
|
||||
name: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
fakeResponsesPath: join(__dirname, 'mcp-read-resource.responses'),
|
||||
});
|
||||
|
||||
// Workaround for ProjectRegistry save issue
|
||||
const userGeminiDir = join(rig.homeDir!, '.gemini');
|
||||
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
|
||||
|
||||
// Add a dummy server to get setup done
|
||||
rig.addTestMcpServer('resource-server', {
|
||||
name: 'resource-server',
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// Overwrite the script with resource support
|
||||
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
|
||||
const scriptContent = `
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Need to provide list resources so the tool is active!
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1',
|
||||
mimeType: 'text/plain',
|
||||
description: 'A test resource',
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
if (request.params.uri === 'test://resource1') {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
mimeType: 'text/plain',
|
||||
text: 'This is the content of resource 1',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error('Resource not found');
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const output = await rig.run({
|
||||
args: 'Read the MCP resource test://resource1.',
|
||||
env: { GEMINI_API_KEY: 'dummy' },
|
||||
});
|
||||
|
||||
const foundCall = await rig.waitForToolCall('read_mcp_resource');
|
||||
expect(foundCall).toBeTruthy();
|
||||
expect(output).toContain('content of resource 1');
|
||||
}, 60000);
|
||||
});
|
||||
@@ -81,10 +81,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args:
|
||||
'Create a file called plan.md in the plans directory with the ' +
|
||||
'content "# Plan". Treat this as a Directive and write the file ' +
|
||||
'immediately without proposing strategy or asking for confirmation.',
|
||||
args: 'Create a file called plan.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -111,7 +108,7 @@ describe('Plan Mode', () => {
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -197,11 +194,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args:
|
||||
'Create a file called plan-no-session.md in the plans directory ' +
|
||||
'with the content "# Plan". Treat this as a Directive and write ' +
|
||||
'the file immediately without proposing strategy or asking for ' +
|
||||
'confirmation.',
|
||||
args: 'Create a file called plan-no-session.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -228,7 +221,7 @@ describe('Plan Mode', () => {
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
|
||||
@@ -277,24 +270,13 @@ describe('Plan Mode', () => {
|
||||
);
|
||||
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
const modelNames = apiRequests.map(
|
||||
(r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown') || 'unknown',
|
||||
);
|
||||
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
|
||||
|
||||
const proRequests = apiRequests.filter((r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('pro'),
|
||||
r.attributes?.model?.includes('pro'),
|
||||
);
|
||||
const flashRequests = apiRequests.filter((r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('flash'),
|
||||
r.attributes?.model?.includes('flash'),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
describe('run_shell_command streaming to file regression', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should stream large outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream large outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 20000;
|
||||
const testFileName = 'large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~20MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(20000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream very large (50MB) outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 1000000;
|
||||
const testFileName = 'very_large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~50MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(45000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should produce clean output resolving carriage returns and backspaces', async () => {
|
||||
await rig.setup(
|
||||
'should produce clean output resolving carriage returns and backspaces',
|
||||
{
|
||||
settings: {
|
||||
tools: { core: ['run_shell_command'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const script = `
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Fill buffer to force file streaming/truncation
|
||||
# 45000 chars to be safe (default threshold is 40000)
|
||||
print('A' * 45000)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Test sequence
|
||||
print('XXXXX', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\rYYYYY', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\nNext Line', end='', flush=True)
|
||||
`;
|
||||
const scriptPath = path.join(rig.testDir!, 'test_script.py');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
|
||||
const prompt = `run_shell_command python3 "${scriptPath}"`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
if (files.length > 0) {
|
||||
savedFilePath = files[0];
|
||||
}
|
||||
}
|
||||
|
||||
expect(savedFilePath, 'Output file should exist').toBeTruthy();
|
||||
const content = fs.readFileSync(savedFilePath, 'utf8');
|
||||
|
||||
// Verify it contains the large chunk
|
||||
expect(content).toContain('AAAA');
|
||||
|
||||
// Verify cleanup logic:
|
||||
// 1. The final text "YYYYY" should be present.
|
||||
expect(content).toContain('YYYYY');
|
||||
// 2. The next line should be present.
|
||||
expect(content).toContain('Next Line');
|
||||
|
||||
// 3. Verify overwrite happened.
|
||||
// In raw output, we would have "XXXXX...YYYYY".
|
||||
// In processed output, "YYYYY" overwrites "XXXXX".
|
||||
// We confirm that escape codes are stripped (processed text).
|
||||
|
||||
// 4. Check for ANSI escape codes (like \\x1b) just in case
|
||||
expect(content).not.toContain('\x1b');
|
||||
}, 60000);
|
||||
});
|
||||
@@ -5,9 +5,5 @@
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" },
|
||||
{ "path": "../packages/cli" }
|
||||
]
|
||||
"references": [{ "path": "../packages/core" }]
|
||||
}
|
||||
|
||||
+36
-36
@@ -1,55 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-20T18:04:59.671Z",
|
||||
"updatedAt": "2026-04-10T15:36:04.547Z",
|
||||
"scenarios": {
|
||||
"multi-turn-conversation": {
|
||||
"heapUsedMB": 68.8,
|
||||
"heapTotalMB": 91.2,
|
||||
"rssMB": 215.4,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:40.101Z"
|
||||
"heapUsedBytes": 120082704,
|
||||
"heapTotalBytes": 177586176,
|
||||
"rssBytes": 269172736,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:17.603Z"
|
||||
},
|
||||
"multi-function-call-repo-search": {
|
||||
"heapUsedMB": 73.5,
|
||||
"heapTotalMB": 93.1,
|
||||
"rssMB": 223.6,
|
||||
"externalMB": 97.7,
|
||||
"timestamp": "2026-04-20T18:02:42.032Z"
|
||||
"heapUsedBytes": 104644984,
|
||||
"heapTotalBytes": 111575040,
|
||||
"rssBytes": 204079104,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:22.480Z"
|
||||
},
|
||||
"idle-session-startup": {
|
||||
"heapUsedMB": 69.8,
|
||||
"heapTotalMB": 92.4,
|
||||
"rssMB": 217.4,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:36.294Z"
|
||||
"heapUsedBytes": 119813672,
|
||||
"heapTotalBytes": 177061888,
|
||||
"rssBytes": 267943936,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:08.035Z"
|
||||
},
|
||||
"simple-prompt-response": {
|
||||
"heapUsedMB": 69.5,
|
||||
"heapTotalMB": 92.4,
|
||||
"rssMB": 216.1,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:38.198Z"
|
||||
"heapUsedBytes": 119722064,
|
||||
"heapTotalBytes": 177324032,
|
||||
"rssBytes": 268812288,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:12.770Z"
|
||||
},
|
||||
"resume-large-chat-with-messages": {
|
||||
"heapUsedMB": 887.1,
|
||||
"heapTotalMB": 954.3,
|
||||
"rssMB": 1109.6,
|
||||
"externalMB": 103.2,
|
||||
"timestamp": "2026-04-20T18:04:59.671Z"
|
||||
"heapUsedBytes": 106545568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:36:04.547Z"
|
||||
},
|
||||
"resume-large-chat": {
|
||||
"heapUsedMB": 885.6,
|
||||
"heapTotalMB": 955.6,
|
||||
"rssMB": 1107.8,
|
||||
"externalMB": 110.5,
|
||||
"timestamp": "2026-04-20T18:04:06.526Z"
|
||||
"heapUsedBytes": 106513760,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:59.528Z"
|
||||
},
|
||||
"large-chat": {
|
||||
"heapUsedMB": 158.5,
|
||||
"heapTotalMB": 193,
|
||||
"rssMB": 787.9,
|
||||
"externalMB": 104,
|
||||
"timestamp": "2026-04-20T18:03:12.486Z"
|
||||
"heapUsedBytes": 106471568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:53.180Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,15 @@ import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
|
||||
function getProjectHash(projectRoot: string): string {
|
||||
return createHash('sha256').update(projectRoot).digest('hex');
|
||||
}
|
||||
const TOLERANCE_PERCENT = 10;
|
||||
|
||||
// Fake API key for tests using fake responses
|
||||
const TEST_ENV = {
|
||||
GEMINI_API_KEY: 'fake-memory-test-key',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '100',
|
||||
};
|
||||
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
|
||||
|
||||
describe('Memory Usage Tests', () => {
|
||||
let harness: MemoryTestHarness;
|
||||
@@ -62,7 +56,6 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'idle-session-startup',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -92,7 +85,6 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'simple-prompt-response',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -130,7 +122,6 @@ describe('Memory Usage Tests', () => {
|
||||
];
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'multi-turn-conversation',
|
||||
async (recordSnapshot) => {
|
||||
// Run through all turns as a piped sequence
|
||||
@@ -153,9 +144,6 @@ describe('Memory Usage Tests', () => {
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
|
||||
const { leaked, message } = harness.analyzeSnapshots(result.snapshots);
|
||||
if (leaked) console.warn(`⚠ ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -180,7 +168,6 @@ describe('Memory Usage Tests', () => {
|
||||
);
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'multi-function-call-repo-search',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -202,7 +189,6 @@ describe('Memory Usage Tests', () => {
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -242,7 +228,6 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'large-chat',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -272,21 +257,19 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'resume-large-chat',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
getProjectHash(rig.testDir!),
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'session-large-chat.json',
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
@@ -319,21 +302,19 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'resume-large-chat-with-messages',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
getProjectHash(rig.testDir!),
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'session-large-chat.json',
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
@@ -476,9 +457,6 @@ async function generateSharedLargeChatData(tempDir: string) {
|
||||
// Generate responses for resumed chat
|
||||
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Doubling up on non-streaming responses to satisfy classifier and complexity checks
|
||||
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
|
||||
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
resumeResponsesStream.write(
|
||||
JSON.stringify({
|
||||
@@ -511,12 +489,8 @@ async function generateSharedLargeChatData(tempDir: string) {
|
||||
|
||||
// Wait for streams to finish
|
||||
await Promise.all([
|
||||
new Promise((res) =>
|
||||
activeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
new Promise((res) =>
|
||||
resumeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
new Promise((res) => activeResponsesStream.on('finish', res)),
|
||||
new Promise((res) => resumeResponsesStream.on('finish', res)),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
Generated
+244
-845
File diff suppressed because it is too large
Load Diff
+5
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"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.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -62,7 +62,7 @@
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"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",
|
||||
@@ -81,7 +81,8 @@
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -93,7 +94,6 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"read-package-up": "^11.0.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
@@ -137,7 +137,6 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"ts-prune": "^0.10.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -98,7 +98,6 @@ export function createMockConfig(
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
@@ -112,7 +111,6 @@ export function createMockConfig(
|
||||
}),
|
||||
isContextManagementEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -41,8 +41,6 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
|
||||
|
||||
const startMemoryServiceMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
@@ -103,7 +101,6 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
startMemoryService: startMemoryServiceMock,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
@@ -151,8 +148,6 @@ describe('GeminiAgent', () => {
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
startMemoryServiceMock.mockResolvedValue(undefined);
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -160,7 +155,6 @@ describe('GeminiAgent', () => {
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
@@ -360,34 +354,6 @@ describe('GeminiAgent', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should start auto memory for new ACP sessions when enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(startMemoryServiceMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should not start auto memory for new ACP sessions when disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(false);
|
||||
|
||||
await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(startMemoryServiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
|
||||
@@ -76,7 +76,6 @@ import { randomUUID } from 'node:crypto';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
|
||||
@@ -325,7 +324,6 @@ export class GeminiAgent {
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
@@ -467,7 +465,6 @@ export class GeminiAgent {
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ describe('GeminiAgent Session Resume', () => {
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -135,10 +135,10 @@ export class InboxMemoryCommand implements Command {
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
if (!context.agentContext.config.isAutoMemoryEnabled()) {
|
||||
if (!context.agentContext.config.isMemoryManagerEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
|
||||
data: 'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule, Argv } from 'yargs';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
import { setupCommand } from './gemma/setup.js';
|
||||
import { startCommand } from './gemma/start.js';
|
||||
import { stopCommand } from './gemma/stop.js';
|
||||
import { statusCommand } from './gemma/status.js';
|
||||
import { logsCommand } from './gemma/logs.js';
|
||||
|
||||
export const gemmaCommand: CommandModule = {
|
||||
command: 'gemma',
|
||||
describe: 'Manage local Gemma model routing',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(setupCommand, 'gemma'))
|
||||
.command(defer(startCommand, 'gemma'))
|
||||
.command(defer(stopCommand, 'gemma'))
|
||||
.command(defer(statusCommand, 'gemma'))
|
||||
.command(defer(logsCommand, 'gemma'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {},
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { Storage } from '@google/gemini-cli-core';
|
||||
|
||||
export const LITERT_RELEASE_VERSION = 'v0.9.0-alpha03';
|
||||
export const LITERT_RELEASE_BASE_URL =
|
||||
'https://github.com/google-ai-edge/LiteRT-LM/releases/download';
|
||||
export const GEMMA_MODEL_NAME = 'gemma3-1b-gpu-custom';
|
||||
export const DEFAULT_PORT = 9379;
|
||||
export const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
export const LITERT_API_VERSION = 'v1beta';
|
||||
export const SERVER_START_WAIT_MS = 3000;
|
||||
|
||||
export const PLATFORM_BINARY_MAP: Record<string, string> = {
|
||||
'darwin-arm64': 'lit.macos_arm64',
|
||||
'linux-x64': 'lit.linux_x86_64',
|
||||
'win32-x64': 'lit.windows_x86_64.exe',
|
||||
};
|
||||
|
||||
// SHA-256 hashes for the official LiteRT-LM v0.9.0-alpha03 release binaries.
|
||||
export const PLATFORM_BINARY_SHA256: Record<string, string> = {
|
||||
'lit.macos_arm64':
|
||||
'9e826a2634f2e8b220ad0f1e1b5c139e0b47cb172326e3b7d46d31382f49478e',
|
||||
'lit.linux_x86_64':
|
||||
'66601df8a07f08244b188e9fcab0bf4a16562fe76d8d47e49f40273d57541ee8',
|
||||
'lit.windows_x86_64.exe':
|
||||
'de82d2829d2fb1cbdb318e2d8a78dc2f9659ff14cb11b2894d1f30e0bfde2bf6',
|
||||
};
|
||||
|
||||
export function getLiteRtBinDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'bin', 'litert');
|
||||
}
|
||||
|
||||
export function getPidFilePath(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'litert-server.pid');
|
||||
}
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'litert-server.log');
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getLogFilePath } from './constants.js';
|
||||
import { logsCommand, readLastLines } from './logs.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./constants.js', () => ({
|
||||
getLogFilePath: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMockChild(): ChildProcess {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('readLastLines', () => {
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempFiles
|
||||
.splice(0)
|
||||
.map((filePath) => fs.promises.rm(filePath, { force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only the requested tail lines without reading the whole file eagerly', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
|
||||
const content = Array.from({ length: 2000 }, (_, i) => `line-${i + 1}`)
|
||||
.join('\n')
|
||||
.concat('\n');
|
||||
await fs.promises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
await expect(readLastLines(filePath, 3)).resolves.toBe(
|
||||
'line-1998\nline-1999\nline-2000\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty string when zero lines are requested', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'line-1\nline-2\n', 'utf-8');
|
||||
|
||||
await expect(readLastLines(filePath, 0)).resolves.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logsCommand', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(getLogFilePath).mockReturnValue('/tmp/gemma.log');
|
||||
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('waits for the tail process to close before exiting in follow mode', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
let resolved = false;
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({}).then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '20', '/tmp/gemma.log'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
expect(resolved).toBe(false);
|
||||
expect(exitCli).not.toHaveBeenCalled();
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('uses one-shot tail output when follow is disabled', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({ follow: false });
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith('tail', ['-n', '20', '/tmp/gemma.log'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('follows from the requested line count when both --lines and --follow are set', async () => {
|
||||
const child = createMockChild();
|
||||
vi.mocked(spawn).mockReturnValue(child);
|
||||
|
||||
const handlerPromise = (
|
||||
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
|
||||
)({ lines: 5, follow: true });
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tail',
|
||||
['-f', '-n', '5', '/tmp/gemma.log'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
child.emit('close', 0);
|
||||
await handlerPromise;
|
||||
|
||||
expect(exitCli).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getLogFilePath } from './constants.js';
|
||||
|
||||
export async function readLastLines(
|
||||
filePath: string,
|
||||
count: number,
|
||||
): Promise<string> {
|
||||
if (count <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
const fileHandle = await fs.promises.open(filePath, fs.constants.O_RDONLY);
|
||||
|
||||
try {
|
||||
const stats = await fileHandle.stat();
|
||||
if (stats.size === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
let newlineCount = 0;
|
||||
let position = stats.size;
|
||||
|
||||
while (position > 0 && newlineCount <= count) {
|
||||
const readSize = Math.min(CHUNK_SIZE, position);
|
||||
position -= readSize;
|
||||
|
||||
const buffer = Buffer.allocUnsafe(readSize);
|
||||
const { bytesRead } = await fileHandle.read(
|
||||
buffer,
|
||||
0,
|
||||
readSize,
|
||||
position,
|
||||
);
|
||||
|
||||
if (bytesRead === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk =
|
||||
bytesRead === readSize ? buffer : buffer.subarray(0, bytesRead);
|
||||
chunks.unshift(chunk);
|
||||
totalBytes += chunk.length;
|
||||
|
||||
for (const byte of chunk) {
|
||||
if (byte === 0x0a) {
|
||||
newlineCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = Buffer.concat(chunks, totalBytes).toString('utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (position > 0 && lines.length > 0) {
|
||||
const boundary = Buffer.allocUnsafe(1);
|
||||
const { bytesRead } = await fileHandle.read(boundary, 0, 1, position - 1);
|
||||
if (bytesRead === 1 && boundary[0] !== 0x0a) {
|
||||
lines.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return lines.slice(-count).join('\n') + '\n';
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface LogsArgs {
|
||||
lines?: number;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
function waitForChild(child: ChildProcess): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
async function runTail(logPath: string, lines: number, follow: boolean) {
|
||||
const tailArgs = follow
|
||||
? ['-f', '-n', String(lines), logPath]
|
||||
: ['-n', String(lines), logPath];
|
||||
const child = spawn('tail', tailArgs, { stdio: 'inherit' });
|
||||
|
||||
if (!follow) {
|
||||
return waitForChild(child);
|
||||
}
|
||||
|
||||
const handleSigint = () => {
|
||||
child.kill('SIGTERM');
|
||||
};
|
||||
process.once('SIGINT', handleSigint);
|
||||
|
||||
try {
|
||||
return await waitForChild(child);
|
||||
} finally {
|
||||
process.off('SIGINT', handleSigint);
|
||||
}
|
||||
}
|
||||
|
||||
export const logsCommand: CommandModule<object, LogsArgs> = {
|
||||
command: 'logs',
|
||||
describe: 'View LiteRT-LM server logs',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('lines', {
|
||||
alias: 'n',
|
||||
type: 'number',
|
||||
description: 'Show the last N lines and exit (omit to follow live)',
|
||||
})
|
||||
.option('follow', {
|
||||
alias: 'f',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Follow log output (defaults to true when --lines is omitted)',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const logPath = getLogFilePath();
|
||||
|
||||
try {
|
||||
await fs.promises.access(logPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
debugLogger.log(`No log file found at ${logPath}`);
|
||||
debugLogger.log(
|
||||
'Is the LiteRT server running? Start it with: gemini gemma start',
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = argv.lines;
|
||||
const follow = argv.follow ?? lines === undefined;
|
||||
const requestedLines = lines ?? 20;
|
||||
|
||||
if (follow && process.platform === 'win32') {
|
||||
debugLogger.log(
|
||||
'Live log following is not supported on Windows. Use --lines N to view recent logs.',
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
process.stdout.write(await readLastLines(logPath, requestedLines));
|
||||
await exitCli(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (follow) {
|
||||
debugLogger.log(`Tailing ${logPath} (Ctrl+C to stop)\n`);
|
||||
}
|
||||
const exitCode = await runTail(logPath, requestedLines, follow);
|
||||
await exitCli(exitCode);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
if (!follow) {
|
||||
process.stdout.write(await readLastLines(logPath, requestedLines));
|
||||
await exitCli(0);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
'"tail" command not found. Use --lines N to view recent logs without tail.',
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
} else {
|
||||
debugLogger.error(
|
||||
`Failed to read log output: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getLiteRtBinDir } from './constants.js';
|
||||
|
||||
const mockLoadSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
loadSettings: mockLoadSettings,
|
||||
SettingScope: {
|
||||
User: 'User',
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getBinaryPath,
|
||||
isExpectedLiteRtServerCommand,
|
||||
isBinaryInstalled,
|
||||
readServerProcessInfo,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
|
||||
describe('gemma platform helpers', () => {
|
||||
function createMockSettings(
|
||||
userGemmaSettings?: object,
|
||||
mergedGemmaSettings?: object,
|
||||
) {
|
||||
return {
|
||||
merged: {
|
||||
experimental: {
|
||||
gemmaModelRouter: mergedGemmaSettings,
|
||||
},
|
||||
},
|
||||
forScope: vi.fn((scope: SettingScope) => {
|
||||
if (scope !== SettingScope.User) {
|
||||
throw new Error(`Unexpected scope ${scope}`);
|
||||
}
|
||||
return {
|
||||
settings: {
|
||||
experimental: {
|
||||
gemmaModelRouter: userGemmaSettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue(createMockSettings());
|
||||
});
|
||||
|
||||
it('prefers the configured binary path from settings', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings({ binaryPath: '/custom/lit' }),
|
||||
);
|
||||
|
||||
expect(getBinaryPath('lit.test')).toBe('/custom/lit');
|
||||
});
|
||||
|
||||
it('ignores workspace overrides for the configured binary path', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings(
|
||||
{ binaryPath: '/user/lit' },
|
||||
{ binaryPath: '/workspace/evil' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(getBinaryPath('lit.test')).toBe('/user/lit');
|
||||
});
|
||||
|
||||
it('falls back to the default install location when no custom path is set', () => {
|
||||
expect(getBinaryPath('lit.test')).toBe(
|
||||
path.join(getLiteRtBinDir(), 'lit.test'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the configured port and binary path from settings', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings(
|
||||
{ binaryPath: '/custom/lit' },
|
||||
{
|
||||
enabled: true,
|
||||
classifier: {
|
||||
host: 'http://localhost:8123/v1beta',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(resolveGemmaConfig(9379)).toEqual({
|
||||
settingsEnabled: true,
|
||||
configuredPort: 8123,
|
||||
configuredBinaryPath: '/custom/lit',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks binary installation using the resolved binary path', () => {
|
||||
mockLoadSettings.mockReturnValue(
|
||||
createMockSettings({ binaryPath: '/custom/lit' }),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
expect(isBinaryInstalled()).toBe(true);
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/custom/lit');
|
||||
});
|
||||
|
||||
it('parses structured server process info from the pid file', () => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readServerProcessInfo()).toEqual({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses legacy pid-only files for backward compatibility', () => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('4321');
|
||||
|
||||
expect(readServerProcessInfo()).toEqual({
|
||||
pid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches only the expected LiteRT serve command', () => {
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit serve --port=8123 --verbose', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit run --port=8123', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isExpectedLiteRtServerCommand('/custom/lit serve --port=9000', {
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
PLATFORM_BINARY_MAP,
|
||||
LITERT_RELEASE_BASE_URL,
|
||||
LITERT_RELEASE_VERSION,
|
||||
getLiteRtBinDir,
|
||||
GEMMA_MODEL_NAME,
|
||||
HEALTH_CHECK_TIMEOUT_MS,
|
||||
LITERT_API_VERSION,
|
||||
getPidFilePath,
|
||||
} from './constants.js';
|
||||
|
||||
export interface PlatformInfo {
|
||||
key: string;
|
||||
binaryName: string;
|
||||
}
|
||||
|
||||
export interface GemmaConfigStatus {
|
||||
settingsEnabled: boolean;
|
||||
configuredPort: number;
|
||||
configuredBinaryPath?: string;
|
||||
}
|
||||
|
||||
export interface LiteRtServerProcessInfo {
|
||||
pid: number;
|
||||
binaryPath?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
function getUserConfiguredBinaryPath(
|
||||
workspaceDir = process.cwd(),
|
||||
): string | undefined {
|
||||
try {
|
||||
const userGemmaSettings = loadSettings(workspaceDir).forScope(
|
||||
SettingScope.User,
|
||||
).settings.experimental?.gemmaModelRouter;
|
||||
return userGemmaSettings?.binaryPath?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePortFromHost(
|
||||
host: string | undefined,
|
||||
fallbackPort: number,
|
||||
): number {
|
||||
if (!host) {
|
||||
return fallbackPort;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(host);
|
||||
const port = Number(url.port);
|
||||
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
|
||||
} catch {
|
||||
const match = host.match(/:(\d+)/);
|
||||
if (!match) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = parseInt(match[1], 10);
|
||||
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGemmaConfig(fallbackPort: number): GemmaConfigStatus {
|
||||
let settingsEnabled = false;
|
||||
let configuredPort = fallbackPort;
|
||||
const configuredBinaryPath = getUserConfiguredBinaryPath();
|
||||
try {
|
||||
const settings = loadSettings(process.cwd());
|
||||
const gemmaSettings = settings.merged.experimental?.gemmaModelRouter;
|
||||
settingsEnabled = gemmaSettings?.enabled === true;
|
||||
configuredPort = parsePortFromHost(
|
||||
gemmaSettings?.classifier?.host,
|
||||
fallbackPort,
|
||||
);
|
||||
} catch {
|
||||
// ignore — settings may fail to load outside a workspace
|
||||
}
|
||||
return { settingsEnabled, configuredPort, configuredBinaryPath };
|
||||
}
|
||||
|
||||
export function detectPlatform(): PlatformInfo | null {
|
||||
const key = `${process.platform}-${process.arch}`;
|
||||
const binaryName = PLATFORM_BINARY_MAP[key];
|
||||
if (!binaryName) {
|
||||
return null;
|
||||
}
|
||||
return { key, binaryName };
|
||||
}
|
||||
|
||||
export function getBinaryPath(binaryName?: string): string | null {
|
||||
const configuredBinaryPath = getUserConfiguredBinaryPath();
|
||||
if (configuredBinaryPath) {
|
||||
return configuredBinaryPath;
|
||||
}
|
||||
|
||||
const name = binaryName ?? detectPlatform()?.binaryName;
|
||||
if (!name) return null;
|
||||
return path.join(getLiteRtBinDir(), name);
|
||||
}
|
||||
|
||||
export function getBinaryDownloadUrl(binaryName: string): string {
|
||||
return `${LITERT_RELEASE_BASE_URL}/${LITERT_RELEASE_VERSION}/${binaryName}`;
|
||||
}
|
||||
|
||||
export function isBinaryInstalled(binaryPath = getBinaryPath()): boolean {
|
||||
if (!binaryPath) return false;
|
||||
return fs.existsSync(binaryPath);
|
||||
}
|
||||
|
||||
export function isModelDownloaded(binaryPath: string): boolean {
|
||||
try {
|
||||
const output = execFileSync(binaryPath, ['list'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
return output.includes(GEMMA_MODEL_NAME);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isServerRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
HEALTH_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://localhost:${port}/${LITERT_API_VERSION}/models/${GEMMA_MODEL_NAME}:generateContent`,
|
||||
{ method: 'POST', signal: controller.signal },
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
// A 400 (bad request) confirms the route exists — the server recognises
|
||||
// the model endpoint. Only a 404 means "wrong server / wrong model".
|
||||
return response.status !== 404;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLiteRtServerProcessInfo(
|
||||
value: unknown,
|
||||
): value is LiteRtServerProcessInfo {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPositiveInteger = (candidate: unknown): candidate is number =>
|
||||
typeof candidate === 'number' &&
|
||||
Number.isInteger(candidate) &&
|
||||
candidate > 0;
|
||||
const isNonEmptyString = (candidate: unknown): candidate is string =>
|
||||
typeof candidate === 'string' && candidate.length > 0;
|
||||
|
||||
const pid: unknown = Object.getOwnPropertyDescriptor(value, 'pid')?.value;
|
||||
if (!isPositiveInteger(pid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const binaryPath: unknown = Object.getOwnPropertyDescriptor(
|
||||
value,
|
||||
'binaryPath',
|
||||
)?.value;
|
||||
if (binaryPath !== undefined && !isNonEmptyString(binaryPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const port: unknown = Object.getOwnPropertyDescriptor(value, 'port')?.value;
|
||||
if (port !== undefined && !isPositiveInteger(port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readServerProcessInfo(): LiteRtServerProcessInfo | null {
|
||||
const pidPath = getPidFilePath();
|
||||
try {
|
||||
const content = fs.readFileSync(pidPath, 'utf-8').trim();
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(content)) {
|
||||
return { pid: parseInt(content, 10) };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return isLiteRtServerProcessInfo(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerProcessInfo(
|
||||
processInfo: LiteRtServerProcessInfo,
|
||||
): void {
|
||||
fs.writeFileSync(getPidFilePath(), JSON.stringify(processInfo), 'utf-8');
|
||||
}
|
||||
|
||||
export function readServerPid(): number | null {
|
||||
return readServerProcessInfo()?.pid ?? null;
|
||||
}
|
||||
|
||||
function normalizeProcessValue(value: string): string {
|
||||
const normalized = value.replace(/\0/g, ' ').trim();
|
||||
if (process.platform === 'win32') {
|
||||
return normalized.replace(/\\/g, '/').replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
return normalized.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function readProcessCommandLine(pid: number): string | null {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const output = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
|
||||
return output.trim() ? output : null;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
return output.trim() || null;
|
||||
}
|
||||
|
||||
const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedLiteRtServerCommand(
|
||||
commandLine: string,
|
||||
options: {
|
||||
binaryPath?: string | null;
|
||||
port?: number;
|
||||
},
|
||||
): boolean {
|
||||
const normalizedCommandLine = normalizeProcessValue(commandLine);
|
||||
if (!normalizedCommandLine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/(^|\s|")serve(\s|$)/.test(normalizedCommandLine)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
options.port !== undefined &&
|
||||
!normalizedCommandLine.includes(`--port=${options.port}`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!options.binaryPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedBinaryPath = normalizeProcessValue(options.binaryPath);
|
||||
const normalizedBinaryName = normalizeProcessValue(
|
||||
path.basename(options.binaryPath),
|
||||
);
|
||||
return (
|
||||
normalizedCommandLine.includes(normalizedBinaryPath) ||
|
||||
normalizedCommandLine.includes(normalizedBinaryName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isExpectedLiteRtServerProcess(
|
||||
pid: number,
|
||||
options: {
|
||||
binaryPath?: string | null;
|
||||
port?: number;
|
||||
},
|
||||
): boolean {
|
||||
const commandLine = readProcessCommandLine(pid);
|
||||
if (!commandLine) {
|
||||
return false;
|
||||
}
|
||||
return isExpectedLiteRtServerCommand(commandLine, options);
|
||||
}
|
||||
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_BINARY_MAP, PLATFORM_BINARY_SHA256 } from './constants.js';
|
||||
import { computeFileSha256, verifyFileSha256 } from './setup.js';
|
||||
|
||||
describe('gemma setup checksum helpers', () => {
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempFiles
|
||||
.splice(0)
|
||||
.map((filePath) => fs.promises.rm(filePath, { force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it('has a pinned checksum for every supported LiteRT binary', () => {
|
||||
expect(Object.keys(PLATFORM_BINARY_SHA256).sort()).toEqual(
|
||||
Object.values(PLATFORM_BINARY_MAP).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes the sha256 for a downloaded file', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
|
||||
|
||||
await expect(computeFileSha256(filePath)).resolves.toBe(
|
||||
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies whether a file matches the expected sha256', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
tempFiles.push(filePath);
|
||||
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
|
||||
|
||||
await expect(
|
||||
verifyFileSha256(
|
||||
filePath,
|
||||
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(verifyFileSha256(filePath, 'deadbeef')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
GEMMA_MODEL_NAME,
|
||||
PLATFORM_BINARY_SHA256,
|
||||
} from './constants.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
getBinaryDownloadUrl,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isModelDownloaded,
|
||||
} from './platform.js';
|
||||
import { startServer } from './start.js';
|
||||
import readline from 'node:readline';
|
||||
|
||||
const log = (msg: string) => debugLogger.log(msg);
|
||||
const logError = (msg: string) => debugLogger.error(msg);
|
||||
|
||||
async function promptYesNo(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (y/N): `, (answer) => {
|
||||
rl.close();
|
||||
resolve(
|
||||
answer.trim().toLowerCase() === 'y' ||
|
||||
answer.trim().toLowerCase() === 'yes',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function renderProgress(downloaded: number, total: number | null): void {
|
||||
const barWidth = 30;
|
||||
if (total && total > 0) {
|
||||
const pct = Math.min(downloaded / total, 1);
|
||||
const filled = Math.round(barWidth * pct);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const pctStr = (pct * 100).toFixed(0).padStart(3);
|
||||
process.stderr.write(
|
||||
`\r [${bar}] ${pctStr}% ${formatBytes(downloaded)} / ${formatBytes(total)}`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`\r Downloaded ${formatBytes(downloaded)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
const tmpPath = destPath + '.downloading';
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { redirect: 'follow' });
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Download failed: HTTP ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('Download failed: No response body');
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
|
||||
let downloadedBytes = 0;
|
||||
|
||||
const fileStream = fs.createWriteStream(tmpPath);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const writeOk = fileStream.write(value);
|
||||
if (!writeOk) {
|
||||
await new Promise<void>((resolve) => fileStream.once('drain', resolve));
|
||||
}
|
||||
downloadedBytes += value.byteLength;
|
||||
renderProgress(downloadedBytes, totalBytes);
|
||||
}
|
||||
} finally {
|
||||
fileStream.end();
|
||||
process.stderr.write('\r' + ' '.repeat(80) + '\r');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.on('finish', resolve);
|
||||
fileStream.on('error', reject);
|
||||
});
|
||||
|
||||
fs.renameSync(tmpPath, destPath);
|
||||
}
|
||||
|
||||
export async function computeFileSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fileStream.on('data', (chunk) => {
|
||||
hash.update(chunk);
|
||||
});
|
||||
fileStream.on('error', reject);
|
||||
fileStream.on('end', () => {
|
||||
resolve(hash.digest('hex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyFileSha256(
|
||||
filePath: string,
|
||||
expectedHash: string,
|
||||
): Promise<boolean> {
|
||||
const actualHash = await computeFileSha256(filePath);
|
||||
return actualHash === expectedHash;
|
||||
}
|
||||
|
||||
function spawnInherited(command: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = nodeSpawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
interface SetupArgs {
|
||||
port: number;
|
||||
skipModel: boolean;
|
||||
start: boolean;
|
||||
force: boolean;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
async function handleSetup(argv: SetupArgs): Promise<number> {
|
||||
const { port, force } = argv;
|
||||
let settingsUpdated = false;
|
||||
let serverStarted = false;
|
||||
let autoStartServer = true;
|
||||
|
||||
log('');
|
||||
log(chalk.bold('Gemma Local Model Routing Setup'));
|
||||
log(chalk.dim('─'.repeat(40)));
|
||||
log('');
|
||||
|
||||
const platform = detectPlatform();
|
||||
if (!platform) {
|
||||
logError(
|
||||
chalk.red(`Unsupported platform: ${process.platform}-${process.arch}`),
|
||||
);
|
||||
logError(
|
||||
'LiteRT-LM binaries are available for: macOS (ARM64), Linux (x86_64), Windows (x86_64)',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
log(chalk.dim(` Platform: ${platform.key} → ${platform.binaryName}`));
|
||||
|
||||
if (!argv.consent) {
|
||||
log('');
|
||||
log('This will download and install the LiteRT-LM runtime and the');
|
||||
log(
|
||||
`Gemma model (${GEMMA_MODEL_NAME}, ~1 GB). By proceeding, you agree to the`,
|
||||
);
|
||||
log('Gemma Terms of Use: https://ai.google.dev/gemma/terms');
|
||||
log('');
|
||||
|
||||
const accepted = await promptYesNo('Do you want to continue?');
|
||||
if (!accepted) {
|
||||
log('Setup cancelled.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath(platform.binaryName)!;
|
||||
const alreadyInstalled = isBinaryInstalled();
|
||||
|
||||
if (alreadyInstalled && !force) {
|
||||
log('');
|
||||
log(chalk.green(' ✓ LiteRT-LM binary already installed at:'));
|
||||
log(chalk.dim(` ${binaryPath}`));
|
||||
} else {
|
||||
log('');
|
||||
log(' Downloading LiteRT-LM binary...');
|
||||
const downloadUrl = getBinaryDownloadUrl(platform.binaryName);
|
||||
debugLogger.log(`Downloading from: ${downloadUrl}`);
|
||||
|
||||
try {
|
||||
const binDir = path.dirname(binaryPath);
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
await downloadFile(downloadUrl, binaryPath);
|
||||
log(chalk.green(' ✓ Binary downloaded successfully'));
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to download binary: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
logError(' Check your internet connection and try again.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const expectedHash = PLATFORM_BINARY_SHA256[platform.binaryName];
|
||||
if (!expectedHash) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ No checksum is configured for ${platform.binaryName}. Refusing to install the binary.`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const checksumVerified = await verifyFileSha256(binaryPath, expectedHash);
|
||||
if (!checksumVerified) {
|
||||
logError(
|
||||
chalk.red(
|
||||
' ✗ Downloaded binary checksum did not match the expected release hash.',
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
log(chalk.green(' ✓ Binary checksum verified'));
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to verify binary checksum: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to set executable permission: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
execFileSync('xattr', ['-d', 'com.apple.quarantine', binaryPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
log(chalk.green(' ✓ macOS quarantine attribute removed'));
|
||||
} catch {
|
||||
// Expected if the attribute doesn't exist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!argv.skipModel) {
|
||||
const modelAlreadyDownloaded = isModelDownloaded(binaryPath);
|
||||
if (modelAlreadyDownloaded && !force) {
|
||||
log('');
|
||||
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} already downloaded`));
|
||||
} else {
|
||||
log('');
|
||||
log(` Downloading model ${GEMMA_MODEL_NAME}...`);
|
||||
log(chalk.dim(' You may be prompted to accept the Gemma Terms of Use.'));
|
||||
log('');
|
||||
|
||||
const exitCode = await spawnInherited(binaryPath, [
|
||||
'pull',
|
||||
GEMMA_MODEL_NAME,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
logError('');
|
||||
logError(
|
||||
chalk.red(` ✗ Model download failed (exit code ${exitCode})`),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
log('');
|
||||
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} downloaded`));
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
log(' Configuring settings...');
|
||||
try {
|
||||
const settings = loadSettings(process.cwd());
|
||||
|
||||
// User scope: security-sensitive settings that must not be overridable
|
||||
// by workspace configs (prevents arbitrary binary execution).
|
||||
const existingUserGemma =
|
||||
settings.forScope(SettingScope.User).settings.experimental
|
||||
?.gemmaModelRouter ?? {};
|
||||
autoStartServer = existingUserGemma.autoStartServer ?? true;
|
||||
const existingUserExperimental =
|
||||
settings.forScope(SettingScope.User).settings.experimental ?? {};
|
||||
settings.setValue(SettingScope.User, 'experimental', {
|
||||
...existingUserExperimental,
|
||||
gemmaModelRouter: {
|
||||
autoStartServer,
|
||||
...(existingUserGemma.binaryPath !== undefined
|
||||
? { binaryPath: existingUserGemma.binaryPath }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Workspace scope: project-isolated settings so the local model only
|
||||
// runs for this specific project, saving resources globally.
|
||||
const existingWorkspaceGemma =
|
||||
settings.forScope(SettingScope.Workspace).settings.experimental
|
||||
?.gemmaModelRouter ?? {};
|
||||
const existingWorkspaceExperimental =
|
||||
settings.forScope(SettingScope.Workspace).settings.experimental ?? {};
|
||||
settings.setValue(SettingScope.Workspace, 'experimental', {
|
||||
...existingWorkspaceExperimental,
|
||||
gemmaModelRouter: {
|
||||
...existingWorkspaceGemma,
|
||||
enabled: true,
|
||||
classifier: {
|
||||
...existingWorkspaceGemma.classifier,
|
||||
host: `http://localhost:${port}`,
|
||||
model: GEMMA_MODEL_NAME,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log(chalk.green(' ✓ Settings updated'));
|
||||
log(chalk.dim(' User (~/.gemini/settings.json): autoStartServer'));
|
||||
log(
|
||||
chalk.dim(' Workspace (.gemini/settings.json): enabled, classifier'),
|
||||
);
|
||||
settingsUpdated = true;
|
||||
} catch (error) {
|
||||
logError(
|
||||
chalk.red(
|
||||
` ✗ Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
logError(
|
||||
' You can manually add the configuration to ~/.gemini/settings.json',
|
||||
);
|
||||
}
|
||||
|
||||
if (argv.start) {
|
||||
log('');
|
||||
log(' Starting LiteRT server...');
|
||||
serverStarted = await startServer(binaryPath, port);
|
||||
if (serverStarted) {
|
||||
log(chalk.green(` ✓ Server started on port ${port}`));
|
||||
} else {
|
||||
log(
|
||||
chalk.yellow(
|
||||
` ! Server may not have started correctly. Check: gemini gemma status`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const routingActive = settingsUpdated && serverStarted;
|
||||
const setupSucceeded = settingsUpdated && (!argv.start || serverStarted);
|
||||
log('');
|
||||
log(chalk.dim('─'.repeat(40)));
|
||||
if (routingActive) {
|
||||
log(chalk.bold.green(' Setup complete! Local model routing is active.'));
|
||||
} else if (settingsUpdated) {
|
||||
log(
|
||||
chalk.bold.green(' Setup complete! Local model routing is configured.'),
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
chalk.bold.yellow(
|
||||
' Setup incomplete. Manual settings changes are still required.',
|
||||
),
|
||||
);
|
||||
}
|
||||
log('');
|
||||
log(' How it works: Every request is classified by the local Gemma model.');
|
||||
log(
|
||||
' Simple tasks (file reads, quick edits) route to ' +
|
||||
chalk.cyan('Flash') +
|
||||
' for speed.',
|
||||
);
|
||||
log(
|
||||
' Complex tasks (debugging, architecture) route to ' +
|
||||
chalk.cyan('Pro') +
|
||||
' for quality.',
|
||||
);
|
||||
log(' This happens automatically — just use the CLI as usual.');
|
||||
log('');
|
||||
if (!settingsUpdated) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Fix the settings update above, then rerun "gemini gemma status".',
|
||||
),
|
||||
);
|
||||
log('');
|
||||
} else if (!argv.start) {
|
||||
log(chalk.yellow(' Note: Run "gemini gemma start" to start the server.'));
|
||||
if (autoStartServer) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Or restart the CLI to auto-start it on the next launch.',
|
||||
),
|
||||
);
|
||||
}
|
||||
log('');
|
||||
} else if (!serverStarted) {
|
||||
log(
|
||||
chalk.yellow(
|
||||
' Review the server logs and rerun "gemini gemma start" after fixing the issue.',
|
||||
),
|
||||
);
|
||||
log('');
|
||||
}
|
||||
log(' Useful commands:');
|
||||
log(chalk.dim(' gemini gemma status Check routing status'));
|
||||
log(chalk.dim(' gemini gemma start Start the LiteRT server'));
|
||||
log(chalk.dim(' gemini gemma stop Stop the LiteRT server'));
|
||||
log(chalk.dim(' /gemma Check status inside a session'));
|
||||
log('');
|
||||
|
||||
return setupSucceeded ? 0 : 1;
|
||||
}
|
||||
|
||||
export const setupCommand: CommandModule = {
|
||||
command: 'setup',
|
||||
describe: 'Download and configure Gemma local model routing',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('port', {
|
||||
type: 'number',
|
||||
default: DEFAULT_PORT,
|
||||
description: 'Port for the LiteRT server',
|
||||
})
|
||||
.option('skip-model', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Skip model download (binary only)',
|
||||
})
|
||||
.option('start', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Start the server after setup',
|
||||
})
|
||||
.option('force', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Re-download binary and model even if already present',
|
||||
})
|
||||
.option('consent', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Skip interactive consent prompt (implies acceptance)',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const exitCode = await handleSetup({
|
||||
port: Number(argv['port']),
|
||||
skipModel: Boolean(argv['skipModel']),
|
||||
start: Boolean(argv['start']),
|
||||
force: Boolean(argv['force']),
|
||||
consent: Boolean(argv['consent']),
|
||||
});
|
||||
await exitCli(exitCode);
|
||||
},
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
getPidFilePath,
|
||||
getLogFilePath,
|
||||
getLiteRtBinDir,
|
||||
SERVER_START_WAIT_MS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isServerRunning,
|
||||
resolveGemmaConfig,
|
||||
writeServerProcessInfo,
|
||||
} from './platform.js';
|
||||
|
||||
export async function startServer(
|
||||
binaryPath: string,
|
||||
port: number,
|
||||
): Promise<boolean> {
|
||||
const alreadyRunning = await isServerRunning(port);
|
||||
if (alreadyRunning) {
|
||||
debugLogger.log(`LiteRT server already running on port ${port}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const logPath = getLogFilePath();
|
||||
fs.mkdirSync(getLiteRtBinDir(), { recursive: true });
|
||||
const tmpDir = path.dirname(getPidFilePath());
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const logFd = fs.openSync(logPath, 'a');
|
||||
|
||||
try {
|
||||
const child = spawn(binaryPath, ['serve', `--port=${port}`, '--verbose'], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
|
||||
if (child.pid) {
|
||||
writeServerProcessInfo({
|
||||
pid: child.pid,
|
||||
binaryPath,
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
child.unref();
|
||||
} finally {
|
||||
fs.closeSync(logFd);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SERVER_START_WAIT_MS));
|
||||
return isServerRunning(port);
|
||||
}
|
||||
|
||||
export const startCommand: CommandModule = {
|
||||
command: 'start',
|
||||
describe: 'Start the LiteRT-LM server',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port for the LiteRT server',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
port = configuredPort;
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
if (!binaryPath || !isBinaryInstalled(binaryPath)) {
|
||||
debugLogger.error(
|
||||
chalk.red(
|
||||
'LiteRT-LM binary not found. Run "gemini gemma setup" first.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRunning = await isServerRunning(port);
|
||||
if (alreadyRunning) {
|
||||
debugLogger.log(
|
||||
chalk.green(`LiteRT server is already running on port ${port}.`),
|
||||
);
|
||||
await exitCli(0);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(`Starting LiteRT server on port ${port}...`);
|
||||
|
||||
const started = await startServer(binaryPath, port);
|
||||
if (started) {
|
||||
debugLogger.log(chalk.green(`LiteRT server started on port ${port}.`));
|
||||
debugLogger.log(chalk.dim(`Logs: ${getLogFilePath()}`));
|
||||
await exitCli(0);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
chalk.red('Server may not have started correctly. Check logs:'),
|
||||
);
|
||||
debugLogger.error(chalk.dim(` ${getLogFilePath()}`));
|
||||
await exitCli(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import { DEFAULT_PORT, GEMMA_MODEL_NAME } from './constants.js';
|
||||
import {
|
||||
detectPlatform,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
isModelDownloaded,
|
||||
isServerRunning,
|
||||
readServerPid,
|
||||
isProcessRunning,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export interface GemmaStatusResult {
|
||||
binaryInstalled: boolean;
|
||||
binaryPath: string | null;
|
||||
modelDownloaded: boolean;
|
||||
serverRunning: boolean;
|
||||
serverPid: number | null;
|
||||
settingsEnabled: boolean;
|
||||
port: number;
|
||||
allPassing: boolean;
|
||||
}
|
||||
|
||||
export async function checkGemmaStatus(
|
||||
port?: number,
|
||||
): Promise<GemmaStatusResult> {
|
||||
const { settingsEnabled, configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
|
||||
const effectivePort = port ?? configuredPort;
|
||||
const binaryPath = getBinaryPath();
|
||||
const binaryInstalled = isBinaryInstalled(binaryPath);
|
||||
const modelDownloaded =
|
||||
binaryInstalled && binaryPath ? isModelDownloaded(binaryPath) : false;
|
||||
const serverRunning = await isServerRunning(effectivePort);
|
||||
const pid = readServerPid();
|
||||
const serverPid = pid && isProcessRunning(pid) ? pid : null;
|
||||
|
||||
const allPassing =
|
||||
binaryInstalled && modelDownloaded && serverRunning && settingsEnabled;
|
||||
|
||||
return {
|
||||
binaryInstalled,
|
||||
binaryPath,
|
||||
modelDownloaded,
|
||||
serverRunning,
|
||||
serverPid,
|
||||
settingsEnabled,
|
||||
port: effectivePort,
|
||||
allPassing,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatGemmaStatus(status: GemmaStatusResult): string {
|
||||
const check = (ok: boolean) => (ok ? chalk.green('✓') : chalk.red('✗'));
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
chalk.bold('Gemma Local Model Routing Status'),
|
||||
chalk.dim('─'.repeat(40)),
|
||||
'',
|
||||
];
|
||||
|
||||
if (status.binaryInstalled) {
|
||||
lines.push(` Binary: ${check(true)} Installed (${status.binaryPath})`);
|
||||
} else {
|
||||
const platform = detectPlatform();
|
||||
if (platform) {
|
||||
lines.push(` Binary: ${check(false)} Not installed`);
|
||||
lines.push(chalk.dim(` Run: gemini gemma setup`));
|
||||
} else {
|
||||
lines.push(
|
||||
` Binary: ${check(false)} Unsupported platform (${process.platform}-${process.arch})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.modelDownloaded) {
|
||||
lines.push(` Model: ${check(true)} ${GEMMA_MODEL_NAME} downloaded`);
|
||||
} else {
|
||||
lines.push(` Model: ${check(false)} ${GEMMA_MODEL_NAME} not found`);
|
||||
if (status.binaryInstalled) {
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
` Run: ${status.binaryPath} pull ${GEMMA_MODEL_NAME}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
lines.push(chalk.dim(` Run: gemini gemma setup`));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.serverRunning) {
|
||||
const pidInfo = status.serverPid ? ` (PID ${status.serverPid})` : '';
|
||||
lines.push(
|
||||
` Server: ${check(true)} Running on port ${status.port}${pidInfo}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
` Server: ${check(false)} Not running on port ${status.port}`,
|
||||
);
|
||||
lines.push(chalk.dim(` Run: gemini gemma start`));
|
||||
}
|
||||
|
||||
if (status.settingsEnabled) {
|
||||
lines.push(` Settings: ${check(true)} Enabled in settings.json`);
|
||||
} else {
|
||||
lines.push(` Settings: ${check(false)} Not enabled in settings.json`);
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
` Run: gemini gemma setup (auto-configures settings)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
if (status.allPassing) {
|
||||
lines.push(chalk.green(' Routing is active — no action needed.'));
|
||||
lines.push('');
|
||||
lines.push(
|
||||
chalk.dim(
|
||||
' Simple requests → Flash (fast) | Complex requests → Pro (powerful)',
|
||||
),
|
||||
);
|
||||
lines.push(chalk.dim(' This happens automatically on every request.'));
|
||||
} else {
|
||||
lines.push(
|
||||
chalk.yellow(
|
||||
' Some checks failed. Run "gemini gemma setup" for guided installation.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const statusCommand: CommandModule = {
|
||||
command: 'status',
|
||||
describe: 'Check Gemma local model routing status',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port to check for the LiteRT server',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
const status = await checkGemmaStatus(port);
|
||||
const output = formatGemmaStatus(status);
|
||||
process.stdout.write(output);
|
||||
await exitCli(status.allPassing ? 0 : 1);
|
||||
},
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockGetBinaryPath = vi.hoisted(() => vi.fn());
|
||||
const mockIsExpectedLiteRtServerProcess = vi.hoisted(() => vi.fn());
|
||||
const mockIsProcessRunning = vi.hoisted(() => vi.fn());
|
||||
const mockIsServerRunning = vi.hoisted(() => vi.fn());
|
||||
const mockReadServerPid = vi.hoisted(() => vi.fn());
|
||||
const mockReadServerProcessInfo = vi.hoisted(() => vi.fn());
|
||||
const mockResolveGemmaConfig = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
'../../test-utils/mockDebugLogger.js'
|
||||
);
|
||||
return mockCoreDebugLogger(
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>(),
|
||||
{
|
||||
stripAnsi: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock('./constants.js', () => ({
|
||||
DEFAULT_PORT: 9379,
|
||||
getPidFilePath: vi.fn(() => '/tmp/litert-server.pid'),
|
||||
}));
|
||||
|
||||
vi.mock('./platform.js', () => ({
|
||||
getBinaryPath: mockGetBinaryPath,
|
||||
isExpectedLiteRtServerProcess: mockIsExpectedLiteRtServerProcess,
|
||||
isProcessRunning: mockIsProcessRunning,
|
||||
isServerRunning: mockIsServerRunning,
|
||||
readServerPid: mockReadServerPid,
|
||||
readServerProcessInfo: mockReadServerProcessInfo,
|
||||
resolveGemmaConfig: mockResolveGemmaConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
import { stopServer } from './stop.js';
|
||||
|
||||
describe('gemma stop command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockGetBinaryPath.mockReturnValue('/custom/lit');
|
||||
mockResolveGemmaConfig.mockReturnValue({ configuredPort: 9379 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('refuses to signal a pid that does not match the expected LiteRT server', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValue(true);
|
||||
mockIsExpectedLiteRtServerProcess.mockReturnValue(false);
|
||||
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
await expect(stopServer(8123)).resolves.toBe('unexpected-process');
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the verified LiteRT server and removes the pid file', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
mockIsExpectedLiteRtServerProcess.mockReturnValue(true);
|
||||
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const stopPromise = stopServer(8123);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect(stopPromise).resolves.toBe('stopped');
|
||||
expect(killSpy).toHaveBeenCalledWith(1234, 'SIGTERM');
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
|
||||
});
|
||||
|
||||
it('cleans up a stale pid file when the recorded process is no longer running', async () => {
|
||||
mockReadServerProcessInfo.mockReturnValue({
|
||||
pid: 1234,
|
||||
binaryPath: '/custom/lit',
|
||||
port: 8123,
|
||||
});
|
||||
mockIsProcessRunning.mockReturnValue(false);
|
||||
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
||||
|
||||
await expect(stopServer(8123)).resolves.toBe('not-running');
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import fs from 'node:fs';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { DEFAULT_PORT, getPidFilePath } from './constants.js';
|
||||
import {
|
||||
getBinaryPath,
|
||||
isExpectedLiteRtServerProcess,
|
||||
isProcessRunning,
|
||||
isServerRunning,
|
||||
readServerPid,
|
||||
readServerProcessInfo,
|
||||
resolveGemmaConfig,
|
||||
} from './platform.js';
|
||||
|
||||
export type StopServerResult =
|
||||
| 'stopped'
|
||||
| 'not-running'
|
||||
| 'unexpected-process'
|
||||
| 'failed';
|
||||
|
||||
export async function stopServer(
|
||||
expectedPort?: number,
|
||||
): Promise<StopServerResult> {
|
||||
const processInfo = readServerProcessInfo();
|
||||
const pidPath = getPidFilePath();
|
||||
|
||||
if (!processInfo) {
|
||||
return 'not-running';
|
||||
}
|
||||
|
||||
const { pid } = processInfo;
|
||||
if (!isProcessRunning(pid)) {
|
||||
debugLogger.log(
|
||||
`Stale PID file found (PID ${pid} is not running), removing ${pidPath}`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(pidPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'not-running';
|
||||
}
|
||||
|
||||
const binaryPath = processInfo.binaryPath ?? getBinaryPath();
|
||||
const port = processInfo.port ?? expectedPort;
|
||||
if (!isExpectedLiteRtServerProcess(pid, { binaryPath, port })) {
|
||||
debugLogger.warn(
|
||||
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
|
||||
);
|
||||
return 'unexpected-process';
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (isProcessRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
if (isProcessRunning(pid)) {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(pidPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
export const stopCommand: CommandModule = {
|
||||
command: 'stop',
|
||||
describe: 'Stop the LiteRT-LM server',
|
||||
builder: (yargs) =>
|
||||
yargs.option('port', {
|
||||
type: 'number',
|
||||
description: 'Port where the LiteRT server is running',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
let port: number | undefined;
|
||||
if (argv['port'] !== undefined) {
|
||||
port = Number(argv['port']);
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
|
||||
port = configuredPort;
|
||||
}
|
||||
|
||||
const processInfo = readServerProcessInfo();
|
||||
const pid = processInfo?.pid ?? readServerPid();
|
||||
|
||||
if (pid !== null && isProcessRunning(pid)) {
|
||||
debugLogger.log(`Stopping LiteRT server (PID ${pid})...`);
|
||||
const result = await stopServer(port);
|
||||
if (result === 'stopped') {
|
||||
debugLogger.log(chalk.green('LiteRT server stopped.'));
|
||||
await exitCli(0);
|
||||
} else if (result === 'unexpected-process') {
|
||||
debugLogger.error(
|
||||
chalk.red(
|
||||
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
|
||||
),
|
||||
);
|
||||
debugLogger.error(
|
||||
chalk.dim(
|
||||
'Remove the stale pid file after verifying the process, or stop the process manually.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
} else {
|
||||
debugLogger.error(chalk.red('Failed to stop LiteRT server.'));
|
||||
await exitCli(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await isServerRunning(port);
|
||||
if (running) {
|
||||
debugLogger.log(
|
||||
chalk.yellow(
|
||||
`A server is responding on port ${port}, but it was not started by "gemini gemma start".`,
|
||||
),
|
||||
);
|
||||
debugLogger.log(
|
||||
chalk.dim(
|
||||
'If you started it manually, stop it from the terminal where it is running.',
|
||||
),
|
||||
);
|
||||
await exitCli(1);
|
||||
} else {
|
||||
debugLogger.log('No LiteRT server is currently running.');
|
||||
await exitCli(0);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -338,7 +338,6 @@ describe('parseArguments', () => {
|
||||
{ cmd: 'skill list', expected: true },
|
||||
{ cmd: 'hooks migrate', expected: true },
|
||||
{ cmd: 'hook migrate', expected: true },
|
||||
{ cmd: 'gemma status', expected: true },
|
||||
{ cmd: 'some query', expected: undefined },
|
||||
{ cmd: 'hello world', expected: undefined },
|
||||
])(
|
||||
@@ -759,12 +758,6 @@ describe('parseArguments', () => {
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for gemma command', async () => {
|
||||
process.argv = ['node', 'script.js', 'gemma', 'status'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig', () => {
|
||||
@@ -3037,8 +3030,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
experimental: {
|
||||
gemmaModelRouter: {
|
||||
enabled: true,
|
||||
autoStartServer: false,
|
||||
binaryPath: '/custom/lit',
|
||||
classifier: {
|
||||
host: 'http://custom:1234',
|
||||
model: 'custom-gemma',
|
||||
@@ -3049,24 +3040,10 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getGemmaModelRouterEnabled()).toBe(true);
|
||||
const gemmaSettings = config.getGemmaModelRouterSettings();
|
||||
expect(gemmaSettings.autoStartServer).toBe(false);
|
||||
expect(gemmaSettings.binaryPath).toBe('/custom/lit');
|
||||
expect(gemmaSettings.classifier?.host).toBe('http://custom:1234');
|
||||
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
|
||||
});
|
||||
|
||||
it('should load experimental.gemma setting from merged settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
gemma: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExperimentalGemma()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle partial gemmaModelRouter settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -3080,8 +3057,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getGemmaModelRouterEnabled()).toBe(true);
|
||||
const gemmaSettings = config.getGemmaModelRouterSettings();
|
||||
expect(gemmaSettings.autoStartServer).toBe(false);
|
||||
expect(gemmaSettings.binaryPath).toBe('');
|
||||
expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379');
|
||||
expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom');
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ 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 { gemmaCommand } from '../commands/gemma.js';
|
||||
import {
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
FileDiscoveryService,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
getPty,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -61,7 +59,6 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { isRecord } from '../utils/settingsUtils.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -108,7 +105,6 @@ export interface CliArgs {
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
skipTrust: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -185,7 +181,6 @@ export async function parseArguments(
|
||||
extensionsCommand,
|
||||
skillsCommand,
|
||||
hooksCommand,
|
||||
gemmaCommand,
|
||||
];
|
||||
|
||||
const subcommands = commandModules.flatMap((mod) => {
|
||||
@@ -265,7 +260,6 @@ export async function parseArguments(
|
||||
yargsInstance.command(extensionsCommand);
|
||||
yargsInstance.command(skillsCommand);
|
||||
yargsInstance.command(hooksCommand);
|
||||
yargsInstance.command(gemmaCommand);
|
||||
|
||||
yargsInstance
|
||||
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
@@ -294,11 +288,6 @@ export async function parseArguments(
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('skip-trust', {
|
||||
type: 'boolean',
|
||||
description: 'Trust the current workspace for this session.',
|
||||
default: false,
|
||||
})
|
||||
.option('worktree', {
|
||||
alias: 'w',
|
||||
type: 'string',
|
||||
@@ -467,16 +456,9 @@ export async function parseArguments(
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
let result;
|
||||
try {
|
||||
const parsed = await yargsInstance.parse();
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Failed to parse arguments');
|
||||
}
|
||||
result = parsed;
|
||||
if (result['skip-trust']) {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
}
|
||||
result = await yargsInstance.parse();
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
debugLogger.error(msg);
|
||||
yargsInstance.showHelp();
|
||||
await runExitCleanup();
|
||||
@@ -490,13 +472,11 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
const queryArg = result['query'];
|
||||
let q: string | undefined;
|
||||
if (Array.isArray(queryArg)) {
|
||||
q = queryArg.join(' ');
|
||||
} else if (typeof queryArg === 'string') {
|
||||
q = queryArg;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
@@ -511,8 +491,8 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
result['query'] = q || undefined;
|
||||
result['startupMessages'] = startupMessages;
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -564,7 +544,7 @@ export async function loadCliConfig(
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder =
|
||||
isWorkspaceTrusted(settings, cwd, {
|
||||
isWorkspaceTrusted(settings, cwd, undefined, {
|
||||
prompt: argv.prompt,
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
@@ -610,7 +590,7 @@ export async function loadCliConfig(
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -634,7 +614,7 @@ export async function loadCliConfig(
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
let extensionRegistryURI =
|
||||
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
|
||||
@@ -1008,15 +988,11 @@ export async function loadCliConfig(
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext,
|
||||
experimentalMemoryV2: settings.experimental?.memoryV2,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
experimentalGemma: settings.experimental?.gemma,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration:
|
||||
settings.general?.topicUpdateNarration ??
|
||||
settings.experimental?.topicUpdateNarration,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
@@ -1050,7 +1026,6 @@ export async function loadCliConfig(
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
billing: settings.billing,
|
||||
vertexAiRouting: settings.billing?.vertexAi,
|
||||
maxAttempts: settings.general?.maxAttempts,
|
||||
ptyInfo: ptyInfo?.name,
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
@@ -1117,7 +1092,7 @@ async function resolveWorktreeSettings(
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -1421,7 +1420,6 @@ name = "yolo-checker"
|
||||
'.gemini',
|
||||
'trustedFolders.json',
|
||||
);
|
||||
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
@@ -1440,9 +1438,7 @@ name = "yolo-checker"
|
||||
const trustedFolders = JSON.parse(
|
||||
fs.readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
|
||||
@@ -1912,9 +1912,6 @@ describe('Settings Loading and Merging', () => {
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
);
|
||||
const workspaceEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, '.env'),
|
||||
);
|
||||
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
@@ -1922,11 +1919,9 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
return [
|
||||
path.resolve(USER_SETTINGS_PATH),
|
||||
geminiEnvPath,
|
||||
workspaceEnvPath,
|
||||
].includes(normalizedP);
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
@@ -1946,7 +1941,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
@@ -1975,7 +1970,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -1983,8 +1978,7 @@ describe('Settings Loading and Merging', () => {
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
|
||||
@@ -78,12 +78,7 @@ export function getMergeStrategyForPath(
|
||||
|
||||
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',
|
||||
'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND',
|
||||
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS',
|
||||
];
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const AUTH_ENV_VAR_WHITELIST = [
|
||||
'GEMINI_API_KEY',
|
||||
@@ -499,15 +494,13 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
if (isTrusted) {
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -516,11 +509,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
if (isTrusted) {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
@@ -563,10 +554,10 @@ export function loadEnvironment(
|
||||
workspaceDir: string,
|
||||
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
||||
): void {
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
// that happens early in the CLI lifecycle. It is designed to detect the
|
||||
@@ -601,8 +592,8 @@ export function loadEnvironment(
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted, only allow whitelisted variables.
|
||||
if (!isTrusted) {
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -138,10 +138,6 @@ describe('SettingsSchema', () => {
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableFileWatcher,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths,
|
||||
@@ -317,22 +313,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should have Vertex AI routing settings in schema', () => {
|
||||
const vertexAi =
|
||||
getSettingsSchema().billing.properties.vertexAi.properties;
|
||||
|
||||
expect(vertexAi.requestType).toBeDefined();
|
||||
expect(vertexAi.requestType.type).toBe('enum');
|
||||
expect(
|
||||
vertexAi.requestType.options?.map((option) => option.value),
|
||||
).toEqual(['dedicated', 'shared']);
|
||||
expect(vertexAi.sharedRequestType).toBeDefined();
|
||||
expect(vertexAi.sharedRequestType.type).toBe('enum');
|
||||
expect(
|
||||
vertexAi.sharedRequestType.options?.map((option) => option.value),
|
||||
).toEqual(['priority', 'flex']);
|
||||
});
|
||||
|
||||
it('should have folderTrustFeature setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled,
|
||||
@@ -491,33 +471,11 @@ describe('SettingsSchema', () => {
|
||||
expect(enabled.category).toBe('Experimental');
|
||||
expect(enabled.default).toBe(false);
|
||||
expect(enabled.requiresRestart).toBe(true);
|
||||
expect(enabled.showInDialog).toBe(true);
|
||||
expect(enabled.showInDialog).toBe(false);
|
||||
expect(enabled.description).toBe(
|
||||
'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
|
||||
);
|
||||
|
||||
const autoStartServer = gemmaModelRouter.properties.autoStartServer;
|
||||
expect(autoStartServer).toBeDefined();
|
||||
expect(autoStartServer.type).toBe('boolean');
|
||||
expect(autoStartServer.category).toBe('Experimental');
|
||||
expect(autoStartServer.default).toBe(false);
|
||||
expect(autoStartServer.requiresRestart).toBe(true);
|
||||
expect(autoStartServer.showInDialog).toBe(true);
|
||||
expect(autoStartServer.description).toBe(
|
||||
'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.',
|
||||
);
|
||||
|
||||
const binaryPath = gemmaModelRouter.properties.binaryPath;
|
||||
expect(binaryPath).toBeDefined();
|
||||
expect(binaryPath.type).toBe('string');
|
||||
expect(binaryPath.category).toBe('Experimental');
|
||||
expect(binaryPath.default).toBe('');
|
||||
expect(binaryPath.requiresRestart).toBe(true);
|
||||
expect(binaryPath.showInDialog).toBe(false);
|
||||
expect(binaryPath.description).toBe(
|
||||
'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).',
|
||||
);
|
||||
|
||||
const classifier = gemmaModelRouter.properties.classifier;
|
||||
expect(classifier).toBeDefined();
|
||||
expect(classifier.type).toBe('object');
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type AgentOverride,
|
||||
type CustomTheme,
|
||||
type SandboxConfig,
|
||||
type VertexAiRoutingConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js';
|
||||
@@ -257,29 +256,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
enableNotifications: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Terminal Notifications',
|
||||
label: 'Enable Notifications',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable terminal run-event notifications for action-required prompts and session completion.',
|
||||
'Enable run-event notifications for action-required prompts and session completion.',
|
||||
showInDialog: true,
|
||||
},
|
||||
notificationMethod: {
|
||||
type: 'enum',
|
||||
label: 'Terminal Notification Method',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description: 'How to send terminal notifications.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'osc9', label: 'OSC 9' },
|
||||
{ value: 'osc777', label: 'OSC 777' },
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
],
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
@@ -419,16 +403,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Enable the Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -991,45 +965,6 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'never', label: 'Never use credits' },
|
||||
],
|
||||
},
|
||||
vertexAi: {
|
||||
type: 'object',
|
||||
label: 'Vertex AI',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as VertexAiRoutingConfig | undefined,
|
||||
description: 'Vertex AI request routing settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
requestType: {
|
||||
type: 'enum',
|
||||
label: 'Vertex AI Request Type',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as VertexAiRoutingConfig['requestType'],
|
||||
description:
|
||||
'Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI requests.',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'dedicated', label: 'Dedicated' },
|
||||
{ value: 'shared', label: 'Shared' },
|
||||
],
|
||||
},
|
||||
sharedRequestType: {
|
||||
type: 'enum',
|
||||
label: 'Vertex AI Shared Request Type',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as VertexAiRoutingConfig['sharedRequestType'],
|
||||
description:
|
||||
'Sets the X-Vertex-AI-LLM-Shared-Request-Type header for Vertex AI requests.',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'flex', label: 'Flex' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1471,17 +1406,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Respect .geminiignore files when searching.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableFileWatcher: {
|
||||
type: 'boolean',
|
||||
label: 'Enable File Watcher',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Enable file watcher updates for @ file suggestions (experimental).
|
||||
`,
|
||||
showInDialog: false,
|
||||
},
|
||||
enableRecursiveFileSearch: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Recursive File Search',
|
||||
@@ -1667,19 +1591,6 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
confirmationRequired: {
|
||||
type: 'array',
|
||||
label: 'Confirmation Required',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: oneLine`
|
||||
Tool names that always require user confirmation.
|
||||
Takes precedence over allowed tools and core tool allowlists.
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
@@ -2052,15 +1963,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
gemma: {
|
||||
type: 'boolean',
|
||||
label: 'Gemma Models',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
adk: {
|
||||
type: 'object',
|
||||
label: 'ADK',
|
||||
@@ -2162,9 +2064,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
|
||||
default: false,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
@@ -2243,26 +2144,6 @@ const SETTINGS_SCHEMA = {
|
||||
default: false,
|
||||
description:
|
||||
'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoStartServer: {
|
||||
type: 'boolean',
|
||||
label: 'Auto-start LiteRT Server',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
binaryPath: {
|
||||
type: 'string',
|
||||
label: 'LiteRT Binary Path',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: '',
|
||||
description:
|
||||
'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).',
|
||||
showInDialog: false,
|
||||
},
|
||||
classifier: {
|
||||
@@ -2297,24 +2178,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
memoryV2: {
|
||||
memoryManager: {
|
||||
type: 'boolean',
|
||||
label: 'Memory v2',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoMemory: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Memory',
|
||||
label: 'Memory Manager Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generalistProfile: {
|
||||
@@ -2342,8 +2213,9 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Deprecated: Use general.topicUpdateNarration instead.',
|
||||
showInDialog: false,
|
||||
description:
|
||||
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -3094,11 +2966,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
description: 'Protocol for OTLP exporters.',
|
||||
enum: ['grpc', 'http'],
|
||||
},
|
||||
traces: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether detailed traces with large attributes are captured.',
|
||||
},
|
||||
logPrompts: {
|
||||
type: 'boolean',
|
||||
description: 'Whether prompts are logged in telemetry payloads.',
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as os from 'node:os';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
normalizePath,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -32,14 +32,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: Object.assign(
|
||||
Object.create(Object.getPrototypeOf(actual.coreEvents)),
|
||||
actual.coreEvents,
|
||||
{
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
),
|
||||
FatalConfigError: actual.FatalConfigError,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,7 +53,6 @@ describe('Trusted Folders', () => {
|
||||
// Reset the internal state
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.clearAllMocks();
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,14 +70,8 @@ describe('Trusted Folders', () => {
|
||||
|
||||
// Start two concurrent calls
|
||||
// These will race to acquire the lock on the real file system
|
||||
const p1 = loadedFolders.setValue(
|
||||
path.resolve('/path1'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p2 = loadedFolders.setValue(
|
||||
path.resolve('/path2'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
|
||||
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
@@ -92,8 +80,8 @@ describe('Trusted Folders', () => {
|
||||
const config = JSON.parse(content);
|
||||
|
||||
expect(config).toEqual({
|
||||
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
|
||||
'/path1': TrustLevel.TRUST_FOLDER,
|
||||
'/path2': TrustLevel.TRUST_FOLDER,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,16 +95,13 @@ describe('Trusted Folders', () => {
|
||||
|
||||
it('should load rules from the configuration file', () => {
|
||||
const config = {
|
||||
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
path: normalizePath('/user/folder'),
|
||||
trustLevel: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -158,14 +143,14 @@ describe('Trusted Folders', () => {
|
||||
const content = `
|
||||
{
|
||||
// This is a comment
|
||||
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
|
||||
"/path": "TRUST_FOLDER"
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -231,18 +216,15 @@ describe('Trusted Folders', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
await loadedFolders.setValue(
|
||||
normalizePath('/new/path'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
|
||||
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
|
||||
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError if there were load errors', async () => {
|
||||
@@ -255,6 +237,28 @@ describe('Trusted Folders', () => {
|
||||
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
|
||||
).rejects.toThrow(FatalConfigError);
|
||||
});
|
||||
|
||||
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
|
||||
// Initialize with valid JSON
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
// Corrupt the file after initial load
|
||||
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
|
||||
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('may be corrupted'),
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Should have overwritten the corrupted file with new valid config
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted Integration', () => {
|
||||
@@ -423,28 +427,16 @@ describe('Trusted Folders', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: undefined,
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
try {
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'env',
|
||||
});
|
||||
} finally {
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to config when isHeadlessMode is false', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
|
||||
@@ -457,12 +449,12 @@ describe('Trusted Folders', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,29 +4,330 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
type HeadlessModeOptions,
|
||||
checkPathTrust,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
loadTrustedFolders as loadCoreTrustedFolders,
|
||||
type LoadedTrustedFolders,
|
||||
coreEvents,
|
||||
type HeadlessModeOptions,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
export {
|
||||
TrustLevel,
|
||||
isTrustLevel,
|
||||
resetTrustedFoldersForTesting,
|
||||
saveTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
const { promises: fsPromises } = fs;
|
||||
|
||||
export type {
|
||||
TrustRule,
|
||||
TrustedFoldersError,
|
||||
TrustedFoldersFile,
|
||||
TrustResult,
|
||||
LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
|
||||
export function getUserSettingsDir(): string {
|
||||
return path.join(homedir(), GEMINI_DIR);
|
||||
}
|
||||
|
||||
export function getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
||||
export enum TrustLevel {
|
||||
TRUST_FOLDER = 'TRUST_FOLDER',
|
||||
TRUST_PARENT = 'TRUST_PARENT',
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrustRule {
|
||||
path: string;
|
||||
trustLevel: TrustLevel;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersError {
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersFile {
|
||||
config: Record<string, TrustLevel>;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrustResult {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | undefined;
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Parses the trusted folders JSON content, stripping comments.
|
||||
*/
|
||||
function parseTrustedFoldersJson(content: string): unknown {
|
||||
return JSON.parse(stripJsonComments(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
*/
|
||||
export function clearRealPathCacheForTesting(): void {
|
||||
realPathCache.clear();
|
||||
}
|
||||
|
||||
function getRealPath(location: string): string {
|
||||
let realPath = realPathCache.get(location);
|
||||
if (realPath !== undefined) {
|
||||
return realPath;
|
||||
}
|
||||
|
||||
try {
|
||||
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
|
||||
} catch {
|
||||
realPath = location;
|
||||
}
|
||||
|
||||
realPathCache.set(location, realPath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export class LoadedTrustedFolders {
|
||||
constructor(
|
||||
readonly user: TrustedFoldersFile,
|
||||
readonly errors: TrustedFoldersError[],
|
||||
) {}
|
||||
|
||||
get rules(): TrustRule[] {
|
||||
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
|
||||
path,
|
||||
trustLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true or false if the path should be "trusted". This function
|
||||
* should only be invoked when the folder trust setting is active.
|
||||
*
|
||||
* @param location path
|
||||
* @returns
|
||||
*/
|
||||
isPathTrusted(
|
||||
location: string,
|
||||
config?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): boolean | undefined {
|
||||
if (isHeadlessMode(headlessOptions)) {
|
||||
return true;
|
||||
}
|
||||
const configToUse = config ?? this.user.config;
|
||||
|
||||
// Resolve location to its realpath for canonical comparison
|
||||
const realLocation = getRealPath(location);
|
||||
|
||||
let longestMatchLen = -1;
|
||||
let longestMatchTrust: TrustLevel | undefined = undefined;
|
||||
|
||||
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
|
||||
const effectivePath =
|
||||
trustLevel === TrustLevel.TRUST_PARENT
|
||||
? path.dirname(rulePath)
|
||||
: rulePath;
|
||||
|
||||
// Resolve effectivePath to its realpath for canonical comparison
|
||||
const realEffectivePath = getRealPath(effectivePath);
|
||||
|
||||
if (isWithinRoot(realLocation, realEffectivePath)) {
|
||||
if (rulePath.length > longestMatchLen) {
|
||||
longestMatchLen = rulePath.length;
|
||||
longestMatchTrust = trustLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
|
||||
if (this.errors.length > 0) {
|
||||
const errorMessages = this.errors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dirPath = path.dirname(this.user.path);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// lockfile requires the file to exist
|
||||
if (!fs.existsSync(this.user.path)) {
|
||||
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const release = await lock(this.user.path, {
|
||||
retries: {
|
||||
retries: 10,
|
||||
minTimeout: 100,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Re-read the file to handle concurrent updates
|
||||
const content = await fsPromises.readFile(this.user.path, 'utf-8');
|
||||
let config: Record<string, TrustLevel>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
|
||||
error,
|
||||
);
|
||||
config = {};
|
||||
}
|
||||
|
||||
const originalTrustLevel = config[folderPath];
|
||||
config[folderPath] = trustLevel;
|
||||
this.user.config[folderPath] = trustLevel;
|
||||
|
||||
try {
|
||||
saveTrustedFolders({ ...this.user, config });
|
||||
} catch (e) {
|
||||
// Revert the in-memory change if the save failed.
|
||||
if (originalTrustLevel === undefined) {
|
||||
delete this.user.config[folderPath];
|
||||
} else {
|
||||
this.user.config[folderPath] = originalTrustLevel;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Resets the in-memory cache of the trusted folders configuration.
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
if (loadedTrustedFolders) {
|
||||
return loadedTrustedFolders;
|
||||
}
|
||||
|
||||
const errors: TrustedFoldersError[] = [];
|
||||
const userConfig: Record<string, TrustLevel> = {};
|
||||
|
||||
const userPath = getTrustedFoldersPath();
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
errors.push({
|
||||
message: 'Trusted folders file is not a valid JSON object.',
|
||||
path: userPath,
|
||||
});
|
||||
} else {
|
||||
for (const [path, trustLevel] of Object.entries(parsed)) {
|
||||
if (isTrustLevel(trustLevel)) {
|
||||
userConfig[path] = trustLevel;
|
||||
} else {
|
||||
const possibleValues = Object.values(TrustLevel).join(', ');
|
||||
errors.push({
|
||||
message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`,
|
||||
path: userPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
});
|
||||
}
|
||||
|
||||
loadedTrustedFolders = new LoadedTrustedFolders(
|
||||
{ path: userPath, config: userConfig },
|
||||
errors,
|
||||
);
|
||||
return loadedTrustedFolders;
|
||||
}
|
||||
|
||||
export function saveTrustedFolders(
|
||||
trustedFoldersFile: TrustedFoldersFile,
|
||||
): void {
|
||||
// Ensure the directory exists
|
||||
const dirPath = path.dirname(trustedFoldersFile.path);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
|
||||
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tempPath, trustedFoldersFile.path);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it was created but rename failed
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
@@ -34,24 +335,57 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
return loadCoreTrustedFolders();
|
||||
function getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir: string,
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): TrustResult {
|
||||
const folders = loadTrustedFolders();
|
||||
const configToUse = trustConfig ?? folders.user.config;
|
||||
|
||||
if (folders.errors.length > 0) {
|
||||
const errorMessages = folders.errors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
const isTrusted = folders.isPathTrusted(
|
||||
workspaceDir,
|
||||
configToUse,
|
||||
headlessOptions,
|
||||
);
|
||||
return {
|
||||
isTrusted,
|
||||
source: isTrusted !== undefined ? 'file' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true or false if the workspace is considered "trusted".
|
||||
*/
|
||||
export function isWorkspaceTrusted(
|
||||
settings: Settings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | 'env' | undefined;
|
||||
} {
|
||||
return checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: isFolderTrustEnabled(settings),
|
||||
isHeadless: isHeadlessMode(headlessOptions),
|
||||
});
|
||||
): TrustResult {
|
||||
if (isHeadlessMode(headlessOptions)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
|
||||
if (ideTrust !== undefined) {
|
||||
return { isTrusted: ideTrust, source: 'ide' };
|
||||
}
|
||||
|
||||
// Fall back to the local user configuration
|
||||
return getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir,
|
||||
trustConfig,
|
||||
headlessOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@ describe('gemini.tsx main function', () => {
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -556,7 +555,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -615,7 +613,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -532,7 +532,7 @@ export async function main() {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -612,23 +612,6 @@ export async function main() {
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
initAppHandle?.end();
|
||||
|
||||
import('./services/liteRtServerManager.js')
|
||||
.then(({ LiteRtServerManager }) => {
|
||||
const mergedGemma = settings.merged.experimental?.gemmaModelRouter;
|
||||
if (!mergedGemma) return;
|
||||
// Security: binaryPath and autoStartServer must come from user-scoped
|
||||
// settings only to prevent workspace configs from triggering arbitrary
|
||||
// binary execution.
|
||||
const userGemma = settings.forScope(SettingScope.User).settings
|
||||
.experimental?.gemmaModelRouter;
|
||||
return LiteRtServerManager.ensureRunning({
|
||||
...mergedGemma,
|
||||
binaryPath: userGemma?.binaryPath,
|
||||
autoStartServer: userGemma?.autoStartServer,
|
||||
});
|
||||
})
|
||||
.catch((e) => debugLogger.warn('LiteRT auto-start import failed:', e));
|
||||
|
||||
if (
|
||||
settings.merged.security.auth.selectedType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE &&
|
||||
@@ -661,12 +644,6 @@ export async function main() {
|
||||
|
||||
cliStartupHandle?.end();
|
||||
|
||||
if (!config.isInteractive()) {
|
||||
for (const warning of startupWarnings) {
|
||||
writeToStderr(warning.message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
// Earlier initialization phases (like TerminalCapabilityManager resolving
|
||||
|
||||
@@ -181,7 +181,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -306,7 +305,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
getExperimentalGemma: vi.fn(() => false),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function startInteractiveUI(
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, Math.round(renderTime));
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive({
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -61,7 +61,6 @@ import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -222,7 +221,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
gemmaStatusCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('SkillCommandLoader', () => {
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
toolArgs: { name: 'test-skill' },
|
||||
postSubmitPrompt: 'Use the skill test-skill',
|
||||
postSubmitPrompt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,10 +46,7 @@ export class SkillCommandLoader implements ICommandLoader {
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
toolArgs: { name: skill.name },
|
||||
postSubmitPrompt:
|
||||
args.trim().length > 0
|
||||
? args.trim()
|
||||
: `Use the skill ${skill.name}`,
|
||||
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GemmaModelRouterSettings } from '@google/gemini-cli-core';
|
||||
|
||||
const mockGetBinaryPath = vi.hoisted(() => vi.fn());
|
||||
const mockIsServerRunning = vi.hoisted(() => vi.fn());
|
||||
const mockStartServer = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../commands/gemma/platform.js', () => ({
|
||||
getBinaryPath: mockGetBinaryPath,
|
||||
isServerRunning: mockIsServerRunning,
|
||||
}));
|
||||
|
||||
vi.mock('../commands/gemma/start.js', () => ({
|
||||
startServer: mockStartServer,
|
||||
}));
|
||||
|
||||
import { LiteRtServerManager } from './liteRtServerManager.js';
|
||||
|
||||
describe('LiteRtServerManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
mockIsServerRunning.mockResolvedValue(false);
|
||||
mockStartServer.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('uses the configured custom binary path when auto-starting', async () => {
|
||||
mockGetBinaryPath.mockReturnValue('/user/lit');
|
||||
|
||||
const settings: GemmaModelRouterSettings = {
|
||||
enabled: true,
|
||||
binaryPath: '/workspace/evil',
|
||||
classifier: {
|
||||
host: 'http://localhost:8123',
|
||||
},
|
||||
};
|
||||
|
||||
await LiteRtServerManager.ensureRunning(settings);
|
||||
|
||||
expect(mockGetBinaryPath).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/user/lit');
|
||||
expect(mockStartServer).toHaveBeenCalledWith('/user/lit', 8123);
|
||||
});
|
||||
|
||||
it('falls back to the default binary path when no custom path is configured', async () => {
|
||||
mockGetBinaryPath.mockReturnValue('/default/lit');
|
||||
|
||||
const settings: GemmaModelRouterSettings = {
|
||||
enabled: true,
|
||||
classifier: {
|
||||
host: 'http://localhost:9379',
|
||||
},
|
||||
};
|
||||
|
||||
await LiteRtServerManager.ensureRunning(settings);
|
||||
|
||||
expect(mockGetBinaryPath).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/default/lit');
|
||||
expect(mockStartServer).toHaveBeenCalledWith('/default/lit', 9379);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import type { GemmaModelRouterSettings } from '@google/gemini-cli-core';
|
||||
import { getBinaryPath, isServerRunning } from '../commands/gemma/platform.js';
|
||||
import { DEFAULT_PORT } from '../commands/gemma/constants.js';
|
||||
|
||||
export class LiteRtServerManager {
|
||||
static async ensureRunning(
|
||||
gemmaSettings: GemmaModelRouterSettings | undefined,
|
||||
): Promise<void> {
|
||||
if (!gemmaSettings?.enabled) return;
|
||||
if (gemmaSettings.autoStartServer === false) return;
|
||||
const binaryPath = getBinaryPath();
|
||||
if (!binaryPath || !fs.existsSync(binaryPath)) {
|
||||
debugLogger.log(
|
||||
'[LiteRtServerManager] Binary not installed, skipping auto-start. Run "gemini gemma setup".',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const port =
|
||||
parseInt(
|
||||
gemmaSettings.classifier?.host?.match(/:(\d+)/)?.[1] ?? '',
|
||||
10,
|
||||
) || DEFAULT_PORT;
|
||||
|
||||
const running = await isServerRunning(port);
|
||||
if (running) {
|
||||
debugLogger.log(
|
||||
`[LiteRtServerManager] Server already running on port ${port}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`[LiteRtServerManager] Auto-starting LiteRT server on port ${port}...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const { startServer } = await import('../commands/gemma/start.js');
|
||||
const started = await startServer(binaryPath, port);
|
||||
if (started) {
|
||||
debugLogger.log(`[LiteRtServerManager] Server started on port ${port}`);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`[LiteRtServerManager] Server may not have started correctly on port ${port}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn('[LiteRtServerManager] Auto-start failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ describe('ShellProcessor', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this as unknown as Config;
|
||||
},
|
||||
|
||||
@@ -38,14 +38,12 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
isMemoryV2Enabled: vi.fn(() => false),
|
||||
isAutoMemoryEnabled: vi.fn(() => false),
|
||||
isMemoryManagerEnabled: vi.fn(() => false),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
setSessionId: vi.fn(),
|
||||
resetNewSessionState: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||
getWorktreeSettings: vi.fn(() => undefined),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||
@@ -89,7 +87,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
|
||||
getTelemetryTarget: vi.fn().mockReturnValue(''),
|
||||
@@ -168,7 +165,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user