mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3cc1c5682 | |||
| 9804bd6964 | |||
| e88b56bbcb | |||
| 63c6560a38 | |||
| 596a736a99 | |||
| a0ee9bac71 | |||
| e9ea7b3e04 | |||
| 22580dd826 | |||
| d1de82c46f | |||
| 7f8fb6f292 | |||
| dd950088c7 | |||
| 4b4794ad2f | |||
| 2f90b46537 | |||
| 82d8680dcc | |||
| 1f3f7247b1 | |||
| 69e2d8c7ae | |||
| 77a874cf65 | |||
| b211f30d95 | |||
| fc51e50bc6 | |||
| 695bcaea0d | |||
| abe83fce0b | |||
| 61fd71dc29 | |||
| cddd9e7148 | |||
| 30271d64eb | |||
| 605432ea70 | |||
| 990d010ecf | |||
| b6c6da3618 | |||
| 8f22ffd2b1 | |||
| 44ce90d76c | |||
| b91f75cd6d | |||
| dfe22aae21 | |||
| bba9c07541 | |||
| 05fda0cf01 | |||
| d43ec6c8f3 | |||
| 56e0865a7b | |||
| cd2096ca80 | |||
| 48130ebd25 | |||
| ef5627eece | |||
| e3df87cf1a | |||
| 8bad5823a9 | |||
| fd62938945 | |||
| fad032d466 | |||
| 366aa84395 | |||
| 17b37144a9 | |||
| 5ddb517593 | |||
| abd9e23337 | |||
| 6061d8cac7 | |||
| bd590bbde6 | |||
| 0bf7ea60c5 | |||
| 8f2697c2e5 | |||
| c5502b2dc5 | |||
| 9f7691fd88 | |||
| 3682842a5d | |||
| 64c50d32ac | |||
| 604d4ded8d | |||
| f75bdba568 | |||
| 8d68ece8d6 | |||
| b49fc8122d | |||
| b0d151bd65 | |||
| fe8d93c75a | |||
| 24933a90d0 | |||
| fa024133e6 | |||
| 24adacdbc2 | |||
| aa23da67af | |||
| bfbd3c40a7 | |||
| dd8d4c98b3 | |||
| d368997ca3 | |||
| bbd80c9393 | |||
| 3b601b3d90 | |||
| b4bcd1a015 | |||
| aa000d7d30 | |||
| 2a7e602356 | |||
| 8d0b2d7f1b | |||
| c156bac5f7 | |||
| 263b8cd3b3 |
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: async-pr-review
|
||||
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
|
||||
---
|
||||
|
||||
# Async PR Review
|
||||
|
||||
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
|
||||
|
||||
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
|
||||
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
|
||||
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
|
||||
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
|
||||
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
|
||||
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
|
||||
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
|
||||
|
||||
### Start Review
|
||||
|
||||
If the user wants to start a new async PR review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. Inform the user that the tasks have started successfully and they can check the status later.
|
||||
|
||||
### Check Status
|
||||
|
||||
If the user wants to check the status or view the final assessment of a previously started async review:
|
||||
|
||||
1. Ask the user for the PR number if they haven't provided it.
|
||||
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
|
||||
```bash
|
||||
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
|
||||
```
|
||||
3. **Evaluate Output**: Read the output from the script.
|
||||
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
|
||||
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
|
||||
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
|
||||
@@ -0,0 +1,148 @@
|
||||
# --- CORE TOOLS ---
|
||||
[[rule]]
|
||||
toolName = "read_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "grep_search"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "glob"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "list_directory"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# --- SHELL COMMANDS ---
|
||||
|
||||
# Git (Safe/Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"git blame",
|
||||
"git show",
|
||||
"git grep",
|
||||
"git show-ref",
|
||||
"git ls-tree",
|
||||
"git ls-remote",
|
||||
"git reflog",
|
||||
"git remote -v",
|
||||
"git diff",
|
||||
"git rev-list",
|
||||
"git rev-parse",
|
||||
"git merge-base",
|
||||
"git cherry",
|
||||
"git fetch",
|
||||
"git status",
|
||||
"git st",
|
||||
"git branch",
|
||||
"git br",
|
||||
"git log",
|
||||
"git --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# GitHub CLI (Read-only)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"gh workflow list",
|
||||
"gh auth status",
|
||||
"gh checkout view",
|
||||
"gh run view",
|
||||
"gh run job view",
|
||||
"gh run list",
|
||||
"gh run --help",
|
||||
"gh issue view",
|
||||
"gh issue list",
|
||||
"gh label list",
|
||||
"gh pr diff",
|
||||
"gh pr check",
|
||||
"gh pr checks",
|
||||
"gh pr view",
|
||||
"gh pr list",
|
||||
"gh pr status",
|
||||
"gh repo view",
|
||||
"gh job view",
|
||||
"gh api",
|
||||
"gh log"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Node.js/NPM (Generic Tests, Checks, and Build)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"npm run start",
|
||||
"npm install",
|
||||
"npm run",
|
||||
"npm test",
|
||||
"npm ci",
|
||||
"npm list",
|
||||
"npm --version"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
|
||||
# Core Utilities (Safe)
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = [
|
||||
"sleep",
|
||||
"env",
|
||||
"break",
|
||||
"xargs",
|
||||
"base64",
|
||||
"uniq",
|
||||
"sort",
|
||||
"echo",
|
||||
"which",
|
||||
"ls",
|
||||
"find",
|
||||
"tail",
|
||||
"head",
|
||||
"cat",
|
||||
"cd",
|
||||
"grep",
|
||||
"ps",
|
||||
"pwd",
|
||||
"wc",
|
||||
"file",
|
||||
"stat",
|
||||
"diff",
|
||||
"lsof",
|
||||
"date",
|
||||
"whoami",
|
||||
"uname",
|
||||
"du",
|
||||
"cut",
|
||||
"true",
|
||||
"false",
|
||||
"readlink",
|
||||
"awk",
|
||||
"jq",
|
||||
"rg",
|
||||
"less",
|
||||
"more",
|
||||
"tree"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#!/bin/bash
|
||||
|
||||
notify() {
|
||||
local title="$1"
|
||||
local message="$2"
|
||||
local pr="$3"
|
||||
# Terminal escape sequence
|
||||
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
|
||||
# Native macOS notification
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number=$1
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
echo "Usage: async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
|
||||
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
|
||||
target_dir="$pr_dir/worktree"
|
||||
log_dir="$pr_dir/logs"
|
||||
|
||||
cd "$base_dir" || exit 1
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
|
||||
|
||||
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
|
||||
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
|
||||
|
||||
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
|
||||
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Fetch failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Fetch failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
|
||||
git worktree prune >> "$log_dir/setup.log" 2>&1
|
||||
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
|
||||
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
|
||||
echo 1 > "$log_dir/setup.exit"
|
||||
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
|
||||
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
|
||||
fi
|
||||
echo 0 > "$log_dir/setup.exit"
|
||||
|
||||
cd "$target_dir" || exit 1
|
||||
|
||||
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
|
||||
|
||||
echo " ↳ [1/5] Grabbing PR diff..."
|
||||
rm -f "$log_dir/pr-diff.exit"
|
||||
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
|
||||
|
||||
echo " ↳ [2/5] Starting build and lint..."
|
||||
rm -f "$log_dir/build-and-lint.exit"
|
||||
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
|
||||
|
||||
# Dynamically resolve gemini binary (fallback to your nightly path)
|
||||
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
|
||||
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
|
||||
|
||||
echo " ↳ [3/5] Starting Gemini code review..."
|
||||
rm -f "$log_dir/review.exit"
|
||||
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
|
||||
|
||||
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
|
||||
rm -f "$log_dir/npm-test.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
|
||||
ci_status=$?
|
||||
|
||||
if [ "$ci_status" -eq 0 ]; then
|
||||
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
elif [ "$ci_status" -eq 8 ]; then
|
||||
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
|
||||
echo 0 > "$log_dir/npm-test.exit"
|
||||
else
|
||||
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
|
||||
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
|
||||
|
||||
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
|
||||
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
|
||||
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
|
||||
|
||||
failed_files=""
|
||||
if [[ -n "$run_id" ]]; then
|
||||
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
|
||||
fi
|
||||
|
||||
if [[ -n "$failed_files" ]]; then
|
||||
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
|
||||
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
|
||||
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
|
||||
|
||||
exit_code=0
|
||||
for file in $failed_files; do
|
||||
if [[ "$file" == packages/* ]]; then
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
|
||||
else
|
||||
ws_dir=$(echo "$file" | cut -d'/' -f1)
|
||||
fi
|
||||
rel_file=${file#$ws_dir/}
|
||||
|
||||
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
|
||||
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
|
||||
exit_code=1
|
||||
fi
|
||||
done
|
||||
echo $exit_code > "$log_dir/npm-test.exit"
|
||||
else
|
||||
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
|
||||
echo 1 > "$log_dir/npm-test.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
|
||||
rm -f "$log_dir/test-execution.exit"
|
||||
{
|
||||
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
|
||||
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
|
||||
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
|
||||
else
|
||||
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
|
||||
echo 1 > "$log_dir/test-execution.exit"
|
||||
fi
|
||||
} &
|
||||
|
||||
echo "✅ All tasks dispatched!"
|
||||
echo "You can monitor progress with: tail -f $log_dir/*.log"
|
||||
echo "Read your review later at: $log_dir/review.md"
|
||||
|
||||
# Polling loop to wait for all background tasks to finish
|
||||
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
|
||||
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
|
||||
|
||||
declare -A task_done
|
||||
for t in "${tasks[@]}"; do task_done[$t]=0; done
|
||||
|
||||
all_done=0
|
||||
while [[ $all_done -eq 0 ]]; do
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
all_done=1
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
|
||||
if [[ -f "$log_dir/$t.exit" ]]; then
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
task_done[$t]=1
|
||||
else
|
||||
echo " ⏳ $t: RUNNING"
|
||||
all_done=0
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo "📝 Live Logs (Last 5 lines of running tasks)"
|
||||
echo "=================================================="
|
||||
|
||||
for i in "${!tasks[@]}"; do
|
||||
t="${tasks[$i]}"
|
||||
log_file="${log_files[$i]}"
|
||||
|
||||
if [[ ${task_done[$t]} -eq 0 ]]; then
|
||||
if [[ -f "$log_dir/$log_file" ]]; then
|
||||
echo ""
|
||||
echo "--- $t ---"
|
||||
tail -n 5 "$log_dir/$log_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $all_done -eq 0 ]]; then
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
clear
|
||||
echo "=================================================="
|
||||
echo "🚀 Async PR Review Status for PR #$pr_number"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
for t in "${tasks[@]}"; do
|
||||
exit_code=$(cat "$log_dir/$t.exit")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo " ✅ $t: SUCCESS"
|
||||
else
|
||||
echo " ❌ $t: FAILED (exit code $exit_code)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "⏳ Tasks complete! Synthesizing final assessment..."
|
||||
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
|
||||
echo $? > "$log_dir/final-assessment.exit"
|
||||
echo "❌ Final assessment synthesis failed!"
|
||||
echo "Check $log_dir/final-assessment.md for details."
|
||||
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 0 > "$log_dir/final-assessment.exit"
|
||||
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
|
||||
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
pr_number=$1
|
||||
|
||||
if [[ -z "$pr_number" ]]; then
|
||||
echo "Usage: check-async-review <pr_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [[ -z "$base_dir" ]]; then
|
||||
echo "❌ Must be run from within a git repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
|
||||
|
||||
if [[ ! -d "$log_dir" ]]; then
|
||||
echo "STATUS: NOT_FOUND"
|
||||
echo "❌ No logs found for PR #$pr_number in $log_dir"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tasks=(
|
||||
"setup|setup.log"
|
||||
"pr-diff|pr-diff.diff"
|
||||
"build-and-lint|build-and-lint.log"
|
||||
"review|review.md"
|
||||
"npm-test|npm-test.log"
|
||||
"test-execution|test-execution.log"
|
||||
"final-assessment|final-assessment.md"
|
||||
)
|
||||
|
||||
all_done=true
|
||||
echo "STATUS: CHECKING"
|
||||
|
||||
for task_info in "${tasks[@]}"; do
|
||||
IFS="|" read -r task_name log_file <<< "$task_info"
|
||||
|
||||
file_path="$log_dir/$log_file"
|
||||
exit_file="$log_dir/$task_name.exit"
|
||||
|
||||
if [[ -f "$exit_file" ]]; then
|
||||
exit_code=$(cat "$exit_file")
|
||||
if [[ "$exit_code" == "0" ]]; then
|
||||
echo "✅ $task_name: SUCCESS"
|
||||
else
|
||||
echo "❌ $task_name: FAILED (exit code $exit_code)"
|
||||
echo " Last lines of $file_path:"
|
||||
tail -n 3 "$file_path" | sed 's/^/ /'
|
||||
fi
|
||||
elif [[ -f "$file_path" ]]; then
|
||||
echo "⏳ $task_name: RUNNING"
|
||||
all_done=false
|
||||
else
|
||||
echo "➖ $task_name: NOT STARTED"
|
||||
all_done=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_done; then
|
||||
echo "STATUS: COMPLETE"
|
||||
echo "LOG_DIR: $log_dir"
|
||||
else
|
||||
echo "STATUS: IN_PROGRESS"
|
||||
fi
|
||||
@@ -40,6 +40,8 @@ jobs:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
@@ -56,48 +58,38 @@ jobs:
|
||||
for (const m of members) maintainerLogins.add(m.login.toLowerCase());
|
||||
core.info(`Successfully fetched ${members.length} team members from ${team_slug}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`);
|
||||
// Silently skip if permissions are insufficient; we will rely on author_association
|
||||
core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isGooglerCache = new Map();
|
||||
const isGoogler = async (login) => {
|
||||
if (isGooglerCache.has(login)) return isGooglerCache.get(login);
|
||||
const isMaintainer = async (login, assoc) => {
|
||||
// Reliably identify maintainers using authorAssociation (provided by GitHub)
|
||||
// and organization membership (if available).
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
if (isTeamMember || isRepoMaintainer) return true;
|
||||
|
||||
// Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission)
|
||||
try {
|
||||
// Check membership in 'googlers' or 'google' orgs
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: org,
|
||||
username: login
|
||||
});
|
||||
core.info(`User ${login} is a member of ${org} organization.`);
|
||||
isGooglerCache.set(login, true);
|
||||
await github.rest.orgs.checkMembershipForUser({ org: org, username: login });
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 404 just means they aren't a member, which is fine
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to check org membership for ${login}: ${e.message}`);
|
||||
// Gracefully ignore failures here
|
||||
}
|
||||
|
||||
isGooglerCache.set(login, false);
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMaintainer = async (login, assoc) => {
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
if (isTeamMember || isRepoMaintainer) return true;
|
||||
|
||||
return await isGoogler(login);
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
// 2. Fetch all open PRs
|
||||
let prs = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -118,64 +110,77 @@ jobs:
|
||||
for (const pr of prs) {
|
||||
const maintainerPr = await isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
if (maintainerPr || isBot) continue;
|
||||
|
||||
// Detection Logic for Linked Issues
|
||||
// Check 1: Official GitHub "Closing Issue" link (GraphQL)
|
||||
const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
// Helper: Fetch labels and linked issues via GraphQL
|
||||
const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) { totalCount }
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let hasClosingLink = false;
|
||||
let linkedIssues = [];
|
||||
try {
|
||||
const res = await github.graphql(linkedIssueQuery, {
|
||||
const res = await github.graphql(prDetailsQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0;
|
||||
} catch (e) {}
|
||||
|
||||
// Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123")
|
||||
// We check for # followed by numbers or direct URLs to issues.
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const hasMentionLink = mentionRegex.test(body);
|
||||
|
||||
const hasLinkedIssue = hasClosingLink || hasMentionLink;
|
||||
|
||||
// Logic for Closed PRs (Auto-Reopen)
|
||||
if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') {
|
||||
if (hasLinkedIssue) {
|
||||
core.info(`PR #${pr.number} now has a linked issue. Reopening.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'open'
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Thank you for linking an issue! This pull request has been automatically reopened."
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// Logic for Open PRs (Immediate Closure)
|
||||
if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) {
|
||||
core.info(`PR #${pr.number} is missing a linked issue. Closing.`);
|
||||
// Check for mentions in body as fallback (regex)
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const matches = body.match(mentionRegex);
|
||||
if (matches && linkedIssues.length === 0) {
|
||||
const issueNumber = parseInt(matches[1]);
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }];
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 3. Enforcement Logic
|
||||
const prLabels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const hasHelpWanted = prLabels.includes('help wanted') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted'));
|
||||
|
||||
const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only'));
|
||||
|
||||
const hasLinkedIssue = linkedIssues.length > 0;
|
||||
|
||||
// Closure Policy: No help-wanted label = Close after 14 days
|
||||
if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) {
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
|
||||
// We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM
|
||||
if (prCreatedAt > fourteenDaysAgo) {
|
||||
core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!"
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
@@ -187,27 +192,22 @@ jobs:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staleness check (Scheduled runs only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
// Also check for linked issue even if it has help wanted (redundant but safe)
|
||||
if (pr.state === 'open' && !hasLinkedIssue) {
|
||||
// Already covered by hasHelpWanted check above, but good for future-proofing
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Staleness Check (Scheduled only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
if (prCreatedAt > thirtyDaysAgo) continue;
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number
|
||||
owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (await isMaintainer(r.user.login, r.author_association)) {
|
||||
@@ -216,9 +216,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number
|
||||
});
|
||||
for (const c of comments) {
|
||||
if (await isMaintainer(c.user.login, c.author_association)) {
|
||||
@@ -226,25 +224,23 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
core.info(`PR #${pr.number} is stale.`);
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only');
|
||||
if (isProtected) {
|
||||
core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!"
|
||||
body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -22,9 +22,10 @@ powerful tool for developers.
|
||||
rendering.
|
||||
- `packages/core`: Backend logic, Gemini API orchestration, prompt
|
||||
construction, and tool execution.
|
||||
- `packages/core/src/tools/`: Built-in tools for file system, shell, and web
|
||||
operations.
|
||||
- `packages/a2a-server`: Experimental Agent-to-Agent server.
|
||||
- `packages/sdk`: Programmatic SDK for embedding Gemini CLI capabilities.
|
||||
- `packages/devtools`: Integrated developer tools (Network/Console inspector).
|
||||
- `packages/test-utils`: Shared test utilities and test rig.
|
||||
- `packages/vscode-ide-companion`: VS Code extension pairing with the CLI.
|
||||
|
||||
## Building and Running
|
||||
@@ -58,10 +59,6 @@ powerful tool for developers.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a
|
||||
snapshot of an older system prompt. Avoid changing the prompting verbiage to
|
||||
preserve its historical behavior; however, structural changes to ensure
|
||||
compilation or simplify the code are permitted.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
@@ -69,8 +66,6 @@ powerful tool for developers.
|
||||
`gh` CLI.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
and `packages/core` (Backend logic).
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
|
||||
|
||||
@@ -125,10 +125,6 @@ on GitHub.
|
||||
|
||||
## Announcements: v0.28.0 - 2026-02-10
|
||||
|
||||
- **Slash Command:** We've added a new `/prompt-suggest` slash command to help
|
||||
you generate prompt suggestions
|
||||
([#17264](https://github.com/google-gemini/gemini-cli/pull/17264) by
|
||||
@NTaylorMullen).
|
||||
- **IDE Support:** Gemini CLI now supports the Positron IDE
|
||||
([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by
|
||||
@kapsner).
|
||||
@@ -168,8 +164,8 @@ on GitHub.
|
||||
([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by
|
||||
@joshualitt).
|
||||
- **UI/UX Improvements:** You can now "Rewind" through your conversation history
|
||||
([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by @Adib234)
|
||||
and use a new `/introspect` command for debugging.
|
||||
([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by
|
||||
@Adib234).
|
||||
- **Core and Scheduler Refactoring:** The core scheduler has been significantly
|
||||
refactored to improve performance and reliability
|
||||
([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.34.0-preview.1
|
||||
# Preview release: v0.34.0-preview.3
|
||||
|
||||
Released: March 12, 2026
|
||||
Released: March 13, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -28,6 +28,14 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
|
||||
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
|
||||
@gemini-cli-robot in
|
||||
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
|
||||
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
|
||||
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
|
||||
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
|
||||
@@ -468,4 +476,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.3
|
||||
|
||||
@@ -120,7 +120,8 @@ These are the only allowed tools:
|
||||
[`list_directory`](../tools/file-system.md#1-list_directory-readfolder),
|
||||
[`glob`](../tools/file-system.md#4-glob-findfiles)
|
||||
- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext),
|
||||
[`google_web_search`](../tools/web-search.md)
|
||||
[`google_web_search`](../tools/web-search.md),
|
||||
[`get_internal_docs`](../tools/internal-docs.md)
|
||||
- **Research Subagents:**
|
||||
[`codebase_investigator`](../core/subagents.md#codebase-investigator),
|
||||
[`cli_help`](../core/subagents.md#cli-help-agent)
|
||||
|
||||
@@ -125,7 +125,9 @@ they appear in the UI.
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
@@ -150,6 +152,7 @@ they appear in the UI.
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| 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` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -901,6 +901,20 @@ Logs keychain availability checks.
|
||||
|
||||
- `available` (boolean)
|
||||
|
||||
##### `gemini_cli.startup_stats`
|
||||
|
||||
Logs detailed startup performance statistics.
|
||||
|
||||
<details>
|
||||
<summary>Attributes</summary>
|
||||
|
||||
- `phases` (json array of startup phases)
|
||||
- `os_platform` (string)
|
||||
- `os_release` (string)
|
||||
- `is_docker` (boolean)
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
### Metrics
|
||||
@@ -917,6 +931,20 @@ Gemini CLI exports several custom metrics.
|
||||
|
||||
Incremented once per CLI startup.
|
||||
|
||||
##### Onboarding
|
||||
|
||||
Tracks onboarding flow from authentication to the user
|
||||
|
||||
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
|
||||
authentication flow begins.
|
||||
|
||||
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
|
||||
onboarding flow completes successfully.
|
||||
<details>
|
||||
<summary>Attributes (Success)</summary>
|
||||
|
||||
- `user_tier` (string)
|
||||
|
||||
##### Tools
|
||||
|
||||
##### `gemini_cli.tool.call.count`
|
||||
|
||||
@@ -25,6 +25,20 @@ To use remote subagents, you must explicitly enable them in your
|
||||
}
|
||||
```
|
||||
|
||||
## Proxy support
|
||||
|
||||
Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is
|
||||
configured. It uses the `general.proxy` setting in your `settings.json` file or
|
||||
standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`).
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"proxy": "http://my-proxy:8080"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Defining remote subagents
|
||||
|
||||
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
|
||||
@@ -40,6 +54,7 @@ You can place them in:
|
||||
| `kind` | string | Yes | Must be `remote`. |
|
||||
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
|
||||
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
|
||||
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
|
||||
|
||||
### Single-subagent example
|
||||
|
||||
@@ -70,6 +85,273 @@ Markdown file.
|
||||
> **Note:** Mixed local and remote agents, or multiple local agents, are not
|
||||
> supported in a single file; the list format is currently remote-only.
|
||||
|
||||
## Authentication
|
||||
|
||||
Many remote agents require authentication. Gemini CLI supports several
|
||||
authentication methods aligned with the
|
||||
[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme).
|
||||
Add an `auth` block to your agent's frontmatter to configure credentials.
|
||||
|
||||
### Supported auth types
|
||||
|
||||
Gemini CLI supports the following authentication types:
|
||||
|
||||
| Type | Description |
|
||||
| :------------------- | :--------------------------------------------------------------------------------------------- |
|
||||
| `apiKey` | Send a static API key as an HTTP header. |
|
||||
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
|
||||
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
|
||||
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
|
||||
|
||||
### Dynamic values
|
||||
|
||||
For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`,
|
||||
`password`, `value`) support dynamic resolution:
|
||||
|
||||
| Format | Description | Example |
|
||||
| :---------- | :-------------------------------------------------- | :------------------------- |
|
||||
| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` |
|
||||
| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` |
|
||||
| literal | Use the string as-is. | `sk-abc123` |
|
||||
| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` |
|
||||
|
||||
> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets
|
||||
> directly in agent files, especially for project-level agents checked into
|
||||
> version control.
|
||||
|
||||
### API key (`apiKey`)
|
||||
|
||||
Sends an API key as an HTTP header on every request.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----- | :----- | :------- | :---------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `apiKey`. |
|
||||
| `key` | string | Yes | The API key value. Supports dynamic values. |
|
||||
| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-agent
|
||||
agent_card_url: https://example.com/agent-card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
---
|
||||
```
|
||||
|
||||
### HTTP authentication (`http`)
|
||||
|
||||
Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP
|
||||
authentication schemes.
|
||||
|
||||
#### Bearer token
|
||||
|
||||
Use the following fields to configure a Bearer token:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :----------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Bearer`. |
|
||||
| `token` | string | Yes | The bearer token. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $MY_BEARER_TOKEN
|
||||
```
|
||||
|
||||
#### Basic authentication
|
||||
|
||||
Use the following fields to configure Basic authentication:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--------- | :----- | :------- | :------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | Must be `Basic`. |
|
||||
| `username` | string | Yes | The username. Supports dynamic values. |
|
||||
| `password` | string | Yes | The password. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $MY_USERNAME
|
||||
password: $MY_PASSWORD
|
||||
```
|
||||
|
||||
#### Raw scheme
|
||||
|
||||
For any other IANA-registered scheme (for example, Digest, HOBA), provide the
|
||||
raw authorization value.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :----- | :------- | :---------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `http`. |
|
||||
| `scheme` | string | Yes | The scheme name (for example, `Digest`). |
|
||||
| `value` | string | Yes | Raw value sent as `Authorization: <scheme> <value>`. Supports dynamic values. |
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
type: http
|
||||
scheme: Digest
|
||||
value: $MY_DIGEST_VALUE
|
||||
```
|
||||
|
||||
### Google Application Default Credentials (`google-credentials`)
|
||||
|
||||
Uses
|
||||
[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials)
|
||||
to authenticate with Google Cloud services and Cloud Run endpoints. This is the
|
||||
recommended auth method for agents hosted on Google Cloud infrastructure.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :------- | :------- | :-------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `google-credentials`. |
|
||||
| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: my-gcp-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
#### How token selection works
|
||||
|
||||
The provider automatically selects the correct token type based on the agent's
|
||||
host:
|
||||
|
||||
| Host pattern | Token type | Use case |
|
||||
| :----------------- | :----------------- | :------------------------------------------ |
|
||||
| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) |
|
||||
| `*.run.app` | **Identity token** | Cloud Run services |
|
||||
|
||||
- **Access tokens** authorize API calls to Google services. They are scoped
|
||||
(default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`.
|
||||
- **Identity tokens** prove the caller's identity to a service that validates
|
||||
the token's audience. The audience is set to the target host. These are
|
||||
fetched via `GoogleAuth.getIdTokenClient()`.
|
||||
|
||||
Both token types are cached and automatically refreshed before expiry.
|
||||
|
||||
#### Setup
|
||||
|
||||
`google-credentials` relies on ADC, which means your environment must have
|
||||
credentials configured. Common setups:
|
||||
|
||||
- **Local development:** Run `gcloud auth application-default login` to
|
||||
authenticate with your Google account.
|
||||
- **CI / Cloud environments:** Use a service account. Set the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your
|
||||
service account key file, or use workload identity on GKE / Cloud Run.
|
||||
|
||||
#### Allowed hosts
|
||||
|
||||
For security, `google-credentials` only sends tokens to known Google-owned
|
||||
hosts:
|
||||
|
||||
- `*.googleapis.com`
|
||||
- `*.run.app`
|
||||
|
||||
Requests to any other host will be rejected with an error. If your agent is
|
||||
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
|
||||
or `oauth2`).
|
||||
|
||||
#### Examples
|
||||
|
||||
The following examples demonstrate how to configure Google Application Default
|
||||
Credentials.
|
||||
|
||||
**Cloud Run agent:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: cloud-run-agent
|
||||
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
---
|
||||
```
|
||||
|
||||
**Google API with custom scopes:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: vertex-agent
|
||||
agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json
|
||||
auth:
|
||||
type: google-credentials
|
||||
scopes:
|
||||
- https://www.googleapis.com/auth/cloud-platform
|
||||
- https://www.googleapis.com/auth/compute
|
||||
---
|
||||
```
|
||||
|
||||
### OAuth 2.0 (`oauth2`)
|
||||
|
||||
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
|
||||
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
|
||||
for subsequent requests.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Must be `oauth2`. |
|
||||
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
|
||||
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
|
||||
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
|
||||
| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. |
|
||||
| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. |
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: remote
|
||||
name: oauth-agent
|
||||
agent_card_url: https://example.com/.well-known/agent.json
|
||||
auth:
|
||||
type: oauth2
|
||||
client_id: my-client-id.apps.example.com
|
||||
---
|
||||
```
|
||||
|
||||
If the agent card advertises an `oauth2` security scheme with
|
||||
`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are
|
||||
automatically discovered. You only need to provide `client_id` (and
|
||||
`client_secret` if required).
|
||||
|
||||
Tokens are persisted to disk and refreshed automatically when they expire.
|
||||
|
||||
### Auth validation
|
||||
|
||||
When Gemini CLI loads a remote agent, it validates your auth configuration
|
||||
against the agent card's declared `securitySchemes`. If the agent requires
|
||||
authentication that you haven't configured, you'll see an error describing
|
||||
what's needed.
|
||||
|
||||
`google-credentials` is treated as compatible with `http` Bearer security
|
||||
schemes, since it produces Bearer tokens.
|
||||
|
||||
### Auth retry behavior
|
||||
|
||||
All auth providers automatically retry on `401` and `403` responses by
|
||||
re-fetching credentials (up to 2 retries). This handles cases like expired
|
||||
tokens or rotated credentials. For `apiKey` with `!command` values, the command
|
||||
is re-executed on retry to fetch a fresh key.
|
||||
|
||||
### Agent card fetching and auth
|
||||
|
||||
When connecting to a remote agent, Gemini CLI first fetches the agent card
|
||||
**without** authentication. If the card endpoint returns a `401` or `403`, it
|
||||
retries the fetch **with** the configured auth headers. This lets agents have
|
||||
publicly accessible cards while protecting their task endpoints, or to protect
|
||||
both behind auth.
|
||||
|
||||
## Managing Subagents
|
||||
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
+137
-27
@@ -7,20 +7,14 @@ the main agent's context or toolset.
|
||||
|
||||
> **Note: Subagents are currently an experimental feature.**
|
||||
>
|
||||
> To use custom subagents, you must explicitly enable them in your
|
||||
> `settings.json`:
|
||||
> To use custom subagents, you must ensure they are enabled in your
|
||||
> `settings.json` (enabled by default):
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": { "enableAgents": true }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **Warning:** Subagents currently operate in
|
||||
> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning
|
||||
> they may execute tools without individual user confirmation for each step.
|
||||
> Proceed with caution when defining agents with powerful tools like
|
||||
> `run_shell_command` or `write_file`.
|
||||
|
||||
## What are subagents?
|
||||
|
||||
@@ -38,6 +32,34 @@ main agent calls the tool, it delegates the task to the subagent. Once the
|
||||
subagent completes its task, it reports back to the main agent with its
|
||||
findings.
|
||||
|
||||
## How to use subagents
|
||||
|
||||
You can use subagents through automatic delegation or by explicitly forcing them
|
||||
in your prompt.
|
||||
|
||||
### Automatic delegation
|
||||
|
||||
Gemini CLI's main agent is instructed to use specialized subagents when a task
|
||||
matches their expertise. For example, if you ask "How does the auth system
|
||||
work?", the main agent may decide to call the `codebase_investigator` subagent
|
||||
to perform the research.
|
||||
|
||||
### Forcing a subagent (@ syntax)
|
||||
|
||||
You can explicitly direct a task to a specific subagent by using the `@` symbol
|
||||
followed by the subagent's name at the beginning of your prompt. This is useful
|
||||
when you want to bypass the main agent's decision-making and go straight to a
|
||||
specialist.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
|
||||
```
|
||||
|
||||
When you use the `@` syntax, the CLI injects a system note that nudges the
|
||||
primary model to use that specific subagent tool immediately.
|
||||
|
||||
## Built-in subagents
|
||||
|
||||
Gemini CLI comes with the following built-in subagents:
|
||||
@@ -49,15 +71,17 @@ Gemini CLI comes with the following built-in subagents:
|
||||
dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
- **Configuration:** Enabled by default. You can override its settings in
|
||||
`settings.json` under `agents.overrides`. Example (forcing a specific model
|
||||
and increasing turns):
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"codebase_investigator": {
|
||||
"modelConfig": { "model": "gemini-3-flash-preview" },
|
||||
"runConfig": { "maxTurns": 50 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +257,7 @@ kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- grep_search
|
||||
model: gemini-2.5-pro
|
||||
model: gemini-3-flash-preview
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
@@ -254,16 +278,102 @@ it yourself; just report it.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
|
||||
|
||||
### Tool wildcards
|
||||
|
||||
When defining `tools` for a subagent, you can use wildcards to quickly grant
|
||||
access to groups of tools:
|
||||
|
||||
- `*`: Grant access to all available built-in and discovered tools.
|
||||
- `mcp_*`: Grant access to all tools from all connected MCP servers.
|
||||
- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
|
||||
`my-server`.
|
||||
|
||||
### Isolation and recursion protection
|
||||
|
||||
Each subagent runs in its own isolated context loop. This means:
|
||||
|
||||
- **Independent history:** The subagent's conversation history does not bloat
|
||||
the main agent's context.
|
||||
- **Isolated tools:** The subagent only has access to the tools you explicitly
|
||||
grant it.
|
||||
- **Recursion protection:** To prevent infinite loops and excessive token usage,
|
||||
subagents **cannot** call other subagents. If a subagent is granted the `*`
|
||||
tool wildcard, it will still be unable to see or invoke other agents.
|
||||
|
||||
## Managing subagents
|
||||
|
||||
You can manage subagents interactively using the `/agents` command or
|
||||
persistently via `settings.json`.
|
||||
|
||||
### Interactive management (/agents)
|
||||
|
||||
If you are in an interactive CLI session, you can use the `/agents` command to
|
||||
manage subagents without editing configuration files manually. This is the
|
||||
recommended way to quickly enable, disable, or re-configure agents on the fly.
|
||||
|
||||
For a full list of sub-commands and usage, see the
|
||||
[`/agents` command reference](../reference/commands.md#agents).
|
||||
|
||||
### Persistent configuration (settings.json)
|
||||
|
||||
While the `/agents` command and agent definition files provide a starting point,
|
||||
you can use `settings.json` for global, persistent overrides. This is useful for
|
||||
enforcing specific models or execution limits across all sessions.
|
||||
|
||||
#### `agents.overrides`
|
||||
|
||||
Use this to enable or disable specific agents or override their run
|
||||
configurations.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"security-auditor": {
|
||||
"enabled": false,
|
||||
"runConfig": {
|
||||
"maxTurns": 20,
|
||||
"maxTimeMinutes": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `modelConfigs.overrides`
|
||||
|
||||
You can target specific subagents with custom model settings (like system
|
||||
instruction prefixes or specific safety settings) using the `overrideScope`
|
||||
field.
|
||||
|
||||
```json
|
||||
{
|
||||
"modelConfigs": {
|
||||
"overrides": [
|
||||
{
|
||||
"match": { "overrideScope": "security-auditor" },
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimizing your subagent
|
||||
|
||||
@@ -298,7 +408,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
See the [Remote Subagents documentation](remote-agents) for detailed
|
||||
configuration and usage instructions.
|
||||
configuration, authentication, and usage instructions.
|
||||
|
||||
## Extension subagents
|
||||
|
||||
|
||||
@@ -14,6 +14,31 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **Description:** Show version info. Share this information when filing issues.
|
||||
|
||||
### `/agents`
|
||||
|
||||
- **Description:** Manage local and remote subagents.
|
||||
- **Note:** This command is experimental and requires
|
||||
`experimental.enableAgents: true` in your `settings.json`.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** Lists all discovered agents, including built-in, local,
|
||||
and remote agents.
|
||||
- **Usage:** `/agents list`
|
||||
- **`reload`** (alias: `refresh`):
|
||||
- **Description:** Rescans agent directories (`~/.gemini/agents` and
|
||||
`.gemini/agents`) and reloads the registry.
|
||||
- **Usage:** `/agents reload`
|
||||
- **`enable`**:
|
||||
- **Description:** Enables a specific subagent.
|
||||
- **Usage:** `/agents enable <agent-name>`
|
||||
- **`disable`**:
|
||||
- **Description:** Disables a specific subagent.
|
||||
- **Usage:** `/agents disable <agent-name>`
|
||||
- **`config`**:
|
||||
- **Description:** Opens a configuration dialog for the specified agent to
|
||||
adjust its model, temperature, or execution limits.
|
||||
- **Usage:** `/agents config <agent-name>`
|
||||
|
||||
### `/auth`
|
||||
|
||||
- **Description:** Open a dialog that lets you change the authentication method.
|
||||
|
||||
@@ -841,6 +841,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.allowedDomains`** (array):
|
||||
- **Description:** A list of allowed domains for the browser agent (e.g.,
|
||||
["github.com", "*.google.com"]).
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
["github.com", "*.google.com", "localhost"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.disableUserInput`** (boolean):
|
||||
- **Description:** Disable user input on browser window during automation.
|
||||
- **Default:** `true`
|
||||
@@ -908,9 +919,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `tools`
|
||||
|
||||
- **`tools.sandbox`** (string):
|
||||
- **Description:** Sandbox execution environment. Set to a boolean to enable
|
||||
or disable the sandbox, provide a string path to a sandbox profile, or
|
||||
specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
|
||||
- **Description:** Legacy full-process sandbox execution environment. Set to a
|
||||
boolean to enable or disable the sandbox, provide a string path to a sandbox
|
||||
profile, or specify an explicit sandbox command (e.g., "docker", "podman",
|
||||
"lxc").
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -1014,11 +1026,22 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `security`
|
||||
|
||||
- **`security.toolSandboxing`** (boolean):
|
||||
- **Description:** Experimental tool-level sandboxing (implementation in
|
||||
progress).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.disableYoloMode`** (boolean):
|
||||
- **Description:** Disable YOLO mode, even if enabled by a flag.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.disableAlwaysAllow`** (boolean):
|
||||
- **Description:** Disable "Always allow" options in tool confirmation
|
||||
dialogs.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.enablePermanentToolApproval`** (boolean):
|
||||
- **Description:** Enable the "Allow for all future sessions" option in tool
|
||||
confirmation dialogs.
|
||||
@@ -1135,8 +1158,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -1168,7 +1190,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.jitContext`** (boolean):
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
@@ -1226,6 +1248,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"gemma3-1b-gpu-custom"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.enabled`** (boolean):
|
||||
@@ -1315,7 +1342,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `admin`
|
||||
|
||||
- **`admin.secureModeEnabled`** (boolean):
|
||||
- **Description:** If true, disallows yolo mode from being used.
|
||||
- **Description:** If true, disallows YOLO mode and "Always allow" options
|
||||
from being used.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`admin.extensions.enabled`** (boolean):
|
||||
|
||||
@@ -60,7 +60,7 @@ command.
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "git "
|
||||
commandPrefix = "git"
|
||||
decision = "ask_user"
|
||||
priority = 100
|
||||
```
|
||||
@@ -264,7 +264,7 @@ argsPattern = '"command":"(git|npm)'
|
||||
|
||||
# (Optional) A string or array of strings that a shell command must start with.
|
||||
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
|
||||
commandPrefix = "git "
|
||||
commandPrefix = "git"
|
||||
|
||||
# (Optional) A regex to match against the entire shell command.
|
||||
# This is also syntactic sugar for `toolName = "run_shell_command"`.
|
||||
@@ -321,7 +321,7 @@ This rule will ask for user confirmation before executing any `git` command.
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "git "
|
||||
commandPrefix = "git"
|
||||
decision = "ask_user"
|
||||
priority = 100
|
||||
```
|
||||
|
||||
@@ -25,7 +25,8 @@ confirmation.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
multiple options. Automatically adds an "All the above" option if there
|
||||
are multiple standard options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
|
||||
@@ -729,6 +729,43 @@ tools. The model will automatically:
|
||||
|
||||
The MCP integration tracks several states:
|
||||
|
||||
#### Overriding extension configurations
|
||||
|
||||
If an MCP server is provided by an extension (for example, the
|
||||
`google-workspace` extension), you can still override its settings in your local
|
||||
`settings.json`. Gemini CLI merges your local configuration with the extension's
|
||||
defaults:
|
||||
|
||||
- **Tool lists:** Tool lists are merged securely to ensure the most restrictive
|
||||
policy wins:
|
||||
- **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either
|
||||
source blocks a tool, it remains disabled.
|
||||
- **Inclusions (`includeTools`):** Arrays are intersected. If both sources
|
||||
provide an allowlist, only tools present in **both** lists are enabled. If
|
||||
only one source provides an allowlist, that list is respected.
|
||||
- **Precedence:** `excludeTools` always takes precedence over `includeTools`.
|
||||
|
||||
This ensures you always have veto power over tools provided by an extension
|
||||
and that an extension cannot re-enable tools you have omitted from your
|
||||
personal allowlist.
|
||||
|
||||
- **Environment variables:** The `env` objects are merged. If the same variable
|
||||
is defined in both places, your local value takes precedence.
|
||||
- **Scalar properties:** Properties like `command`, `url`, and `timeout` are
|
||||
replaced by your local values if provided.
|
||||
|
||||
**Example override:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"google-workspace": {
|
||||
"excludeTools": ["gmail.send"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Server status (`MCPServerStatus`)
|
||||
|
||||
- **`DISCONNECTED`:** Server is not connected or has errors
|
||||
|
||||
+18
-2
@@ -51,6 +51,7 @@ export default tseslint.config(
|
||||
'evals/**',
|
||||
'packages/test-utils/**',
|
||||
'.gemini/skills/**',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
@@ -206,11 +207,26 @@ export default tseslint.config(
|
||||
{
|
||||
// Rules that only apply to product code
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx', 'packages/*/src/test-utils/**'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'CallExpression[callee.object.name="Object"][callee.property.name="create"]',
|
||||
message:
|
||||
'Avoid using Object.create() in product code. Use object spread {...obj}, explicit class instantiation, structuredClone(), or copy constructors instead.',
|
||||
},
|
||||
{
|
||||
selector: 'Identifier[name="Reflect"]',
|
||||
message:
|
||||
'Avoid using Reflect namespace in product code. Do not use reflection to make copies. Instead, use explicit object copying or cloning (structuredClone() for values, new instance/clone function for classes).',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -303,7 +319,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js'],
|
||||
files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => {
|
||||
* Ensures that when the user asks a question about style, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should not edit files when asked about style',
|
||||
prompt: 'Is app.ts following good style?',
|
||||
files: FILES,
|
||||
|
||||
+72
-33
@@ -5,31 +5,62 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
|
||||
import { EvalPolicy } from './test-helper.js';
|
||||
|
||||
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
return appEvalTest(policy, {
|
||||
...evalCase,
|
||||
configOverrides: {
|
||||
...evalCase.configOverrides,
|
||||
general: {
|
||||
...evalCase.configOverrides?.general,
|
||||
approvalMode: 'default',
|
||||
enableAutoUpdate: false,
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
},
|
||||
files: {
|
||||
...evalCase.files,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ask_user', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to present multiple choice options',
|
||||
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
|
||||
},
|
||||
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
|
||||
const confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
expect(
|
||||
confirmation,
|
||||
'Expected a pending confirmation for ask_user tool',
|
||||
).toBeDefined();
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
|
||||
files: {
|
||||
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
|
||||
@@ -39,28 +70,37 @@ describe('ask_user', () => {
|
||||
}),
|
||||
'README.md': '# Gemini CLI',
|
||||
},
|
||||
prompt: `Refactor the entire core package to be better.`,
|
||||
prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['enter_plan_mode', 'ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
// It might call enter_plan_mode first.
|
||||
let confirmation = await rig.waitForPendingConfirmation([
|
||||
'enter_plan_mode',
|
||||
'ask_user',
|
||||
]);
|
||||
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
|
||||
|
||||
if (confirmation?.name === 'enter_plan_mode') {
|
||||
rig.acceptConfirmation('enter_plan_mode');
|
||||
confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
}
|
||||
|
||||
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
'Expected ask_user tool to be called to clarify the significant rework',
|
||||
).toBe(true);
|
||||
confirmation?.toolName,
|
||||
'Expected ask_user to be called to clarify the significant rework',
|
||||
).toBe('ask_user');
|
||||
},
|
||||
});
|
||||
|
||||
// --- Regression Tests for Recent Fixes ---
|
||||
|
||||
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
|
||||
// Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to
|
||||
// confirm shell commands. Fixed via prompt refinements and tool definition
|
||||
// updates to clarify that shell command confirmation is handled by the UI.
|
||||
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
|
||||
evalTest('USUALLY_PASSES', {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -68,25 +108,24 @@ describe('ask_user', () => {
|
||||
}),
|
||||
},
|
||||
prompt: `Run 'npm run build' in the current directory.`,
|
||||
setup: async (rig) => {
|
||||
rig.setBreakpoint(['run_shell_command', 'ask_user']);
|
||||
},
|
||||
assert: async (rig) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const wasShellCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const wasAskUserCalled = toolLogs.some(
|
||||
(log) => log.toolRequest.name === 'ask_user',
|
||||
);
|
||||
const confirmation = await rig.waitForPendingConfirmation([
|
||||
'run_shell_command',
|
||||
'ask_user',
|
||||
]);
|
||||
|
||||
expect(
|
||||
wasShellCalled,
|
||||
'Expected run_shell_command tool to be called',
|
||||
).toBe(true);
|
||||
confirmation,
|
||||
'Expected a pending confirmation for a tool',
|
||||
).toBeDefined();
|
||||
|
||||
expect(
|
||||
wasAskUserCalled,
|
||||
confirmation?.toolName,
|
||||
'ask_user should not be called to confirm shell commands',
|
||||
).toBe(false);
|
||||
).toBe('run_shell_command');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js';
|
||||
describe('Hierarchical Memory', () => {
|
||||
const conflictResolutionTest =
|
||||
'Agent follows hierarchy for contradictory instructions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: conflictResolutionTest,
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
|
||||
@@ -203,4 +203,33 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
|
||||
// Should successfully complete all operations
|
||||
assertModelHasOutput(result);
|
||||
});
|
||||
|
||||
it('should handle tool confirmation for write_file without crashing', async () => {
|
||||
rig.setup('tool-confirmation', {
|
||||
fakeResponsesPath: join(
|
||||
__dirname,
|
||||
'browser-agent.confirmation.responses',
|
||||
),
|
||||
settings: {
|
||||
agents: {
|
||||
browser_agent: {
|
||||
headless: true,
|
||||
sessionMode: 'isolated',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
await run.type('Write hello to test.txt');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('Allow', 15000);
|
||||
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('successfully written', 15000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,11 +42,10 @@ describe('extension install', () => {
|
||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
||||
expect(listResult).toContain('test-extension-install');
|
||||
writeFileSync(testServerPath, extensionUpdate);
|
||||
const updateResult = await rig.runCommand([
|
||||
'extensions',
|
||||
'update',
|
||||
`test-extension-install`,
|
||||
]);
|
||||
const updateResult = await rig.runCommand(
|
||||
['extensions', 'update', `test-extension-install`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(updateResult).toContain('0.0.2');
|
||||
} finally {
|
||||
await rig.runCommand([
|
||||
|
||||
Generated
+549
-22
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -3038,6 +3038,27 @@
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
|
||||
"integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"extract-zip": "^2.0.1",
|
||||
"progress": "^2.0.3",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"semver": "^7.7.4",
|
||||
"tar-fs": "^3.1.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/cjs/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
@@ -3762,6 +3783,12 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz",
|
||||
@@ -3949,6 +3976,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-stable-stringify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz",
|
||||
"integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
@@ -5584,6 +5618,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
|
||||
@@ -5676,6 +5722,20 @@
|
||||
"typed-rest-client": "^1.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native-b4a": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-b4a": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -5685,6 +5745,93 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
|
||||
"integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-stream": "^2.6.4",
|
||||
"bare-url": "^2.2.2",
|
||||
"fast-fifo": "^1.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-os": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz",
|
||||
"integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bare": ">=1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
|
||||
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-os": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-stream": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz",
|
||||
"integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"streamx": "^2.21.0",
|
||||
"teex": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*",
|
||||
"bare-events": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-events": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-url": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
|
||||
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -5705,6 +5852,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/before-after-hook": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
|
||||
@@ -5895,7 +6051,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
@@ -6103,6 +6258,32 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chrome-devtools-mcp": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.19.0.tgz",
|
||||
"integrity": "sha512-LfqjOxdUjWvCQrfeI5V3ZBJCUIDKGNmexSbSAgsrjVggN4X1OSObLxleSlX2zwcXRZYxqy209cww0MXcXuN1zw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"chrome-devtools-mcp": "build/src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",
|
||||
"integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cjs-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
@@ -6901,7 +7082,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
@@ -6945,6 +7125,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ast-types": "^0.13.4",
|
||||
"escodegen": "^2.1.0",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -7204,6 +7398,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1581282",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
|
||||
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
@@ -7759,6 +7959,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/escodegen": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
|
||||
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esprima": "^4.0.1",
|
||||
"estraverse": "^5.2.0",
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"escodegen": "bin/escodegen.js",
|
||||
"esgenerate": "bin/esgenerate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"source-map": "~0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz",
|
||||
@@ -8118,7 +8339,6 @@
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
@@ -8137,7 +8357,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8189,6 +8408,15 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -8395,6 +8623,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -9037,6 +9271,29 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
|
||||
"integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-ftp": "^5.0.2",
|
||||
"data-uri-to-buffer": "^6.0.2",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri/node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
"integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
|
||||
@@ -9461,7 +9718,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
@@ -9663,7 +9919,6 @@
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
@@ -10577,7 +10832,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
@@ -10801,6 +11055,25 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
@@ -10849,6 +11122,15 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
@@ -11759,6 +12041,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
@@ -11959,6 +12247,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/netmask": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
|
||||
"integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
@@ -12401,7 +12698,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -12662,6 +12958,38 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
"integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/quickjs-emscripten": "^0.23.0",
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"get-uri": "^6.0.1",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"degenerator": "^5.0.0",
|
||||
"netmask": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz",
|
||||
@@ -13132,6 +13460,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -13237,6 +13574,40 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
|
||||
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"http-proxy-agent": "^7.0.1",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lru-cache": "^7.14.1",
|
||||
"pac-proxy-agent": "^7.1.0",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent/node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
@@ -13290,6 +13661,45 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "24.39.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.0.tgz",
|
||||
"integrity": "sha512-SzIxz76Kgu17HUIi57HOejPiN0JKa9VCd2GcPY1sAh6RA4BzGZarFQdOYIYrBdUVbtyH7CrDb9uhGEwVXK/YNA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "2.13.0",
|
||||
"chromium-bidi": "14.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"devtools-protocol": "0.0.1581282",
|
||||
"typed-query-selector": "^2.12.1",
|
||||
"webdriver-bidi-protocol": "0.4.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -14250,9 +14660,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -14317,7 +14727,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
@@ -14583,6 +14992,54 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -14711,6 +15168,17 @@
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
|
||||
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events-universal": "^1.0.0",
|
||||
"fast-fifo": "^1.3.2",
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
@@ -15308,6 +15776,32 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
|
||||
"integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^3.1.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bare-fs": "^4.0.1",
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
|
||||
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"bare-fs": "^4.5.5",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/teeny-request": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
|
||||
@@ -15363,6 +15857,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/teex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
|
||||
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"streamx": "^2.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/terminal-link": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz",
|
||||
@@ -15395,6 +15898,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
@@ -15719,7 +16231,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
@@ -15869,6 +16380,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
|
||||
"integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typed-rest-client": {
|
||||
"version": "1.8.11",
|
||||
"resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz",
|
||||
@@ -16336,6 +16853,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
|
||||
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
@@ -16890,7 +17413,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17005,7 +17528,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -17177,7 +17700,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -17226,12 +17749,14 @@
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
"mime": "4.0.7",
|
||||
"mnemonist": "^0.40.3",
|
||||
"open": "^10.1.2",
|
||||
"picomatch": "^4.0.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"puppeteer-core": "^24.0.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
@@ -17249,7 +17774,9 @@
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"chrome-devtools-mcp": "^0.19.0",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
@@ -17439,7 +17966,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -17454,7 +17981,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17471,7 +17998,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -17488,7 +18015,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"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.35.0-nightly.20260311.657f19c1f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`)
|
||||
|
||||
Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities
|
||||
over HTTP for inter-agent communication.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/agent/`: Agent session management for A2A interactions.
|
||||
- `src/commands/`: CLI command definitions for the A2A server binary.
|
||||
- `src/config/`: Server configuration.
|
||||
- `src/http/`: HTTP server and route handlers.
|
||||
- `src/persistence/`: Session and state persistence.
|
||||
- `src/utils/`: Shared utility functions.
|
||||
- `src/types.ts`: Shared type definitions.
|
||||
|
||||
## Running
|
||||
|
||||
- Binary entry point: `gemini-cli-a2a-server`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests: `npm test -w @google/gemini-cli-a2a-server`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -177,10 +177,13 @@ describe('a2a-server memory commands', () => {
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
{
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
shellExecutionConfig: {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
sandboxManager: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -103,7 +103,10 @@ export class AddMemoryCommand implements Command {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
shellExecutionConfig: {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
sandboxManager: loopContext.sandboxManager,
|
||||
},
|
||||
});
|
||||
await refreshMemory(context.config);
|
||||
return {
|
||||
|
||||
@@ -325,6 +325,28 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass enableAgents to Config constructor', async () => {
|
||||
const settings: Settings = {
|
||||
experimental: {
|
||||
enableAgents: false,
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to false when not provided', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
describe('interactivity', () => {
|
||||
it('should set interactive true when not headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface Settings {
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
tmpdir,
|
||||
type Config,
|
||||
type Storage,
|
||||
NoopSandboxManager,
|
||||
type ToolRegistry,
|
||||
type SandboxManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
import { expect, vi } from 'vitest';
|
||||
@@ -97,6 +99,15 @@ export function createMockConfig(
|
||||
}),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
sandboxManager: new NoopSandboxManager() as unknown as SandboxManager,
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
|
||||
dependencies.
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/config/keyBindings.ts`
|
||||
`packages/cli/src/ui/key/keyBindings.ts`
|
||||
- Do not implement any logic performing custom string measurement or string
|
||||
truncation. Use Ink layout instead leveraging ResizeObserver as needed.
|
||||
- Avoid prop drilling when at all possible.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.35.0-nightly.20260311.657f19c1f",
|
||||
"version": "0.35.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('GeminiAgent', () => {
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -654,6 +655,7 @@ describe('Session', () => {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
@@ -947,6 +949,61 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude always allow options when disableAlwaysAllow is true', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true);
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
|
||||
@@ -908,7 +908,7 @@ export class Session {
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
sessionId: this.id,
|
||||
options: toPermissionOptions(confirmationDetails),
|
||||
options: toPermissionOptions(confirmationDetails, this.config),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
@@ -1004,6 +1004,7 @@ export class Session {
|
||||
callId,
|
||||
toolResult.llmContent,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
),
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
@@ -1017,6 +1018,7 @@ export class Session {
|
||||
callId,
|
||||
toolResult.llmContent,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
@@ -1457,60 +1459,76 @@ const basicPermissionOptions = [
|
||||
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
): acp.PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
{
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
|
||||
if (!disableAlwaysAllow) {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'exec':
|
||||
return [
|
||||
{
|
||||
});
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'info':
|
||||
return [
|
||||
{
|
||||
});
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
});
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
// askuser and exit_plan_mode don't need "always allow" options
|
||||
break;
|
||||
default:
|
||||
// No "always allow" options for other types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
options.push(...basicPermissionOptions);
|
||||
|
||||
// Exhaustive check
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
case 'exec':
|
||||
case 'mcp':
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
// askuser doesn't need "always allow" options since it's asking questions
|
||||
return [...basicPermissionOptions];
|
||||
case 'exit_plan_mode':
|
||||
// exit_plan_mode doesn't need "always allow" options since it's a plan approval flow
|
||||
return [...basicPermissionOptions];
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
listExtensions,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type {
|
||||
|
||||
@@ -104,7 +104,10 @@ export class AddMemoryCommand implements Command {
|
||||
await context.sendMessage(`Saving memory via ${result.toolName}...`);
|
||||
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
shellExecutionConfig: {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
sandboxManager: context.config.sandboxManager,
|
||||
},
|
||||
});
|
||||
await refreshMemory(context.config);
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
@@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('handleInstall', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
securityWarnings: [],
|
||||
discoveryErrors: [],
|
||||
@@ -379,6 +380,7 @@ describe('handleInstall', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: ['cool-skill'],
|
||||
agents: ['cool-agent'],
|
||||
settings: [],
|
||||
securityWarnings: ['Security risk!'],
|
||||
discoveryErrors: ['Read error'],
|
||||
@@ -408,6 +410,10 @@ describe('handleInstall', () => {
|
||||
expect.stringContaining('cool-skill'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cool-agent'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security Warnings:'),
|
||||
false,
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
@@ -99,11 +99,15 @@ export async function handleInstall(args: InstallArgs) {
|
||||
if (hasDiscovery) {
|
||||
promptLines.push(chalk.bold('This folder contains:'));
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults.commands },
|
||||
{ label: 'MCP Servers', items: discoveryResults.mcps },
|
||||
{ label: 'Hooks', items: discoveryResults.hooks },
|
||||
{ label: 'Skills', items: discoveryResults.skills },
|
||||
{ label: 'Setting overrides', items: discoveryResults.settings },
|
||||
{ label: 'Commands', items: discoveryResults.commands ?? [] },
|
||||
{ label: 'MCP Servers', items: discoveryResults.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults.skills ?? [] },
|
||||
{ label: 'Agents', items: discoveryResults.agents ?? [] },
|
||||
{
|
||||
label: 'Setting overrides',
|
||||
items: discoveryResults.settings ?? [],
|
||||
},
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
for (const group of groups) {
|
||||
|
||||
@@ -13,26 +13,24 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { type Argv } from 'yargs';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.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: true },
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -5,27 +5,23 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.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,
|
||||
},
|
||||
);
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
|
||||
return { ...mocked, getErrorMessage: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type Argv } from 'yargs';
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
|
||||
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
|
||||
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
|
||||
@@ -66,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
@@ -28,6 +28,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
debugLogger,
|
||||
type SkillDefinition,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { installSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -24,6 +24,9 @@ const { debugLogger } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
|
||||
@@ -21,6 +21,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger,
|
||||
getErrorMessage: vi.fn((e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
}));
|
||||
|
||||
import { handleUninstall, uninstallCommand } from './uninstall.js';
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { uninstallSkill } from '../../utils/skillUtils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -814,7 +814,9 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
|
||||
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
});
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
path: '/path/to/ext1',
|
||||
@@ -865,6 +867,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
context: {
|
||||
includeDirectories: [includeDir],
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
@@ -892,6 +895,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
context: {
|
||||
includeDirectories: ['/path/to/include'],
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
@@ -1773,7 +1777,7 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings({
|
||||
@@ -1785,11 +1789,11 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash');
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
|
||||
});
|
||||
|
||||
it('selects the model from argv if provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings({
|
||||
@@ -1799,7 +1803,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash');
|
||||
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
|
||||
});
|
||||
|
||||
it('selects the default auto model if provided via auto alias', async () => {
|
||||
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
type HierarchicalMemory,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
isValidModelOrAlias,
|
||||
getValidModelsAndAliases,
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
@@ -496,11 +494,12 @@ export async function loadCliConfig(
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
let extensionRegistryURI =
|
||||
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
|
||||
(trustedFolder ? settings.experimental?.extensionRegistryURI : undefined);
|
||||
|
||||
let extensionRegistryURI: string | undefined = trustedFolder
|
||||
? settings.experimental?.extensionRegistryURI
|
||||
: undefined;
|
||||
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
|
||||
extensionRegistryURI = resolveToRealPath(
|
||||
path.resolve(cwd, resolvePath(extensionRegistryURI)),
|
||||
@@ -673,18 +672,6 @@ export async function loadCliConfig(
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
// Validate the model if one was explicitly specified
|
||||
if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) {
|
||||
if (!isValidModelOrAlias(specifiedModel)) {
|
||||
const validModels = getValidModelsAndAliases();
|
||||
|
||||
throw new FatalConfigError(
|
||||
`Invalid model: "${specifiedModel}"\n\n` +
|
||||
`Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` +
|
||||
`Use /model to switch models interactively.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
? defaultModel
|
||||
@@ -744,11 +731,14 @@ export async function loadCliConfig(
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
toolSandboxing: settings.security?.toolSandboxing ?? false,
|
||||
targetDir: cwd,
|
||||
includeDirectoryTree,
|
||||
includeDirectories,
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
|
||||
@@ -784,6 +774,9 @@ export async function loadCliConfig(
|
||||
approvalMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
showMemoryUsage: settings.ui?.showMemoryUsage || false,
|
||||
accessibility: {
|
||||
...settings.ui?.accessibility,
|
||||
@@ -823,6 +816,7 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
type Config,
|
||||
tmpdir,
|
||||
NoopSandboxManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings, SettingScope } from './settings.js';
|
||||
|
||||
describe('ExtensionManager theme loading', () => {
|
||||
@@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
terminalHeight: 24,
|
||||
showColor: false,
|
||||
pager: 'cat',
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
|
||||
@@ -18,9 +18,17 @@ import {
|
||||
loadTrustedFolders,
|
||||
isWorkspaceTrusted,
|
||||
} from './trustedFolders.js';
|
||||
import { getRealPath, type CustomTheme } from '@google/gemini-cli-core';
|
||||
import {
|
||||
getRealPath,
|
||||
type CustomTheme,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
const mockIntegrityManager = vi.hoisted(() => ({
|
||||
verify: vi.fn().mockResolvedValue('verified'),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
@@ -36,6 +44,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
ExtensionIntegrityManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockIntegrityManager),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -82,6 +93,7 @@ describe('ExtensionManager', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,6 +257,7 @@ describe('ExtensionManager', () => {
|
||||
} as unknown as MergedSettings,
|
||||
requestConsent: () => Promise.resolve(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
// Trust the workspace to allow installation
|
||||
@@ -290,6 +303,7 @@ describe('ExtensionManager', () => {
|
||||
settings,
|
||||
requestConsent: () => Promise.resolve(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const installMetadata = {
|
||||
@@ -324,6 +338,7 @@ describe('ExtensionManager', () => {
|
||||
settings,
|
||||
requestConsent: () => Promise.resolve(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const installMetadata = {
|
||||
@@ -353,6 +368,7 @@ describe('ExtensionManager', () => {
|
||||
settings: settingsOnlySymlink,
|
||||
requestConsent: () => Promise.resolve(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
// This should FAIL because it checks the real path against the pattern
|
||||
@@ -507,6 +523,80 @@ describe('ExtensionManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension integrity', () => {
|
||||
it('should store integrity data during installation', async () => {
|
||||
const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity');
|
||||
|
||||
const extDir = path.join(tempHomeDir, 'new-integrity-ext');
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: 'integrity-ext', version: '1.0.0' }),
|
||||
);
|
||||
|
||||
const installMetadata = {
|
||||
source: extDir,
|
||||
type: 'local' as const,
|
||||
};
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension(installMetadata);
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledWith('integrity-ext', installMetadata);
|
||||
});
|
||||
|
||||
it('should store integrity data during first update', async () => {
|
||||
const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity');
|
||||
const verifySpy = vi.spyOn(extensionManager, 'verifyExtensionIntegrity');
|
||||
|
||||
// Setup existing extension
|
||||
const extName = 'update-integrity-ext';
|
||||
const extDir = path.join(userExtensionsDir, extName);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: extName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: extDir }),
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Ensure no integrity data exists for this extension
|
||||
verifySpy.mockResolvedValueOnce(IntegrityDataStatus.MISSING);
|
||||
|
||||
const initialStatus = await extensionManager.verifyExtensionIntegrity(
|
||||
extName,
|
||||
{ type: 'local', source: extDir },
|
||||
);
|
||||
expect(initialStatus).toBe('missing');
|
||||
|
||||
// Create new version of the extension
|
||||
const newSourceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'new-source-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: extName, version: '1.1.0' }),
|
||||
);
|
||||
|
||||
const installMetadata = {
|
||||
source: newSourceDir,
|
||||
type: 'local' as const,
|
||||
};
|
||||
|
||||
// Perform update and verify integrity was stored
|
||||
await extensionManager.installOrUpdateExtension(installMetadata, {
|
||||
name: extName,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledWith(extName, installMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('early theme registration', () => {
|
||||
it('should register themes with ThemeManager during loadExtensions for active extensions', async () => {
|
||||
createExtension({
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
loadSkillsFromDir,
|
||||
loadAgentsFromDirectory,
|
||||
homedir,
|
||||
ExtensionIntegrityManager,
|
||||
type IExtensionIntegrity,
|
||||
type IntegrityDataStatus,
|
||||
type ExtensionEvents,
|
||||
type MCPServerConfig,
|
||||
type ExtensionInstallMetadata,
|
||||
@@ -89,6 +92,7 @@ interface ExtensionManagerParams {
|
||||
workspaceDir: string;
|
||||
eventEmitter?: EventEmitter<ExtensionEvents>;
|
||||
clientVersion?: string;
|
||||
integrityManager?: IExtensionIntegrity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +102,7 @@ interface ExtensionManagerParams {
|
||||
*/
|
||||
export class ExtensionManager extends ExtensionLoader {
|
||||
private extensionEnablementManager: ExtensionEnablementManager;
|
||||
private integrityManager: IExtensionIntegrity;
|
||||
private settings: MergedSettings;
|
||||
private requestConsent: (consent: string) => Promise<boolean>;
|
||||
private requestSetting:
|
||||
@@ -127,12 +132,28 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
});
|
||||
this.requestConsent = options.requestConsent;
|
||||
this.requestSetting = options.requestSetting ?? undefined;
|
||||
this.integrityManager =
|
||||
options.integrityManager ?? new ExtensionIntegrityManager();
|
||||
}
|
||||
|
||||
getEnablementManager(): ExtensionEnablementManager {
|
||||
return this.extensionEnablementManager;
|
||||
}
|
||||
|
||||
async verifyExtensionIntegrity(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata | undefined,
|
||||
): Promise<IntegrityDataStatus> {
|
||||
return this.integrityManager.verify(extensionName, metadata);
|
||||
}
|
||||
|
||||
async storeExtensionIntegrity(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata,
|
||||
): Promise<void> {
|
||||
return this.integrityManager.store(extensionName, metadata);
|
||||
}
|
||||
|
||||
setRequestConsent(
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
): void {
|
||||
@@ -159,10 +180,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) {
|
||||
const extensionAllowed = this.settings.security?.allowedExtensions.some(
|
||||
(pattern) => {
|
||||
try {
|
||||
@@ -421,6 +439,12 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
|
||||
// Establish trust at point of installation
|
||||
await this.storeExtensionIntegrity(
|
||||
newExtensionConfig.name,
|
||||
installMetadata,
|
||||
);
|
||||
|
||||
// TODO: Gracefully handle this call failing, we should back up the old
|
||||
// extension prior to overwriting it and then restore and restart it.
|
||||
extension = await this.loadExtension(destinationPath);
|
||||
@@ -693,10 +717,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) {
|
||||
if (!installMetadata?.source) {
|
||||
throw new Error(
|
||||
`Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
|
||||
@@ -898,9 +919,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
let skills = await loadSkillsFromDir(
|
||||
path.join(effectiveExtensionPath, 'skills'),
|
||||
);
|
||||
skills = skills.map((skill) =>
|
||||
recursivelyHydrateStrings(skill, hydrationContext),
|
||||
);
|
||||
skills = skills.map((skill) => ({
|
||||
...recursivelyHydrateStrings(skill, hydrationContext),
|
||||
extensionName: config.name,
|
||||
}));
|
||||
|
||||
let rules: PolicyRule[] | undefined;
|
||||
let checkers: SafetyCheckerRule[] | undefined;
|
||||
@@ -923,9 +945,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const agentLoadResult = await loadAgentsFromDirectory(
|
||||
path.join(effectiveExtensionPath, 'agents'),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
|
||||
recursivelyHydrateStrings(agent, hydrationContext),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({
|
||||
...recursivelyHydrateStrings(agent, hydrationContext),
|
||||
extensionName: config.name,
|
||||
}));
|
||||
|
||||
// Log errors but don't fail the entire extension load
|
||||
for (const error of agentLoadResult.errors) {
|
||||
|
||||
@@ -103,6 +103,10 @@ const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionDisable = vi.hoisted(() => vi.fn());
|
||||
const mockIntegrityManager = vi.hoisted(() => ({
|
||||
verify: vi.fn().mockResolvedValue('verified'),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -118,6 +122,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
ExtensionUninstallEvent: vi.fn(),
|
||||
ExtensionDisableEvent: vi.fn(),
|
||||
ExtensionIntegrityManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockIntegrityManager),
|
||||
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
|
||||
getSecret: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
@@ -214,6 +221,7 @@ describe('extension tests', () => {
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
resetTrustedFoldersForTesting();
|
||||
});
|
||||
@@ -241,7 +249,7 @@ describe('extension tests', () => {
|
||||
expect(extensions[0].name).toBe('test-extension');
|
||||
});
|
||||
|
||||
it('should throw an error if a context file path is outside the extension directory', async () => {
|
||||
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -654,7 +662,7 @@ name = "yolo-checker"
|
||||
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
|
||||
});
|
||||
|
||||
it('should skip extensions with invalid JSON and log a warning', async () => {
|
||||
it('should skip an extension with invalid JSON config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -685,7 +693,7 @@ name = "yolo-checker"
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip extensions with missing name and log a warning', async () => {
|
||||
it('should skip an extension with missing "name" in config and log an error', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -735,7 +743,7 @@ name = "yolo-checker"
|
||||
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error for invalid extension names', async () => {
|
||||
it('should log an error for invalid extension names during loading', async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -754,7 +762,7 @@ name = "yolo-checker"
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
it('should not load github extensions and log a warning if blockGitExtensions is set', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
@@ -774,6 +782,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
@@ -807,6 +816,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
|
||||
@@ -814,7 +824,7 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('my-ext');
|
||||
});
|
||||
|
||||
it('should not load disallowed extensions if the allowlist is set.', async () => {
|
||||
it('should not load disallowed extensions and log a warning if the allowlist is set.', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
@@ -835,6 +845,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
@@ -862,6 +873,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: loadedSettings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -885,6 +897,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: loadedSettings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -909,6 +922,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: loadedSettings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -1047,6 +1061,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -1082,6 +1097,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
@@ -1306,6 +1322,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
@@ -1330,6 +1347,7 @@ name = "yolo-checker"
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: allowedExtensionsSetting,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
@@ -1677,6 +1695,7 @@ ${INSTALL_WARNING_MESSAGE}`,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: null,
|
||||
settings: loadSettings(tempWorkspaceDir).merged,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
@@ -16,21 +16,14 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
import { createTestMergedSettings } from '../settings.js';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = await importOriginal<any>();
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
},
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
@@ -38,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
promises: {
|
||||
...actual.promises,
|
||||
mkdir: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
cp: vi.fn(),
|
||||
@@ -75,6 +69,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
Config: vi.fn().mockImplementation(() => ({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
|
||||
})),
|
||||
KeychainService: class {
|
||||
isAvailable = vi.fn().mockResolvedValue(true);
|
||||
getPassword = vi.fn().mockResolvedValue('test-key');
|
||||
setPassword = vi.fn().mockResolvedValue(undefined);
|
||||
},
|
||||
ExtensionIntegrityManager: class {
|
||||
verify = vi.fn().mockResolvedValue('verified');
|
||||
store = vi.fn().mockResolvedValue(undefined);
|
||||
},
|
||||
IntegrityDataStatus: {
|
||||
VERIFIED: 'verified',
|
||||
MISSING: 'missing',
|
||||
INVALID: 'invalid',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -134,13 +142,21 @@ describe('extensionUpdates', () => {
|
||||
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(getMissingSettings).mockResolvedValue([]);
|
||||
|
||||
// Allow directories to exist by default to satisfy Config/WorkspaceContext checks
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
vi.mocked(fs.lstatSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
|
||||
tempWorkspaceDir = '/mock/workspace';
|
||||
@@ -202,11 +218,10 @@ describe('extensionUpdates', () => {
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
// Mock loadExtension to return something so the method doesn't crash at the end
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
|
||||
vi.spyOn(manager, 'loadExtension').mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
} as GeminiCLIExtension);
|
||||
} as unknown as GeminiCLIExtension);
|
||||
|
||||
// 4. Mock External Helpers
|
||||
// This is the key fix: we explicitly mock `getMissingSettings` to return
|
||||
@@ -235,5 +250,52 @@ describe('extensionUpdates', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store integrity data after update', async () => {
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
};
|
||||
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: '/mock/source',
|
||||
type: 'local',
|
||||
};
|
||||
|
||||
const manager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
settings: createTestMergedSettings(),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
});
|
||||
|
||||
await manager.loadExtensions();
|
||||
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
|
||||
vi.spyOn(manager, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
path: '/mock/extensions/test-ext',
|
||||
isActive: true,
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
vi.spyOn(manager, 'loadExtension').mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
|
||||
const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity');
|
||||
|
||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
import { type ExtensionManager, copyExtension } from '../extension-manager.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import * as fs from 'node:fs';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type GeminiCLIExtension,
|
||||
type ExtensionInstallMetadata,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./storage.js', () => ({
|
||||
ExtensionStorage: {
|
||||
createTmpDir: vi.fn(),
|
||||
@@ -64,8 +67,18 @@ describe('Extension Update Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
installOrUpdateExtension: vi.fn(),
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
installOrUpdateExtension: vi.fn().mockResolvedValue({
|
||||
...mockExtension,
|
||||
version: '1.1.0',
|
||||
}),
|
||||
verifyExtensionIntegrity: vi
|
||||
.fn()
|
||||
.mockResolvedValue(IntegrityDataStatus.VERIFIED),
|
||||
storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ExtensionManager;
|
||||
mockDispatch = vi.fn();
|
||||
|
||||
@@ -92,7 +105,7 @@ describe('Extension Update Logic', () => {
|
||||
it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
|
||||
vi.mocked(loadInstallMetadata).mockReturnValue({
|
||||
type: undefined,
|
||||
} as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata);
|
||||
} as unknown as ExtensionInstallMetadata);
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
@@ -295,6 +308,77 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
expect(fs.promises.rm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Integrity Verification', () => {
|
||||
it('should fail update with security alert if integrity is invalid', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockResolvedValue(IntegrityDataStatus.INVALID);
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension test-extension cannot be updated. Extension integrity cannot be verified.',
|
||||
);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: mockExtension.name,
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should establish trust on first update if integrity data is missing', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockResolvedValue(IntegrityDataStatus.MISSING);
|
||||
|
||||
await updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
);
|
||||
|
||||
// Verify updateExtension delegates to installOrUpdateExtension,
|
||||
// which is responsible for establishing trust internally.
|
||||
expect(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).toHaveBeenCalled();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: mockExtension.name,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if integrity manager throws', async () => {
|
||||
vi.mocked(
|
||||
mockExtensionManager.verifyExtensionIntegrity,
|
||||
).mockRejectedValue(new Error('Verification failed'));
|
||||
|
||||
await expect(
|
||||
updateExtension(
|
||||
mockExtension,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension test-extension cannot be updated. Verification failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllUpdatableExtensions', () => {
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
} from '../../ui/state/extensions.js';
|
||||
import { loadInstallMetadata } from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
type GeminiCLIExtension,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
|
||||
@@ -48,6 +52,26 @@ export async function updateExtension(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await extensionManager.verifyExtensionIntegrity(
|
||||
extension.name,
|
||||
installMetadata,
|
||||
);
|
||||
|
||||
if (status === IntegrityDataStatus.INVALID) {
|
||||
throw new Error('Extension integrity cannot be verified');
|
||||
}
|
||||
} catch (e) {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
|
||||
@@ -346,6 +346,12 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(
|
||||
(await engine.check({ name: 'list_directory' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'get_internal_docs' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'cli_help' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Other tools should be denied via catch all
|
||||
expect(
|
||||
|
||||
@@ -63,6 +63,9 @@ export async function createPolicyEngineConfig(
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
workspacePoliciesDir,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
|
||||
@@ -34,7 +34,9 @@ const VALID_SANDBOX_COMMANDS = [
|
||||
function isSandboxCommand(
|
||||
value: string,
|
||||
): value is Exclude<SandboxConfig['command'], undefined> {
|
||||
return VALID_SANDBOX_COMMANDS.includes(value);
|
||||
return (VALID_SANDBOX_COMMANDS as ReadonlyArray<string | undefined>).includes(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function getSandboxCommand(
|
||||
|
||||
@@ -524,16 +524,19 @@ describe('Settings Loading and Merging', () => {
|
||||
const userSettingsContent = {
|
||||
security: {
|
||||
disableYoloMode: false,
|
||||
disableAlwaysAllow: false,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
security: {
|
||||
disableYoloMode: false, // This should be ignored
|
||||
disableAlwaysAllow: false, // This should be ignored
|
||||
},
|
||||
};
|
||||
const systemSettingsContent = {
|
||||
security: {
|
||||
disableYoloMode: true,
|
||||
disableAlwaysAllow: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -551,6 +554,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
|
||||
expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -2594,7 +2598,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'There was an error saving your latest settings changes.',
|
||||
'Failed to save settings: Write failed',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
getFsErrorMessage,
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
@@ -1072,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
settingsToSave as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your latest settings changes.',
|
||||
`Failed to save settings: ${detailedErrorMessage}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1087,9 +1089,10 @@ export function saveModelChange(
|
||||
try {
|
||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||
} catch (error) {
|
||||
const detailedErrorMessage = getFsErrorMessage(error);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your preferred model.',
|
||||
`Failed to save preferred model: ${detailedErrorMessage}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -403,9 +403,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||
);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
});
|
||||
|
||||
it('should have skills setting enabled by default', () => {
|
||||
|
||||
@@ -1131,6 +1131,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Model override for the visual agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowedDomains: {
|
||||
type: 'array',
|
||||
label: 'Allowed Domains',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: ['github.com', '*.google.com', 'localhost'] as string[],
|
||||
description: oneLine`
|
||||
A list of allowed domains for the browser agent
|
||||
(e.g., ["github.com", "*.google.com"]).
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
disableUserInput: {
|
||||
type: 'boolean',
|
||||
label: 'Disable User Input',
|
||||
@@ -1301,7 +1314,7 @@ const SETTINGS_SCHEMA = {
|
||||
default: undefined as boolean | string | SandboxConfig | undefined,
|
||||
ref: 'BooleanOrStringOrObject',
|
||||
description: oneLine`
|
||||
Sandbox execution environment.
|
||||
Legacy full-process sandbox execution environment.
|
||||
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
|
||||
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
|
||||
`,
|
||||
@@ -1523,6 +1536,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Security-related settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolSandboxing: {
|
||||
type: 'boolean',
|
||||
label: 'Tool Sandboxing',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Experimental tool-level sandboxing (implementation in progress).',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableYoloMode: {
|
||||
type: 'boolean',
|
||||
label: 'Disable YOLO Mode',
|
||||
@@ -1532,6 +1555,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable YOLO mode, even if enabled by a flag.',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableAlwaysAllow: {
|
||||
type: 'boolean',
|
||||
label: 'Disable Always Allow',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Disable "Always allow" options in tool confirmation dialogs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enablePermanentToolApproval: {
|
||||
type: 'boolean',
|
||||
label: 'Allow Permanent Tool Approval',
|
||||
@@ -1806,8 +1839,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionManagement: {
|
||||
@@ -1862,7 +1894,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1985,9 +2017,18 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
extensions: {
|
||||
type: 'object',
|
||||
label: 'Extensions',
|
||||
@@ -2268,7 +2309,8 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'If true, disallows yolo mode from being used.',
|
||||
description:
|
||||
'If true, disallows YOLO mode and "Always allow" options from being used.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
|
||||
@@ -122,4 +122,16 @@ describe('SkillCommandLoader', () => {
|
||||
const actionResult = (await commands[0].action!({} as any, '')) as any;
|
||||
expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' });
|
||||
});
|
||||
|
||||
it('should propagate extensionName to the generated slash command', async () => {
|
||||
const mockSkills = [
|
||||
{ name: 'skill1', description: 'desc', extensionName: 'ext1' },
|
||||
];
|
||||
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
|
||||
|
||||
const loader = new SkillCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
expect(commands[0].extensionName).toBe('ext1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export class SkillCommandLoader implements ICommandLoader {
|
||||
description: skill.description || `Activate the ${skill.name} skill`,
|
||||
kind: CommandKind.SKILL,
|
||||
autoExecute: true,
|
||||
extensionName: skill.extensionName,
|
||||
action: async (_context, args) => ({
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
|
||||
@@ -172,4 +172,23 @@ describe('SlashCommandConflictHandler', () => {
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display a descriptive message for a skill conflict', () => {
|
||||
simulateEvent([
|
||||
{
|
||||
name: 'chat',
|
||||
renamedTo: 'google-workspace.chat',
|
||||
loserExtensionName: 'google-workspace',
|
||||
loserKind: CommandKind.SKILL,
|
||||
winnerKind: CommandKind.BUILT_IN,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
"Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,10 @@ export class SlashCommandConflictHandler {
|
||||
return extensionName
|
||||
? `extension '${extensionName}' command`
|
||||
: 'extension command';
|
||||
case CommandKind.SKILL:
|
||||
return extensionName
|
||||
? `extension '${extensionName}' skill`
|
||||
: 'skill command';
|
||||
case CommandKind.MCP_PROMPT:
|
||||
return mcpServerName
|
||||
? `MCP server '${mcpServerName}' command`
|
||||
|
||||
@@ -173,5 +173,30 @@ describe('SlashCommandResolver', () => {
|
||||
|
||||
expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should prefix skills with extension name when they conflict with built-in', () => {
|
||||
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
|
||||
const skill = {
|
||||
...createMockCommand('chat', CommandKind.SKILL),
|
||||
extensionName: 'google-workspace',
|
||||
};
|
||||
|
||||
const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]);
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('chat');
|
||||
expect(names).toContain('google-workspace.chat');
|
||||
});
|
||||
|
||||
it('should NOT prefix skills with "skill" when extension name is missing', () => {
|
||||
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
|
||||
const skill = createMockCommand('chat', CommandKind.SKILL);
|
||||
|
||||
const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]);
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('chat');
|
||||
expect(names).toContain('chat1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,7 @@ export class SlashCommandResolver {
|
||||
private static getPrefix(cmd: SlashCommand): string | undefined {
|
||||
switch (cmd.kind) {
|
||||
case CommandKind.EXTENSION_FILE:
|
||||
case CommandKind.SKILL:
|
||||
return cmd.extensionName;
|
||||
case CommandKind.MCP_PROMPT:
|
||||
return cmd.mcpServerName;
|
||||
@@ -185,7 +186,6 @@ export class SlashCommandResolver {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a conflict event.
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ApprovalMode,
|
||||
getShellConfiguration,
|
||||
PolicyDecision,
|
||||
NoopSandboxManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { quote } from 'shell-quote';
|
||||
import { createPartFromText } from '@google/genai';
|
||||
@@ -77,7 +78,14 @@ describe('ShellProcessor', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IdeClient,
|
||||
debugLogger,
|
||||
CoreToolCallStatus,
|
||||
IntegrityDataStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type MockShellCommand,
|
||||
@@ -118,6 +119,12 @@ class MockExtensionManager extends ExtensionLoader {
|
||||
getExtensions = vi.fn().mockReturnValue([]);
|
||||
setRequestConsent = vi.fn();
|
||||
setRequestSetting = vi.fn();
|
||||
integrityManager = {
|
||||
verifyExtensionIntegrity: vi
|
||||
.fn()
|
||||
.mockResolvedValue(IntegrityDataStatus.VERIFIED),
|
||||
storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode.
|
||||
@@ -487,7 +494,7 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async waitForPendingConfirmation(
|
||||
toolNameOrDisplayName?: string | RegExp,
|
||||
toolNameOrDisplayName?: string | RegExp | string[],
|
||||
timeout = 30000,
|
||||
): Promise<PendingConfirmation> {
|
||||
const matches = (p: PendingConfirmation) => {
|
||||
@@ -498,6 +505,12 @@ export class AppRig {
|
||||
p.toolDisplayName === toolNameOrDisplayName
|
||||
);
|
||||
}
|
||||
if (Array.isArray(toolNameOrDisplayName)) {
|
||||
return (
|
||||
toolNameOrDisplayName.includes(p.toolName) ||
|
||||
toolNameOrDisplayName.includes(p.toolDisplayName || '')
|
||||
);
|
||||
}
|
||||
return (
|
||||
toolNameOrDisplayName.test(p.toolName) ||
|
||||
toolNameOrDisplayName.test(p.toolDisplayName || '')
|
||||
@@ -611,7 +624,7 @@ export class AppRig {
|
||||
async addUserHint(hint: string) {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
await act(async () => {
|
||||
this.config!.userHintService.addUserHint(hint);
|
||||
this.config!.injectionService.addInjection(hint, 'user_steering');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { NoopSandboxManager } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
createTestMergedSettings,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
@@ -78,6 +78,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getFileService: vi.fn().mockReturnValue({}),
|
||||
getGitService: vi.fn().mockResolvedValue({}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getSystemInstructionMemory: vi.fn().mockReturnValue(''),
|
||||
getSessionMemory: vi.fn().mockReturnValue(''),
|
||||
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
|
||||
getShowMemoryUsage: vi.fn().mockReturnValue(false),
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
@@ -121,6 +123,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
|
||||
reloadSkills: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAgents: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -131,7 +134,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
@@ -173,11 +183,9 @@ export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
type InjectionSource,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -1089,13 +1090,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (hint: string) => {
|
||||
pendingHintsRef.current.push(hint);
|
||||
const hintListener = (text: string, source: InjectionSource) => {
|
||||
if (source !== 'user_steering') {
|
||||
return;
|
||||
}
|
||||
pendingHintsRef.current.push(text);
|
||||
setPendingHintCount((prev) => prev + 1);
|
||||
};
|
||||
config.userHintService.onUserHint(hintListener);
|
||||
config.injectionService.onInjection(hintListener);
|
||||
return () => {
|
||||
config.userHintService.offUserHint(hintListener);
|
||||
config.injectionService.offInjection(hintListener);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -1259,7 +1263,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
config.userHintService.addUserHint(trimmed);
|
||||
config.injectionService.addInjection(trimmed, 'user_steering');
|
||||
// Render hints with a distinct style.
|
||||
historyManager.addItem({
|
||||
type: 'hint',
|
||||
@@ -1425,6 +1429,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pager: settings.merged.tools.shell.pager,
|
||||
showColor: settings.merged.tools.shell.showColor,
|
||||
sanitizationConfig: config.sanitizationConfig,
|
||||
sandboxManager: config.sandboxManager,
|
||||
});
|
||||
|
||||
const { isFocused, hasReceivedFocusEvent } = useFocus();
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('clearCommand', () => {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
userHintService: {
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const clearCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
// Reset user steering hints
|
||||
config?.userHintService.clear();
|
||||
config?.injectionService.clear();
|
||||
|
||||
// Start a new conversation recording with a new session ID
|
||||
// We MUST do this before calling resetChat() so the new ChatRecordingService
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
import {
|
||||
debugLogger,
|
||||
listExtensions,
|
||||
getErrorMessage,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
emptyIcon,
|
||||
MessageType,
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
|
||||
@@ -87,6 +87,31 @@ describe('AskUserDialog', () => {
|
||||
writeKey(stdin, '\r'); // Toggle TS
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\r'); // Toggle ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
},
|
||||
expectedSubmit: { '0': 'TypeScript, ESLint' },
|
||||
},
|
||||
{
|
||||
name: 'All of the above',
|
||||
questions: [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\x1b[B'); // Down to ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\r'); // Toggle All of the above
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
@@ -131,6 +156,42 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies "All of the above" visual state with snapshot', async () => {
|
||||
const questions = [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[];
|
||||
|
||||
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to "All of the above" and toggle it
|
||||
writeKey(stdin, '\x1b[B'); // Down to ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\r'); // Toggle All of the above
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
// Verify visual state (checkmarks on all options)
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles custom option in single select with inline typing', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
|
||||
|
||||
@@ -395,7 +395,7 @@ interface OptionItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'option' | 'other' | 'done';
|
||||
type: 'option' | 'other' | 'done' | 'all';
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -407,6 +407,7 @@ interface ChoiceQuestionState {
|
||||
|
||||
type ChoiceQuestionAction =
|
||||
| { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } }
|
||||
| { type: 'TOGGLE_ALL'; payload: { totalOptions: number } }
|
||||
| {
|
||||
type: 'SET_CUSTOM_SELECTED';
|
||||
payload: { selected: boolean; multiSelect: boolean };
|
||||
@@ -419,6 +420,25 @@ function choiceQuestionReducer(
|
||||
action: ChoiceQuestionAction,
|
||||
): ChoiceQuestionState {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_ALL': {
|
||||
const { totalOptions } = action.payload;
|
||||
const allSelected = state.selectedIndices.size === totalOptions;
|
||||
if (allSelected) {
|
||||
return {
|
||||
...state,
|
||||
selectedIndices: new Set(),
|
||||
};
|
||||
} else {
|
||||
const newIndices = new Set<number>();
|
||||
for (let i = 0; i < totalOptions; i++) {
|
||||
newIndices.add(i);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
selectedIndices: newIndices,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'TOGGLE_INDEX': {
|
||||
const { index, multiSelect } = action.payload;
|
||||
const newIndices = new Set(multiSelect ? state.selectedIndices : []);
|
||||
@@ -703,6 +723,18 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
// Add 'All of the above' for multi-select
|
||||
if (question.multiSelect && questionOptions.length > 1) {
|
||||
const allItem: OptionItem = {
|
||||
key: 'all',
|
||||
label: 'All of the above',
|
||||
description: 'Select all options',
|
||||
type: 'all',
|
||||
index: list.length,
|
||||
};
|
||||
list.push({ key: 'all', value: allItem });
|
||||
}
|
||||
|
||||
// Only add custom option for choice type, not yesno
|
||||
if (question.type !== 'yesno') {
|
||||
const otherItem: OptionItem = {
|
||||
@@ -755,6 +787,11 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
type: 'TOGGLE_CUSTOM_SELECTED',
|
||||
payload: { multiSelect: true },
|
||||
});
|
||||
} else if (itemValue.type === 'all') {
|
||||
dispatch({
|
||||
type: 'TOGGLE_ALL',
|
||||
payload: { totalOptions: questionOptions.length },
|
||||
});
|
||||
} else if (itemValue.type === 'done') {
|
||||
// Done just triggers navigation, selections already saved via useEffect
|
||||
onAnswer(
|
||||
@@ -783,6 +820,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
},
|
||||
[
|
||||
question.multiSelect,
|
||||
questionOptions.length,
|
||||
selectedIndices,
|
||||
isCustomOptionSelected,
|
||||
customOptionText,
|
||||
@@ -857,11 +895,16 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
renderItem={(item, context) => {
|
||||
const optionItem = item.value;
|
||||
const isChecked =
|
||||
selectedIndices.has(optionItem.index) ||
|
||||
(optionItem.type === 'other' && isCustomOptionSelected);
|
||||
(optionItem.type === 'option' &&
|
||||
selectedIndices.has(optionItem.index)) ||
|
||||
(optionItem.type === 'other' && isCustomOptionSelected) ||
|
||||
(optionItem.type === 'all' &&
|
||||
selectedIndices.size === questionOptions.length);
|
||||
const showCheck =
|
||||
question.multiSelect &&
|
||||
(optionItem.type === 'option' || optionItem.type === 'other');
|
||||
(optionItem.type === 'option' ||
|
||||
optionItem.type === 'other' ||
|
||||
optionItem.type === 'all');
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
|
||||
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
|
||||
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
|
||||
agents: [],
|
||||
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -95,6 +96,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -125,6 +127,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -152,6 +155,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -332,6 +336,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: ['mcp1'],
|
||||
hooks: ['hook1'],
|
||||
skills: ['skill1'],
|
||||
agents: ['agent1'],
|
||||
settings: ['general', 'ui'],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -355,6 +360,8 @@ describe('FolderTrustDialog', () => {
|
||||
expect(lastFrame()).toContain('- hook1');
|
||||
expect(lastFrame()).toContain('• Skills (1):');
|
||||
expect(lastFrame()).toContain('- skill1');
|
||||
expect(lastFrame()).toContain('• Agents (1):');
|
||||
expect(lastFrame()).toContain('- agent1');
|
||||
expect(lastFrame()).toContain('• Setting overrides (2):');
|
||||
expect(lastFrame()).toContain('- general');
|
||||
expect(lastFrame()).toContain('- ui');
|
||||
@@ -367,6 +374,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: ['Dangerous setting detected!'],
|
||||
@@ -390,6 +398,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: ['Failed to load custom commands'],
|
||||
securityWarnings: [],
|
||||
@@ -413,6 +422,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
settings: [],
|
||||
discoveryErrors: [],
|
||||
securityWarnings: [],
|
||||
@@ -446,6 +456,7 @@ describe('FolderTrustDialog', () => {
|
||||
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
|
||||
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
|
||||
skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
|
||||
agents: [],
|
||||
settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
|
||||
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
|
||||
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
|
||||
|
||||
@@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
|
||||
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
|
||||
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
|
||||
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
|
||||
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
AuthType,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -28,8 +30,9 @@ const mockGetDisplayString = vi.fn();
|
||||
const mockLogModelSlashCommand = vi.fn();
|
||||
const mockModelSlashCommandEvent = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getDisplayString: (val: string) => mockGetDisplayString(val),
|
||||
@@ -40,6 +43,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
mockModelSlashCommandEvent(model);
|
||||
}
|
||||
},
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,6 +53,9 @@ describe('<ModelDialog />', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
const mockGetGemini31LaunchedSync = vi.fn();
|
||||
const mockGetProModelNoAccess = vi.fn();
|
||||
const mockGetProModelNoAccessSync = vi.fn();
|
||||
const mockGetUserTier = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
@@ -56,6 +63,9 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
getIdeMode: () => boolean;
|
||||
getGemini31LaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getUserTier: () => UserTierId | undefined;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
@@ -64,6 +74,9 @@ describe('<ModelDialog />', () => {
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
getIdeMode: () => false,
|
||||
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getUserTier: mockGetUserTier,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -71,6 +84,9 @@ describe('<ModelDialog />', () => {
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetGemini31LaunchedSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
@@ -109,6 +125,55 @@ describe('<ModelDialog />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders the "manual" view initially for users with no pro access and filters Pro models with correct order', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(true);
|
||||
mockGetProModelNoAccess.mockResolvedValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
mockGetDisplayString.mockImplementation((val: string) => val);
|
||||
|
||||
const { lastFrame, unmount } = await renderComponent();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Select Model');
|
||||
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
|
||||
|
||||
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
|
||||
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
const flashLitePreviewIdx = output.indexOf(
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
);
|
||||
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
|
||||
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
|
||||
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
|
||||
expect(flashIdx).toBeLessThan(flashLiteIdx);
|
||||
|
||||
expect(output).not.toContain('Auto');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('closes dialog on escape in "manual" view for users with no pro access', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(true);
|
||||
mockGetProModelNoAccess.mockResolvedValue(true);
|
||||
const { stdin, waitUntilReady, unmount } = await renderComponent();
|
||||
|
||||
// Already in manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B'); // Escape
|
||||
});
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => {
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model';
|
||||
@@ -369,5 +434,50 @@ describe('<ModelDialog />', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides Flash Lite Preview model for users with pro access', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Flash Lite Preview model for free tier users', async () => {
|
||||
mockGetProModelNoAccessSync.mockReturnValue(false);
|
||||
mockGetProModelNoAccess.mockResolvedValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetUserTier.mockReturnValue(UserTierId.FREE);
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } =
|
||||
await renderComponent();
|
||||
|
||||
// Go to manual view
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Manual
|
||||
});
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -35,9 +38,26 @@ interface ModelDialogProps {
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const settings = useSettings();
|
||||
const [view, setView] = useState<'main' | 'manual'>('main');
|
||||
const [hasAccessToProModel, setHasAccessToProModel] = useState<boolean>(
|
||||
() => !(config?.getProModelNoAccessSync() ?? false),
|
||||
);
|
||||
const [view, setView] = useState<'main' | 'manual'>(() =>
|
||||
config?.getProModelNoAccessSync() ? 'manual' : 'main',
|
||||
);
|
||||
const [persistMode, setPersistMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAccess() {
|
||||
if (!config) return;
|
||||
const noAccess = await config.getProModelNoAccess();
|
||||
setHasAccessToProModel(!noAccess);
|
||||
if (noAccess) {
|
||||
setView('manual');
|
||||
}
|
||||
}
|
||||
void checkAccess();
|
||||
}, [config]);
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
@@ -66,7 +86,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
if (view === 'manual') {
|
||||
if (view === 'manual' && hasAccessToProModel) {
|
||||
setView('main');
|
||||
} else {
|
||||
onClose();
|
||||
@@ -115,6 +135,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
@@ -142,7 +163,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
list.unshift(
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
@@ -153,10 +174,32 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
key: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
if (isFreeTier) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
list.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
if (!hasAccessToProModel) {
|
||||
// Filter out all Pro models for free tier
|
||||
return list.filter((option) => !isProModel(option.value));
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
config,
|
||||
]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
|
||||
|
||||
@@ -22,6 +22,25 @@ describe('NewAgentsNotification', () => {
|
||||
{
|
||||
name: 'Agent B',
|
||||
description: 'Description B',
|
||||
kind: 'local' as const,
|
||||
inputConfig: { inputSchema: {} },
|
||||
promptConfig: {},
|
||||
modelConfig: {},
|
||||
runConfig: {},
|
||||
mcpServers: {
|
||||
github: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
},
|
||||
postgres: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-postgres'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Agent C',
|
||||
description: 'Description C',
|
||||
kind: 'remote' as const,
|
||||
agentCardUrl: '',
|
||||
inputConfig: { inputSchema: {} },
|
||||
|
||||
@@ -80,16 +80,35 @@ export const NewAgentsNotification = ({
|
||||
borderStyle="single"
|
||||
padding={1}
|
||||
>
|
||||
{displayAgents.map((agent) => (
|
||||
<Box key={agent.name}>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
- {agent.name}:{' '}
|
||||
</Text>
|
||||
{displayAgents.map((agent) => {
|
||||
const mcpServers =
|
||||
agent.kind === 'local' ? agent.mcpServers : undefined;
|
||||
const hasMcpServers =
|
||||
mcpServers && Object.keys(mcpServers).length > 0;
|
||||
return (
|
||||
<Box key={agent.name} flexDirection="column">
|
||||
<Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
- {agent.name}:{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{agent.description}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasMcpServers && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Includes MCP servers:{' '}
|
||||
{Object.keys(mcpServers).join(', ')})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}> {agent.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {remaining} more.
|
||||
|
||||
@@ -116,38 +116,9 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Loading state component displayed while sessions are being loaded.
|
||||
*/
|
||||
const SessionBrowserLoading = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>Loading sessions…</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Error state component displayed when session loading fails.
|
||||
*/
|
||||
const SessionBrowserError = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Empty state component displayed when no sessions are found.
|
||||
*/
|
||||
const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
|
||||
<Text color={Colors.Gray}>Press q to exit</Text>
|
||||
</Box>
|
||||
);
|
||||
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
|
||||
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
|
||||
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
|
||||
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user