Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9ba38ed5c | |||
| c4b38a5aef | |||
| 3f82b001d2 | |||
| 3dc8e7e13c | |||
| 571ca5a555 | |||
| 2eebc2980b | |||
| e96eb82a13 | |||
| d4c5333dcf | |||
| 85f99d174f | |||
| 1f73ec70c5 | |||
| 69150e87b2 | |||
| 27927c55e5 | |||
| c024064f47 | |||
| ff28d55100 | |||
| dba9b9a0ff | |||
| a007f64d20 | |||
| d1c91f5267 | |||
| aa05b4583d | |||
| 5318610c1d | |||
| 9c0a6864da | |||
| 2e12c34009 | |||
| 2a52611e71 | |||
| 1c43deee07 | |||
| 0758a5eb28 | |||
| 607180bfb2 | |||
| 6edfba481f | |||
| ffb28c772b | |||
| d6f88f8720 | |||
| 194c779f9b | |||
| 189c0ac0a0 | |||
| c47233a474 | |||
| 8999a885f0 | |||
| 93a8d9001c | |||
| cdc5cccc13 | |||
| a4e98c0a4c | |||
| c260550146 | |||
| 7f8f3309a6 | |||
| ebebbbfc20 | |||
| 27344833cb | |||
| aee2cde1a3 | |||
| 2c14954010 | |||
| a38e2f0048 | |||
| 1d383a4a8e | |||
| 6afc47f81c |
@@ -2,7 +2,8 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true
|
||||
"autoMemory": true,
|
||||
"gemma": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,6 +98,7 @@ 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,6 +167,7 @@ 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'
|
||||
@@ -212,6 +213,7 @@ 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'
|
||||
@@ -288,6 +290,7 @@ 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'
|
||||
|
||||
@@ -179,6 +179,7 @@ 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"
|
||||
@@ -267,6 +268,7 @@ 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
|
||||
@@ -430,6 +432,7 @@ 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,6 +62,7 @@ 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 }}'
|
||||
@@ -105,6 +106,7 @@ 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'
|
||||
@@ -159,6 +161,7 @@ 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'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: '🧠 Gemini CLI Bot: Brain'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Every 24 hours
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
brain:
|
||||
name: 'Brain (Reasoning Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Gemini CLI'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Download Previous Metrics'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'tools/gemini-cli-bot/history/'
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,59 @@
|
||||
name: '🔄 Gemini CLI Bot: Pulse'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *' # Every 30 minutes
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
pulse:
|
||||
name: 'Pulse (Reflex Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Collect Metrics'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npm run metrics'
|
||||
|
||||
- name: 'Archive Metrics'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'metrics-before.csv'
|
||||
|
||||
- name: 'Run Reflex Processes'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ -d "tools/gemini-cli-bot/processes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/processes/scripts)" ]; then
|
||||
for script in tools/gemini-cli-bot/processes/scripts/*.ts; do
|
||||
echo "Running reflex script: $script"
|
||||
npx tsx "$script"
|
||||
done
|
||||
else
|
||||
echo "No reflex scripts found."
|
||||
fi
|
||||
@@ -141,6 +141,7 @@ 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
|
||||
|
||||
@@ -371,6 +371,8 @@ 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.1
|
||||
# Latest stable release: v0.38.2
|
||||
|
||||
Released: April 15, 2026
|
||||
Released: April 17, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,6 +29,9 @@ 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)
|
||||
@@ -268,4 +271,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.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
|
||||
@@ -52,6 +52,7 @@ 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.** |
|
||||
|
||||
@@ -331,7 +331,6 @@ 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"
|
||||
@@ -360,7 +359,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -161,17 +161,20 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| 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` |
|
||||
| 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 |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| 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` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,17 +35,18 @@ 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` |
|
||||
| `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` |
|
||||
| `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 | - |
|
||||
|
||||
**Note on boolean environment variables:** For boolean settings like `enabled`,
|
||||
setting the environment variable to `true` or `1` enables the feature.
|
||||
@@ -1235,6 +1236,12 @@ 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,6 +100,34 @@ 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
|
||||
|
||||
@@ -436,6 +436,20 @@ 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):
|
||||
@@ -549,6 +563,18 @@ 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": {
|
||||
@@ -820,6 +846,28 @@ 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,
|
||||
@@ -890,6 +938,12 @@ 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": [
|
||||
@@ -1359,6 +1413,12 @@ 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.
|
||||
@@ -1447,6 +1507,12 @@ 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`
|
||||
@@ -1620,6 +1686,11 @@ 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`
|
||||
@@ -1668,8 +1739,10 @@ 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.
|
||||
- **Default:** `false`
|
||||
- **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`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
@@ -1711,6 +1784,18 @@ 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"`
|
||||
@@ -1722,11 +1807,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"gemma3-1b-gpu-custom"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`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`
|
||||
- **`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):
|
||||
@@ -1986,6 +2077,8 @@ 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.
|
||||
@@ -2114,6 +2207,14 @@ 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
|
||||
@@ -2186,6 +2287,10 @@ 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.
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
folderTrust: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
folderTrust: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
folderTrust: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -305,7 +305,7 @@ describe('plan_mode', () => {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'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.',
|
||||
'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.',
|
||||
assert: async (rig, result) => {
|
||||
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(
|
||||
@@ -376,4 +376,48 @@ 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
@@ -40,6 +45,11 @@ 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) => {
|
||||
@@ -61,6 +71,11 @@ 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) => {
|
||||
@@ -83,6 +98,11 @@ 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) => {
|
||||
@@ -108,6 +128,11 @@ 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) => {
|
||||
@@ -129,6 +154,11 @@ 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) => {
|
||||
@@ -151,6 +181,11 @@ 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(
|
||||
@@ -180,6 +215,11 @@ 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) => {
|
||||
@@ -202,6 +242,11 @@ 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(
|
||||
@@ -231,6 +276,11 @@ 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(
|
||||
@@ -259,6 +309,11 @@ 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) => {
|
||||
@@ -283,7 +338,7 @@ describe('save_memory', () => {
|
||||
name: proactiveMemoryFromLongSession,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryManager: true },
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -341,29 +396,75 @@ describe('save_memory', () => {
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
|
||||
);
|
||||
// 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)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
|
||||
wroteVitestToGlobal,
|
||||
'Expected the cross-project Vitest preference to be written to the global personal memory file (~/.gemini/GEMINI.md) via write_file or replace',
|
||||
).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 memoryManagerRoutingPreferences =
|
||||
'Agent routes global and project preferences to memory';
|
||||
const memoryV2RoutesTeamConventionsToProjectGemini =
|
||||
'Agent routes team-shared project conventions to ./GEMINI.md';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryManagerRoutingPreferences,
|
||||
name: memoryV2RoutesTeamConventionsToProjectGemini,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryManager: true },
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -372,7 +473,7 @@ describe('save_memory', () => {
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'I always use dark mode in all my editors and terminals.',
|
||||
text: 'For this project, the team always runs tests with `npm run test` — please remember that as our project convention.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
@@ -380,7 +481,9 @@ describe('save_memory', () => {
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Got it, I will keep that in mind!' }],
|
||||
content: [
|
||||
{ text: 'Got it, I will keep `npm run test` in mind for tests.' },
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
@@ -404,16 +507,238 @@ describe('save_memory', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args),
|
||||
);
|
||||
// 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)
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent',
|
||||
wroteToProjectRoot(/npm run test/i),
|
||||
'Expected the team test-command convention to be written to the project-root ./GEMINI.md',
|
||||
).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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -172,6 +172,7 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ 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,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,16 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('Interactive Mode', () => {
|
||||
// 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', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -134,7 +134,9 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('hello');
|
||||
// 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');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -81,7 +81,10 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called plan.md in the plans directory.',
|
||||
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.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -194,7 +197,11 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args: 'Create a file called plan-no-session.md in the plans directory.',
|
||||
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.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-10T15:36:04.547Z",
|
||||
"updatedAt": "2026-04-20T18:04:59.671Z",
|
||||
"scenarios": {
|
||||
"multi-turn-conversation": {
|
||||
"heapUsedBytes": 120082704,
|
||||
"heapTotalBytes": 177586176,
|
||||
"rssBytes": 269172736,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:17.603Z"
|
||||
"heapUsedMB": 68.8,
|
||||
"heapTotalMB": 91.2,
|
||||
"rssMB": 215.4,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:40.101Z"
|
||||
},
|
||||
"multi-function-call-repo-search": {
|
||||
"heapUsedBytes": 104644984,
|
||||
"heapTotalBytes": 111575040,
|
||||
"rssBytes": 204079104,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:22.480Z"
|
||||
"heapUsedMB": 73.5,
|
||||
"heapTotalMB": 93.1,
|
||||
"rssMB": 223.6,
|
||||
"externalMB": 97.7,
|
||||
"timestamp": "2026-04-20T18:02:42.032Z"
|
||||
},
|
||||
"idle-session-startup": {
|
||||
"heapUsedBytes": 119813672,
|
||||
"heapTotalBytes": 177061888,
|
||||
"rssBytes": 267943936,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:08.035Z"
|
||||
"heapUsedMB": 69.8,
|
||||
"heapTotalMB": 92.4,
|
||||
"rssMB": 217.4,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:36.294Z"
|
||||
},
|
||||
"simple-prompt-response": {
|
||||
"heapUsedBytes": 119722064,
|
||||
"heapTotalBytes": 177324032,
|
||||
"rssBytes": 268812288,
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:12.770Z"
|
||||
"heapUsedMB": 69.5,
|
||||
"heapTotalMB": 92.4,
|
||||
"rssMB": 216.1,
|
||||
"externalMB": 93.8,
|
||||
"timestamp": "2026-04-20T18:02:38.198Z"
|
||||
},
|
||||
"resume-large-chat-with-messages": {
|
||||
"heapUsedBytes": 106545568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:36:04.547Z"
|
||||
"heapUsedMB": 887.1,
|
||||
"heapTotalMB": 954.3,
|
||||
"rssMB": 1109.6,
|
||||
"externalMB": 103.2,
|
||||
"timestamp": "2026-04-20T18:04:59.671Z"
|
||||
},
|
||||
"resume-large-chat": {
|
||||
"heapUsedBytes": 106513760,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:59.528Z"
|
||||
"heapUsedMB": 885.6,
|
||||
"heapTotalMB": 955.6,
|
||||
"rssMB": 1107.8,
|
||||
"externalMB": 110.5,
|
||||
"timestamp": "2026-04-20T18:04:06.526Z"
|
||||
},
|
||||
"large-chat": {
|
||||
"heapUsedBytes": 106471568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:53.180Z"
|
||||
"heapUsedMB": 158.5,
|
||||
"heapTotalMB": 193,
|
||||
"rssMB": 787.9,
|
||||
"externalMB": 104,
|
||||
"timestamp": "2026-04-20T18:03:12.486Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,21 @@ import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { randomUUID, createHash } 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' };
|
||||
const TEST_ENV = {
|
||||
GEMINI_API_KEY: 'fake-memory-test-key',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '100',
|
||||
};
|
||||
|
||||
describe('Memory Usage Tests', () => {
|
||||
let harness: MemoryTestHarness;
|
||||
@@ -56,6 +62,7 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'idle-session-startup',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -85,6 +92,7 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'simple-prompt-response',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -122,6 +130,7 @@ describe('Memory Usage Tests', () => {
|
||||
];
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'multi-turn-conversation',
|
||||
async (recordSnapshot) => {
|
||||
// Run through all turns as a piped sequence
|
||||
@@ -144,6 +153,9 @@ 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,6 +180,7 @@ describe('Memory Usage Tests', () => {
|
||||
);
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'multi-function-call-repo-search',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -189,6 +202,7 @@ describe('Memory Usage Tests', () => {
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,6 +242,7 @@ describe('Memory Usage Tests', () => {
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
rig,
|
||||
'large-chat',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
@@ -257,19 +272,21 @@ 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.testDir!,
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
getProjectHash(rig.testDir!),
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
'session-large-chat.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
@@ -302,19 +319,21 @@ 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.testDir!,
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
getProjectHash(rig.testDir!),
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
'session-large-chat.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
@@ -457,6 +476,9 @@ 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({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -991,6 +991,37 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
|
||||
@@ -1038,6 +1069,24 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -1051,6 +1100,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
|
||||
@@ -3733,6 +3795,37 @@
|
||||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -4593,56 +4686,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
|
||||
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
|
||||
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
|
||||
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -4943,6 +4986,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -4982,6 +5032,30 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -6479,6 +6553,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -6994,6 +7075,23 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depcheck/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
@@ -7053,6 +7151,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/minimatch": {
|
||||
"version": "7.4.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
|
||||
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
@@ -7943,6 +8057,24 @@
|
||||
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@@ -7953,6 +8085,19 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -8009,6 +8154,37 @@
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -8067,6 +8243,37 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -11707,12 +11914,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -11893,6 +12100,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/multimatch/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
@@ -12133,6 +12371,24 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
@@ -12182,6 +12438,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
@@ -15548,6 +15817,39 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
@@ -16306,6 +16608,23 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
@@ -16316,6 +16635,22 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -17742,7 +18077,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17871,7 +18206,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18019,7 +18354,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18054,6 +18389,7 @@
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
@@ -18203,6 +18539,21 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@@ -18271,6 +18622,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -18300,7 +18664,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18315,7 +18679,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18346,7 +18710,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18378,7 +18742,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"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.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -63,6 +63,7 @@
|
||||
"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",
|
||||
"metrics": "tsx tools/gemini-cli-bot/metrics/index.ts",
|
||||
"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,8 +82,7 @@
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -98,6 +98,7 @@ 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({
|
||||
@@ -111,6 +112,7 @@ 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.40.0-nightly.20260414.g5b1f7375a",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"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.40.0-nightly.20260414.g5b1f7375a"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -41,6 +41,8 @@ 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(),
|
||||
}));
|
||||
@@ -101,6 +103,7 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
startMemoryService: startMemoryServiceMock,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
@@ -148,6 +151,8 @@ describe('GeminiAgent', () => {
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
startMemoryServiceMock.mockResolvedValue(undefined);
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -155,6 +160,7 @@ 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({
|
||||
@@ -354,6 +360,34 @@ 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,6 +76,7 @@ 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';
|
||||
|
||||
@@ -324,6 +325,7 @@ export class GeminiAgent {
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
@@ -465,6 +467,7 @@ export class GeminiAgent {
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ 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),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @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: () => {},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @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');
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* @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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @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,6 +338,7 @@ 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 },
|
||||
])(
|
||||
@@ -758,6 +759,12 @@ 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', () => {
|
||||
@@ -3030,6 +3037,8 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
experimental: {
|
||||
gemmaModelRouter: {
|
||||
enabled: true,
|
||||
autoStartServer: false,
|
||||
binaryPath: '/custom/lit',
|
||||
classifier: {
|
||||
host: 'http://custom:1234',
|
||||
model: 'custom-gemma',
|
||||
@@ -3040,10 +3049,24 @@ 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());
|
||||
@@ -3057,6 +3080,8 @@ 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,6 +13,7 @@ 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,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
FileDiscoveryService,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
getPty,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -59,6 +61,7 @@ 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';
|
||||
@@ -105,6 +108,7 @@ export interface CliArgs {
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
skipTrust: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -181,6 +185,7 @@ export async function parseArguments(
|
||||
extensionsCommand,
|
||||
skillsCommand,
|
||||
hooksCommand,
|
||||
gemmaCommand,
|
||||
];
|
||||
|
||||
const subcommands = commandModules.flatMap((mod) => {
|
||||
@@ -260,6 +265,7 @@ export async function parseArguments(
|
||||
yargsInstance.command(extensionsCommand);
|
||||
yargsInstance.command(skillsCommand);
|
||||
yargsInstance.command(hooksCommand);
|
||||
yargsInstance.command(gemmaCommand);
|
||||
|
||||
yargsInstance
|
||||
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
@@ -288,6 +294,11 @@ 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',
|
||||
@@ -456,9 +467,16 @@ export async function parseArguments(
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
let result;
|
||||
try {
|
||||
result = await yargsInstance.parse();
|
||||
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';
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const msg = getErrorMessage(e);
|
||||
debugLogger.error(msg);
|
||||
yargsInstance.showHelp();
|
||||
await runExitCleanup();
|
||||
@@ -472,11 +490,13 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
// 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;
|
||||
const queryArg = result['query'];
|
||||
let q: string | undefined;
|
||||
if (Array.isArray(queryArg)) {
|
||||
q = queryArg.join(' ');
|
||||
} else if (typeof queryArg === 'string') {
|
||||
q = queryArg;
|
||||
}
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
@@ -491,8 +511,8 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
result['query'] = q || undefined;
|
||||
result['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -544,7 +564,7 @@ export async function loadCliConfig(
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder =
|
||||
isWorkspaceTrusted(settings, cwd, undefined, {
|
||||
isWorkspaceTrusted(settings, cwd, {
|
||||
prompt: argv.prompt,
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
@@ -590,7 +610,7 @@ export async function loadCliConfig(
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -614,7 +634,7 @@ export async function loadCliConfig(
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
|
||||
let extensionRegistryURI =
|
||||
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
|
||||
@@ -988,9 +1008,10 @@ export async function loadCliConfig(
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalJitContext,
|
||||
experimentalMemoryV2: settings.experimental?.memoryV2,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
experimentalGemma: settings.experimental?.gemma,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration:
|
||||
@@ -1029,6 +1050,7 @@ 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,
|
||||
@@ -1095,7 +1117,7 @@ async function resolveWorktreeSettings(
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
|
||||
'.gemini',
|
||||
'trustedFolders.json',
|
||||
);
|
||||
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
|
||||
const trustedFolders = JSON.parse(
|
||||
fs.readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
|
||||
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
|
||||
@@ -1912,6 +1912,9 @@ 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,
|
||||
@@ -1919,9 +1922,11 @@ 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].includes(
|
||||
normalizedP,
|
||||
);
|
||||
return [
|
||||
path.resolve(USER_SETTINGS_PATH),
|
||||
geminiEnvPath,
|
||||
workspaceEnvPath,
|
||||
].includes(normalizedP);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
@@ -1941,7 +1946,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)
|
||||
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
|
||||
@@ -78,7 +78,12 @@ 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'];
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = [
|
||||
'DEBUG',
|
||||
'DEBUG_MODE',
|
||||
'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND',
|
||||
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS',
|
||||
];
|
||||
|
||||
const AUTH_ENV_VAR_WHITELIST = [
|
||||
'GEMINI_API_KEY',
|
||||
@@ -494,13 +499,15 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
if (isTrusted) {
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -509,9 +516,11 @@ function findEnvFile(startDir: string): string | null {
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
if (isTrusted) {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
@@ -554,10 +563,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);
|
||||
|
||||
// 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
|
||||
@@ -592,8 +601,8 @@ export function loadEnvironment(
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
// If the workspace is untrusted, only allow whitelisted variables.
|
||||
if (!isTrusted) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,10 @@ describe('SettingsSchema', () => {
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableFileWatcher,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths,
|
||||
@@ -313,6 +317,22 @@ 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,
|
||||
@@ -471,11 +491,33 @@ describe('SettingsSchema', () => {
|
||||
expect(enabled.category).toBe('Experimental');
|
||||
expect(enabled.default).toBe(false);
|
||||
expect(enabled.requiresRestart).toBe(true);
|
||||
expect(enabled.showInDialog).toBe(false);
|
||||
expect(enabled.showInDialog).toBe(true);
|
||||
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,6 +21,7 @@ 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';
|
||||
@@ -990,6 +991,45 @@ 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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1431,6 +1471,17 @@ 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',
|
||||
@@ -1616,6 +1667,19 @@ 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',
|
||||
@@ -1988,6 +2052,15 @@ 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',
|
||||
@@ -2089,8 +2162,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
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.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
@@ -2169,6 +2243,26 @@ 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: {
|
||||
@@ -2203,14 +2297,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
memoryManager: {
|
||||
memoryV2: {
|
||||
type: 'boolean',
|
||||
label: 'Memory Manager Agent',
|
||||
label: 'Memory v2',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
'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: {
|
||||
@@ -3000,6 +3094,11 @@ 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,
|
||||
coreEvents,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
coreEvents: Object.assign(
|
||||
Object.create(Object.getPrototypeOf(actual.coreEvents)),
|
||||
actual.coreEvents,
|
||||
{
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
),
|
||||
FatalConfigError: actual.FatalConfigError,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,6 +58,7 @@ describe('Trusted Folders', () => {
|
||||
// Reset the internal state
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.clearAllMocks();
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
|
||||
|
||||
// Start two concurrent calls
|
||||
// These will race to acquire the lock on the real file system
|
||||
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
|
||||
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
|
||||
const p1 = loadedFolders.setValue(
|
||||
path.resolve('/path1'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p2 = loadedFolders.setValue(
|
||||
path.resolve('/path2'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
|
||||
const config = JSON.parse(content);
|
||||
|
||||
expect(config).toEqual({
|
||||
'/path1': TrustLevel.TRUST_FOLDER,
|
||||
'/path2': TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
|
||||
|
||||
it('should load rules from the configuration file', () => {
|
||||
const config = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{
|
||||
path: normalizePath('/user/folder'),
|
||||
trustLevel: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
|
||||
const content = `
|
||||
{
|
||||
// This is a comment
|
||||
"/path": "TRUST_FOLDER"
|
||||
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
await loadedFolders.setValue(
|
||||
normalizePath('/new/path'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
|
||||
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError if there were load errors', async () => {
|
||||
@@ -237,28 +255,6 @@ 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', () => {
|
||||
@@ -427,16 +423,28 @@ describe('Trusted Folders', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
it('should NOT 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: true,
|
||||
isTrusted: undefined,
|
||||
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);
|
||||
@@ -449,12 +457,12 @@ describe('Trusted Folders', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
it('should return undefined 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(true);
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,330 +4,29 @@
|
||||
* 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 {
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
coreEvents,
|
||||
type HeadlessModeOptions,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
loadTrustedFolders as loadCoreTrustedFolders,
|
||||
type LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const { promises: fsPromises } = fs;
|
||||
export {
|
||||
TrustLevel,
|
||||
isTrustLevel,
|
||||
resetTrustedFoldersForTesting,
|
||||
saveTrustedFolders,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
export type {
|
||||
TrustRule,
|
||||
TrustedFoldersError,
|
||||
TrustedFoldersFile,
|
||||
TrustResult,
|
||||
LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
return loadCoreTrustedFolders();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): 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,
|
||||
);
|
||||
): {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | 'env' | undefined;
|
||||
} {
|
||||
return checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: isFolderTrustEnabled(settings),
|
||||
isHeadless: isHeadlessMode(headlessOptions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ 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');
|
||||
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -613,6 +615,7 @@ 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'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -612,6 +612,23 @@ 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 &&
|
||||
@@ -644,6 +661,12 @@ 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,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -305,6 +306,7 @@ 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(),
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive({
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -61,6 +61,7 @@ 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
|
||||
@@ -221,6 +222,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
gemmaStatusCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @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,6 +89,7 @@ describe('ShellProcessor', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this as unknown as Config;
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
isMemoryManagerEnabled: vi.fn(() => false),
|
||||
isMemoryV2Enabled: vi.fn(() => false),
|
||||
isAutoMemoryEnabled: vi.fn(() => false),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
@@ -89,6 +89,7 @@ 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(''),
|
||||
@@ -167,6 +168,7 @@ 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'),
|
||||
|
||||
@@ -92,7 +92,6 @@ import {
|
||||
ApiKeyUpdatedEvent,
|
||||
LegacyAgentProtocol,
|
||||
type InjectionSource,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -125,6 +124,7 @@ import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
@@ -486,12 +486,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
// Fire-and-forget Auto Memory service (skill extraction from past sessions)
|
||||
if (config.isAutoMemoryEnabled()) {
|
||||
startMemoryService(config).catch((e) => {
|
||||
debugLogger.error('Failed to start memory service:', e);
|
||||
});
|
||||
}
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const sessionStartSource = resumedSessionData
|
||||
? SessionStartSource.Resume
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
<rect x="27" y="0" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="0" width="549" height="17" fill="#141414" />
|
||||
<rect x="0" y="17" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
" > Can you edit InputPrompt.tsx for me?
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ApiAuthDialog({
|
||||
height: 4,
|
||||
},
|
||||
inputFilter: (text) =>
|
||||
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
|
||||
text.replace(/[^a-zA-Z0-9_.-]/g, '').replace(/[\r\n]/g, ''),
|
||||
singleLine: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
description: 'Clear the screen and conversation history',
|
||||
altNames: ['new'],
|
||||
description: 'Clear the screen and start a new session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context, _args) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType, type HistoryItemGemmaStatus } from '../types.js';
|
||||
import { checkGemmaStatus } from '../../commands/gemma/status.js';
|
||||
import { GEMMA_MODEL_NAME } from '../../commands/gemma/constants.js';
|
||||
|
||||
export const gemmaStatusCommand: SlashCommand = {
|
||||
name: 'gemma',
|
||||
description: 'Check local Gemma model routing status',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => {
|
||||
const port =
|
||||
parseInt(
|
||||
context.services.settings.merged.experimental?.gemmaModelRouter?.classifier?.host?.match(
|
||||
/:(\d+)/,
|
||||
)?.[1] ?? '',
|
||||
10,
|
||||
) || undefined;
|
||||
const status = await checkGemmaStatus(port);
|
||||
const item: Omit<HistoryItemGemmaStatus, 'id'> = {
|
||||
type: MessageType.GEMMA_STATUS,
|
||||
binaryInstalled: status.binaryInstalled,
|
||||
binaryPath: status.binaryPath,
|
||||
modelName: GEMMA_MODEL_NAME,
|
||||
modelDownloaded: status.modelDownloaded,
|
||||
serverRunning: status.serverRunning,
|
||||
serverPid: status.serverPid,
|
||||
serverPort: status.port,
|
||||
settingsEnabled: status.settingsEnabled,
|
||||
allPassing: status.allPassing,
|
||||
};
|
||||
context.ui.addItem(item);
|
||||
},
|
||||
};
|
||||
@@ -159,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
isTrustedFolder: () => true,
|
||||
getPreferredEditor: () => undefined,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getProjectRoot: () => mockTargetDir,
|
||||
storage: {
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getProjectRoot: () => mockTargetDir,
|
||||
storage: {
|
||||
getPlansDir: () => mockPlansDir,
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
if (ignore) return;
|
||||
if (pathError) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ToolsList } from './views/ToolsList.js';
|
||||
import { SkillsList } from './views/SkillsList.js';
|
||||
import { AgentsStatus } from './views/AgentsStatus.js';
|
||||
import { McpStatus } from './views/McpStatus.js';
|
||||
import { GemmaStatus } from './views/GemmaStatus.js';
|
||||
import { ChatList } from './views/ChatList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
@@ -228,6 +229,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'mcp_status' && (
|
||||
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
|
||||
)}
|
||||
{itemForDisplay.type === 'gemma_status' && (
|
||||
<GemmaStatus {...itemForDisplay} />
|
||||
)}
|
||||
{itemForDisplay.type === 'chat_list' && (
|
||||
<ChatList chats={itemForDisplay.chats} />
|
||||
)}
|
||||
|
||||
@@ -56,11 +56,8 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import chalk from 'chalk';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
@@ -78,9 +75,6 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('clipboardy');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../hooks/useKittyKeyboardProtocol.js');
|
||||
vi.mock('../utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -1914,172 +1908,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Background Color Styles', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render with background color by default', async () => {
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ color: 'black', name: 'black' },
|
||||
{ color: '#000000', name: '#000000' },
|
||||
{ color: '#000', name: '#000' },
|
||||
{ color: 'white', name: 'white' },
|
||||
{ color: '#ffffff', name: '#ffffff' },
|
||||
{ color: '#fff', name: '#fff' },
|
||||
])(
|
||||
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
|
||||
async ({ color }) => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: color,
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
const isWhite =
|
||||
color === 'white' || color === '#ffffff' || color === '#fff';
|
||||
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
// Use chalk to get the expected background color escape sequence
|
||||
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
|
||||
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
|
||||
|
||||
// Background color code should be present
|
||||
expect(frame).toContain(bgCode);
|
||||
// Background characters should be rendered
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
// Side borders should STILL be removed
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: '#333333',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// It SHOULD have horizontal fallback lines
|
||||
expect(frame).toContain('─');
|
||||
// It SHOULD NOT have vertical side borders (standard Box borders have │)
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: 'black',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
expect(frame).toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: 'blue',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
// Should NOT have background characters
|
||||
|
||||
expect(frame).not.toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('▄');
|
||||
|
||||
// Should HAVE horizontal lines from the fallback Box borders
|
||||
|
||||
// Box style "round" uses these for top/bottom
|
||||
|
||||
expect(frame).toContain('─');
|
||||
|
||||
// Should NOT have vertical side borders
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render with plain borders when useBackgroundColor is false', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// Check for Box borders (round style uses unicode box chars)
|
||||
expect(frame).toMatch(/[─│┐└┘┌]/);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -4007,9 +3835,9 @@ describe('InputPrompt', () => {
|
||||
expect(stdout.lastFrame()).toContain('hello world');
|
||||
});
|
||||
|
||||
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
|
||||
// With plain borders offset
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
|
||||
stdin.write(`\x1b[<0;4;2M`); // Click at col 4, row 2
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -73,8 +73,6 @@ import {
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
@@ -1645,21 +1643,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
);
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
|
||||
// We should fallback to lines if the background color is disabled OR if it is
|
||||
// enabled but we are in a low color depth terminal where we don't have a safe
|
||||
// background color to use.
|
||||
const useLineFallback = useMemo(() => {
|
||||
if (!useBackgroundColor) {
|
||||
return true;
|
||||
}
|
||||
if (isLowColor) {
|
||||
return !getSafeLowColorBackground(terminalBg);
|
||||
}
|
||||
return false;
|
||||
}, [useBackgroundColor, isLowColor, terminalBg]);
|
||||
|
||||
const prevCursorRef = useRef(buffer.visualCursor);
|
||||
const prevTextRef = useRef(buffer.text);
|
||||
@@ -1698,8 +1681,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}, [buffer.visualCursor, buffer.text, focus]);
|
||||
|
||||
const listBackgroundColor =
|
||||
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
|
||||
const listBackgroundColor = !useBackgroundColor
|
||||
? undefined
|
||||
: theme.background.input;
|
||||
|
||||
const useLineFallback = !!process.env['NO_COLOR'];
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
@@ -1762,7 +1748,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
{useLineFallback ? (
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={true}
|
||||
@@ -1781,17 +1767,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
paddingX={1}
|
||||
borderColor={borderColor}
|
||||
borderStyle={useLineFallback ? 'round' : undefined}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={!useBackgroundColor}
|
||||
borderRight={!useBackgroundColor}
|
||||
>
|
||||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
@@ -1880,7 +1856,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
</HalfLinePaddedBox>
|
||||
{useLineFallback ? (
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getExperimentalGemma: () => boolean;
|
||||
getLastRetrievedQuota: () =>
|
||||
| {
|
||||
buckets: Array<{
|
||||
@@ -85,6 +86,7 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getExperimentalGemma: () => false,
|
||||
getLastRetrievedQuota: () => ({ buckets: [] }),
|
||||
getSessionId: () => 'test-session-id',
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
@@ -222,7 +224,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const list = [
|
||||
const showGemmaModels = config?.getExperimentalGemma() ?? false;
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
@@ -240,6 +244,21 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (showGemmaModels) {
|
||||
options.push(
|
||||
{
|
||||
value: GEMMA_4_31B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
|
||||
key: GEMMA_4_31B_IT_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
|
||||
key: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
@@ -270,15 +289,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
list.unshift(...previewOptions);
|
||||
options.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
if (!hasAccessToProModel) {
|
||||
// Filter out all Pro models for free tier
|
||||
return list.filter((option) => !isProModel(option.value));
|
||||
return options.filter((option) => !isProModel(option.value));
|
||||
}
|
||||
|
||||
return list;
|
||||
return options;
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
|
||||
@@ -86,6 +86,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getProjectTempDir: () => '/tmp/test',
|
||||
},
|
||||
getSessionId: () => 'default-session-id',
|
||||
getExperimentalGemma: () => false,
|
||||
...overrides,
|
||||
}) as Config;
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('Hint Visibility', () => {
|
||||
<ThemeDialog {...baseProps} settings={settings} />,
|
||||
{
|
||||
settings,
|
||||
uiState: { terminalBackgroundColor: '#FFFFFF' },
|
||||
uiState: { terminalBackgroundColor: '#123456' },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -287,11 +287,15 @@ export function ThemeDialog({
|
||||
const itemWithExtras = item as typeof item & {
|
||||
themeWarning?: string;
|
||||
themeMatch?: string;
|
||||
themeNameDisplay?: string;
|
||||
themeTypeDisplay?: string;
|
||||
};
|
||||
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
|
||||
let themeNamePart: React.ReactNode = item.themeNameDisplay;
|
||||
if (itemWithExtras.themeNameDisplay) {
|
||||
const match =
|
||||
itemWithExtras.themeNameDisplay.match(/^(.*) \((.*)\)$/);
|
||||
let themeNamePart: React.ReactNode =
|
||||
itemWithExtras.themeNameDisplay;
|
||||
if (match) {
|
||||
themeNamePart = (
|
||||
<>
|
||||
@@ -303,10 +307,15 @@ export function ThemeDialog({
|
||||
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{themeNamePart}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.themeTypeDisplay}
|
||||
</Text>
|
||||
{themeNamePart}
|
||||
{itemWithExtras.themeTypeDisplay ? (
|
||||
<>
|
||||
{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{itemWithExtras.themeTypeDisplay}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
{itemWithExtras.themeMatch && (
|
||||
<Text color={theme.status.success}>
|
||||
{itemWithExtras.themeMatch}
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
getModel: () => 'gemini-pro',
|
||||
getDebugMode: () => false,
|
||||
getTargetDir: () => '/mock/target/dir',
|
||||
getProjectRoot: () => '/mock/project/root',
|
||||
getFileSystemService: () => ({
|
||||
readFile: vi.fn().mockResolvedValue('Plan content'),
|
||||
}),
|
||||
|
||||
@@ -139,9 +139,9 @@ Tips for getting started:
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello Gemini
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> Hello Gemini
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
✦ Hello User!
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -5,15 +5,11 @@
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="45" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="27" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
|
||||
<text x="36" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,14 +5,10 @@
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<rect x="126" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<rect x="117" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,19 +5,13 @@
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
|
||||
<rect x="63" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="63" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
|
||||
<text x="72" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="27" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
|
||||
<rect x="54" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="54" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
|
||||
<text x="63" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
|
||||
<text x="27" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -5,16 +5,10 @@
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
|
||||
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
|
||||
<text x="27" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,12 +5,10 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="45" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">A</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">A</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,12 +5,10 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
|
||||
<text x="45" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="27" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,10 +5,8 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -5,11 +5,9 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -5,11 +5,9 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -5,13 +5,11 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">hello </text>
|
||||
<rect x="90" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="90" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<text x="99" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> world</text>
|
||||
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">hello </text>
|
||||
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="81" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> world</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,13 +5,11 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">hel</text>
|
||||
<rect x="63" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="63" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
|
||||
<text x="72" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">o world</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">hel</text>
|
||||
<rect x="54" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="54" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
|
||||
<text x="63" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">o world</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,14 +5,12 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">run </text>
|
||||
<text x="72" y="19" fill="#d7afff" textLength="45" lengthAdjust="spacingAndGlyphs">@path</text>
|
||||
<rect x="117" y="17" width="9" height="17" fill="#d7afff" />
|
||||
<text x="117" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">/</text>
|
||||
<text x="126" y="19" fill="#d7afff" textLength="63" lengthAdjust="spacingAndGlyphs">to/file</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">run </text>
|
||||
<text x="63" y="19" fill="#d7afff" textLength="45" lengthAdjust="spacingAndGlyphs">@path</text>
|
||||
<rect x="108" y="17" width="9" height="17" fill="#d7afff" />
|
||||
<text x="108" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">/</text>
|
||||
<text x="117" y="19" fill="#d7afff" textLength="63" lengthAdjust="spacingAndGlyphs">to/file</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -5,12 +5,10 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="90" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="81" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,11 +5,9 @@
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="45" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
|
||||
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -5,16 +5,10 @@
|
||||
<rect width="920" height="105" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="53" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<rect x="81" y="51" width="9" height="17" fill="#ffffff" />
|
||||
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
|
||||
<text x="27" y="53" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
|
||||
<rect x="72" y="51" width="9" height="17" fill="#ffffff" />
|
||||
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.5 KiB |