mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 11:41:00 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ced2f2873d |
@@ -1,63 +0,0 @@
|
||||
---
|
||||
name: docs-writer
|
||||
description:
|
||||
Use this skill when asked to write documentation (`/docs` directory)
|
||||
for Gemini CLI.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
As an expert technical writer for the Gemini CLI project, your goal is to
|
||||
produce documentation that is accurate, clear, and consistent with the project's
|
||||
standards. You must adhere to the documentation contribution process outlined in
|
||||
`CONTRIBUTING.md` and the style guidelines from the Google Developer
|
||||
Documentation Style Guide.
|
||||
|
||||
## Step 1: Understand the goal and create a plan
|
||||
|
||||
1. **Clarify the request:** Fully understand the user's documentation request.
|
||||
Identify the core feature, command, or concept that needs to be documented.
|
||||
2. **Ask questions:** If the request is ambiguous or lacks detail, ask
|
||||
clarifying questions. Don't invent or assume. It's better to ask than to
|
||||
write incorrect documentation.
|
||||
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
|
||||
changes. If requested or necessary, store this plan in a temporary file or
|
||||
a file identified by the user.
|
||||
|
||||
## Step 2: Investigate and gather information
|
||||
|
||||
1. **Read the code:** Thoroughly examine the relevant codebase, primarily within
|
||||
the `packages/` directory, to ensure your writing is backed by the
|
||||
implementation.
|
||||
2. **Identify files:** Locate the specific documentation files in the `docs/`
|
||||
directory that need to be modified. Always read the latest
|
||||
version of a file before you begin to edit it.
|
||||
3. **Check for connections:** Consider related documentation. If you add a new
|
||||
page, check if `docs/sidebar.json` needs to be updated. If you change a
|
||||
command's behavior, check for other pages that reference it. Make sure links
|
||||
in these pages are up to date.
|
||||
|
||||
## Step 3: Draft the documentation
|
||||
|
||||
1. **Follow the style guide:**
|
||||
- Text must be wrapped at 80 characters. Exceptions are long links or
|
||||
tables, unless otherwise stated by the user.
|
||||
- Use sentence case for headings, titles, and bolded text.
|
||||
- Address the reader as "you".
|
||||
- Use contractions to keep the tone more casual.
|
||||
- Use simple, direct, and active language and the present tense.
|
||||
- Keep paragraphs short and focused.
|
||||
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
|
||||
2. **Use `replace` and `write_file`:** Use the file system tools to apply your
|
||||
planned changes precisely. For small edits, `replace` is preferred. For new
|
||||
files or large rewrites, `write_file` is more appropriate.
|
||||
|
||||
## Step 4: Verify and finalize
|
||||
|
||||
1. **Review your work:** After making changes, re-read the files to ensure the
|
||||
documentation is well-formatted, content is correct and based on existing
|
||||
code, and that all new links are valid.
|
||||
2. **Offer to run npm format:** Once all changes are complete and the user
|
||||
confirms they have no more requests, offer to run the project's formatting
|
||||
script to ensure consistency. Propose the following command:
|
||||
`npm run format`
|
||||
@@ -9,10 +9,10 @@ set -euo pipefail
|
||||
PRS_NEEDING_COMMENT=""
|
||||
|
||||
# Global cache for issue labels (compatible with Bash 3.2)
|
||||
# Stores "|ISSUE_NUM:LABELS|" segments
|
||||
ISSUE_LABELS_CACHE_FLAT="|"
|
||||
# Stores "ISSUE_NUM:LABELS" pairs separated by spaces
|
||||
ISSUE_LABELS_CACHE_FLAT=""
|
||||
|
||||
# Function to get labels from an issue (with caching)
|
||||
# Function to get area and priority labels from an issue (with caching)
|
||||
get_issue_labels() {
|
||||
local ISSUE_NUM="${1}"
|
||||
if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then
|
||||
@@ -20,10 +20,10 @@ get_issue_labels() {
|
||||
fi
|
||||
|
||||
# Check cache
|
||||
case "${ISSUE_LABELS_CACHE_FLAT}" in
|
||||
*"|${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
|
||||
echo "${suffix%%|*}"
|
||||
case " ${ISSUE_LABELS_CACHE_FLAT} " in
|
||||
*" ${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#* " ${ISSUE_NUM}:"}"
|
||||
echo "${suffix%% *}"
|
||||
return
|
||||
;;
|
||||
*)
|
||||
@@ -31,19 +31,19 @@ get_issue_labels() {
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " 📥 Fetching labels from issue #${ISSUE_NUM}" >&2
|
||||
echo " 📥 Fetching area and priority labels from issue #${ISSUE_NUM}" >&2
|
||||
local gh_output
|
||||
if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then
|
||||
echo " ⚠️ Could not fetch issue #${ISSUE_NUM}" >&2
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|"
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:"
|
||||
return
|
||||
fi
|
||||
|
||||
local labels
|
||||
labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|🔒 maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "")
|
||||
labels=$(echo "${gh_output}" | grep -E '^(area|priority)/' | tr '\n' ',' | sed 's/,$//' || echo "")
|
||||
|
||||
# Save to flat cache
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|"
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:${labels}"
|
||||
echo "${labels}"
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ done
|
||||
EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}")
|
||||
fi
|
||||
|
||||
("${EDIT_CMD[@]}" || true)
|
||||
("${EDIT_CMD[@]}" 2>/dev/null || true)
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ on:
|
||||
description: 'Run all evaluations (including usually passing)'
|
||||
type: 'boolean'
|
||||
default: true
|
||||
test_name_pattern:
|
||||
description: 'Test name pattern or file name'
|
||||
required: false
|
||||
type: 'string'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
@@ -53,25 +49,11 @@ jobs:
|
||||
run: 'mkdir -p evals/logs'
|
||||
|
||||
- name: 'Run Evals'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
run: |
|
||||
CMD="npm run test:all_evals"
|
||||
PATTERN="${{ env.TEST_NAME_PATTERN }}"
|
||||
|
||||
if [[ -n "$PATTERN" ]]; then
|
||||
if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then
|
||||
$CMD -- "$PATTERN"
|
||||
else
|
||||
$CMD -- -t "$PATTERN"
|
||||
fi
|
||||
else
|
||||
$CMD
|
||||
fi
|
||||
run: 'npm run test:all_evals'
|
||||
|
||||
- name: 'Upload Logs'
|
||||
if: 'always()'
|
||||
|
||||
@@ -79,14 +79,8 @@ jobs:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if it has a maintainer, help wanted, or Public Roadmap label
|
||||
const rawLabels = issue.labels.map((l) => l.name);
|
||||
const lowercaseLabels = rawLabels.map((l) => l.toLowerCase());
|
||||
if (
|
||||
lowercaseLabels.some((l) => l.includes('maintainer')) ||
|
||||
lowercaseLabels.includes('help wanted') ||
|
||||
rawLabels.includes('🗓️ Public Roadmap')
|
||||
) {
|
||||
// Skip if it has a maintainer label
|
||||
if (issue.labels.some(label => label.name.toLowerCase().includes('maintainer'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
name: 'Gemini Scheduled Stale PR Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Every day at 2 AM UTC
|
||||
pull_request:
|
||||
types: ['opened', 'edited']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@v1'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
owner: '${{ github.repository_owner }}'
|
||||
repositories: 'gemini-cli'
|
||||
|
||||
- name: 'Process Stale PRs'
|
||||
uses: 'actions/github-script@v7'
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
let prs = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
prs = [pr];
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
}
|
||||
|
||||
for (const pr of prs) {
|
||||
const maintainerPr = isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
|
||||
// Detection Logic for Linked Issues
|
||||
// Check 1: Official GitHub "Closing Issue" link (GraphQL)
|
||||
const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) { totalCount }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let hasClosingLink = false;
|
||||
try {
|
||||
const res = await github.graphql(linkedIssueQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0;
|
||||
} catch (e) {}
|
||||
|
||||
// Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123")
|
||||
// We check for # followed by numbers or direct URLs to issues.
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const hasMentionLink = mentionRegex.test(body);
|
||||
|
||||
const hasLinkedIssue = hasClosingLink || hasMentionLink;
|
||||
|
||||
// Logic for Closed PRs (Auto-Reopen)
|
||||
if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') {
|
||||
if (hasLinkedIssue) {
|
||||
core.info(`PR #${pr.number} now has a linked issue. Reopening.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'open'
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Thank you for linking an issue! This pull request has been automatically reopened."
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logic for Open PRs (Immediate Closure)
|
||||
if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) {
|
||||
core.info(`PR #${pr.number} is missing a linked issue. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staleness check (Scheduled runs only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (isMaintainer(r.user.login, r.author_association)) {
|
||||
const d = new Date(r.submitted_at || r.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
for (const c of comments) {
|
||||
if (isMaintainer(c.user.login, c.author_association)) {
|
||||
const d = new Date(c.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
core.info(`PR #${pr.number} is stale.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
name: '🏷️ Enforce Restricted Label Permissions'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- 'labeled'
|
||||
- 'unlabeled'
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
# Run this job only when restricted labels are changed
|
||||
if: |-
|
||||
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage' || github.event.label.name == '🔒 maintainer only') &&
|
||||
(github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Check if user is in the maintainers team'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |-
|
||||
const org = context.repo.owner;
|
||||
const username = context.payload.sender.login;
|
||||
const team_slug = 'gemini-cli-maintainers';
|
||||
const action = context.payload.action; // 'labeled' or 'unlabeled'
|
||||
const labelName = context.payload.label.name;
|
||||
|
||||
// Skip if the change was made by a bot to avoid infinite loops
|
||||
if (username === 'github-actions[bot]' || username === 'gemini-cli[bot]' || username.endsWith('[bot]')) {
|
||||
core.info('Change made by a bot. Skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check repository permission level directly.
|
||||
// This is more robust than team membership as it doesn't require Org-level read permissions
|
||||
// and correctly handles Repo Admins/Writers who might not be in the specific team.
|
||||
const { data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
|
||||
if (permission === 'admin' || permission === 'write') {
|
||||
core.info(`${username} has '${permission}' permission. Allowed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`${username} has '${permission}' permission (needs 'write' or 'admin'). Reverting '${action}' action for '${labelName}' label.`);
|
||||
} catch (error) {
|
||||
core.error(`Failed to check permissions for ${username}: ${error.message}`);
|
||||
// Fall through to revert logic if we can't verify permissions (fail safe)
|
||||
}
|
||||
|
||||
// If we are here, the user is NOT authorized.
|
||||
if (true) { // wrapping block to preserve variable scope if needed
|
||||
if (action === 'labeled') {
|
||||
// 1. Remove the label if added by a non-maintainer
|
||||
await github.rest.issues.removeLabel ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName
|
||||
});
|
||||
|
||||
// 2. Post a polite comment
|
||||
const comment = `
|
||||
Hi @${username}, thank you for your interest in helping triage issues!
|
||||
|
||||
The \`${labelName}\` label is reserved for project maintainers to apply. This helps us ensure that an issue is ready and properly vetted for community contribution.
|
||||
|
||||
A maintainer will review this issue soon. Please see our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) for more details on our labeling process.
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
} else if (action === 'unlabeled') {
|
||||
// 1. Add the label back if removed by a non-maintainer
|
||||
await github.rest.issues.addLabels ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [labelName]
|
||||
});
|
||||
|
||||
// 2. Post a polite comment
|
||||
const comment = `
|
||||
Hi @${username}, it looks like the \`${labelName}\` label was removed.
|
||||
|
||||
This label is managed by project maintainers. We've added it back to ensure the issue remains visible to potential contributors until a maintainer decides otherwise.
|
||||
|
||||
Thank you for your understanding!
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,5 @@ jobs:
|
||||
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
exempt-issue-labels: 'pinned,security'
|
||||
exempt-pr-labels: 'pinned,security'
|
||||
|
||||
@@ -55,7 +55,6 @@ gha-creds-*.json
|
||||
|
||||
# Log files
|
||||
patch_output.log
|
||||
gemini-debug.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
|
||||
@@ -62,17 +62,11 @@ powerful tool for developers.
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- **Environment Variables:** When testing code that depends on environment
|
||||
variables, use `vi.stubEnv('NAME', 'value')` in `beforeEach` and
|
||||
`vi.unstubAllEnvs()` in `afterEach`. Avoid modifying `process.env` directly as
|
||||
it can lead to test leakage and is less reliable. To "unset" a variable, use
|
||||
an empty string `vi.stubEnv('NAME', '')`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Suggest documentation updates when code changes render existing documentation
|
||||
obsolete or incomplete.
|
||||
- Located in the `docs/` directory.
|
||||
- Use the `docs-writer` skill.
|
||||
- Architecture overview: `docs/architecture.md`.
|
||||
- Contribution guide: `CONTRIBUTING.md`.
|
||||
- Documentation is organized via `docs/sidebar.json`.
|
||||
- Follows the
|
||||
[Google Developer Documentation Style Guide](https://developers.google.com/style).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml)
|
||||
[](https://www.npmjs.com/package/@google/gemini-cli)
|
||||
[](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
|
||||
[](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli)
|
||||
[](https://codewiki.google/github.com/google-gemini/gemini-cli)
|
||||
|
||||

|
||||
|
||||
@@ -55,23 +55,6 @@ npm install -g @google/gemini-cli
|
||||
brew install gemini-cli
|
||||
```
|
||||
|
||||
#### Install globally with MacPorts (macOS)
|
||||
|
||||
```bash
|
||||
sudo port install gemini-cli
|
||||
```
|
||||
|
||||
#### Install with Anaconda (for restricted environments)
|
||||
|
||||
```bash
|
||||
# Create and activate a new environment
|
||||
conda create -y -n gemini_env -c conda-forge nodejs
|
||||
conda activate gemini_env
|
||||
|
||||
# Install Gemini CLI globally via npm (inside the environment)
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
## Release Cadence and Tags
|
||||
|
||||
See [Releases](./docs/releases.md) for more details.
|
||||
|
||||
@@ -62,7 +62,6 @@ available combinations.
|
||||
| Start reverse search through history. | `Ctrl + R` |
|
||||
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
||||
| Accept a suggestion while reverse searching. | `Tab` |
|
||||
| Browse and rewind previous interactions. | `Double Esc` |
|
||||
|
||||
#### Navigation
|
||||
|
||||
|
||||
+18
-16
@@ -90,17 +90,16 @@ they appear in the UI.
|
||||
|
||||
### Tools
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -114,11 +113,14 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
||||
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
|
||||
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### HooksConfig
|
||||
|
||||
|
||||
@@ -45,21 +45,3 @@ npm uninstall -g @google/gemini-cli
|
||||
```
|
||||
|
||||
This command completely removes the package from your system.
|
||||
|
||||
## Method 3: Homebrew
|
||||
|
||||
If you installed the CLI globally using Homebrew (e.g.,
|
||||
`brew install gemini-cli`), use the `brew uninstall` command to remove it.
|
||||
|
||||
```bash
|
||||
brew uninstall gemini-cli
|
||||
```
|
||||
|
||||
## Method 4: MacPorts
|
||||
|
||||
If you installed the CLI globally using MacPorts (e.g.,
|
||||
`sudo port install gemini-cli`), use the `port uninstall` command to remove it.
|
||||
|
||||
```bash
|
||||
sudo port uninstall gemini-cli
|
||||
```
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Extensions on Gemini CLI: Best practices
|
||||
|
||||
This guide covers best practices for developing, securing, and maintaining
|
||||
Gemini CLI extensions.
|
||||
|
||||
## Development
|
||||
|
||||
Developing extensions for Gemini CLI is intended to be a lightweight, iterative
|
||||
process.
|
||||
|
||||
### Structure your extension
|
||||
|
||||
While simple extensions can just be a few files, we recommend a robust structure
|
||||
for complex extensions:
|
||||
|
||||
```
|
||||
my-extension/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── gemini-extension.json
|
||||
├── src/
|
||||
│ ├── index.ts
|
||||
│ └── tools/
|
||||
└── dist/
|
||||
```
|
||||
|
||||
- **Use TypeScript**: We strongly recommend using TypeScript for type safety and
|
||||
better tooling.
|
||||
- **Separate source and build**: Keep your source code in `src` and build to
|
||||
`dist`.
|
||||
- **Bundle dependencies**: If your extension has many dependencies, consider
|
||||
bundling them (e.g., with `esbuild` or `webpack`) to reduce install time and
|
||||
potential conflicts.
|
||||
|
||||
### Iterate with `link`
|
||||
|
||||
Use `gemini extensions link` to develop locally without constantly reinstalling:
|
||||
|
||||
```bash
|
||||
cd my-extension
|
||||
gemini extensions link .
|
||||
```
|
||||
|
||||
Changes to your code (after rebuilding) will be immediately available in the CLI
|
||||
on restart.
|
||||
|
||||
### Use `GEMINI.md` effectively
|
||||
|
||||
Your `GEMINI.md` file provides context to the model. Keep it focused:
|
||||
|
||||
- **Do:** Explain high-level goals and how to use the provided tools.
|
||||
- **Don't:** Dump your entire documentation.
|
||||
- **Do:** Use clear, concise language.
|
||||
|
||||
## Security
|
||||
|
||||
When building a Gemini CLI extension, follow general security best practices
|
||||
(such as least privilege and input validation) to reduce risk.
|
||||
|
||||
### Minimal permissions
|
||||
|
||||
When defining tools in your MCP server, only request the permissions necessary.
|
||||
Avoid giving the model broad access (like full shell access) if a more
|
||||
restricted set of tools will suffice.
|
||||
|
||||
If you must use powerful tools like `run_shell_command`, consider restricting
|
||||
them to specific commands in your `gemini-extension.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-safe-extension",
|
||||
"excludeTools": ["run_shell_command(rm -rf *)"]
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that even if the model tries to execute a dangerous command, it
|
||||
will be blocked at the CLI level.
|
||||
|
||||
### Validate inputs
|
||||
|
||||
Your MCP server is running on the user's machine. Always validate inputs to your
|
||||
tools to prevent arbitrary code execution or filesystem access outside the
|
||||
intended scope.
|
||||
|
||||
```typescript
|
||||
// Good: Validating paths
|
||||
if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive settings
|
||||
|
||||
If your extension requires API keys, use the `sensitive: true` option in
|
||||
`gemini-extension.json`. This ensures keys are stored securely in the system
|
||||
keychain and obfuscated in the UI.
|
||||
|
||||
```json
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"envVar": "MY_API_KEY",
|
||||
"sensitive": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
You can upload your extension directly to GitHub to list it in the gallery.
|
||||
Gemini CLI extensions also offers support for more complicated
|
||||
[releases](releasing.md).
|
||||
|
||||
### Semantic versioning
|
||||
|
||||
Follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
- **Major**: Breaking changes (renaming tools, changing arguments).
|
||||
- **Minor**: New features (new tools, commands).
|
||||
- **Patch**: Bug fixes.
|
||||
|
||||
### Release Channels
|
||||
|
||||
Use git branches to manage release channels (e.g., `main` for stable, `dev` for
|
||||
bleeding edge). This allows users to choose their stability level:
|
||||
|
||||
```bash
|
||||
# Stable
|
||||
gemini extensions install github.com/user/repo
|
||||
|
||||
# Dev
|
||||
gemini extensions install github.com/user/repo --ref dev
|
||||
```
|
||||
|
||||
### Clean artifacts
|
||||
|
||||
If you are using GitHub Releases, ensure your release artifacts only contain the
|
||||
necessary files (`dist/`, `gemini-extension.json`, `package.json`). Exclude
|
||||
`node_modules` (users will install them) and `src/` to keep downloads small.
|
||||
+27
-28
@@ -8,19 +8,7 @@ file.
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have the Gemini CLI installed and a basic
|
||||
understanding of Node.js.
|
||||
|
||||
## When to use what
|
||||
|
||||
Extensions offer a variety of ways to customize Gemini CLI.
|
||||
|
||||
| Feature | What it is | When to use it | Invoked by |
|
||||
| :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- |
|
||||
| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model |
|
||||
| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User |
|
||||
| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model |
|
||||
| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model |
|
||||
| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI |
|
||||
understanding of Node.js and TypeScript.
|
||||
|
||||
## Step 1: Create a new extension
|
||||
|
||||
@@ -38,9 +26,10 @@ This will create a new directory with the following structure:
|
||||
|
||||
```
|
||||
my-first-extension/
|
||||
├── example.js
|
||||
├── example.ts
|
||||
├── gemini-extension.json
|
||||
└── package.json
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Step 2: Understand the extension files
|
||||
@@ -54,12 +43,12 @@ and use your extension.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mcp-server-example",
|
||||
"name": "my-first-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
@@ -75,12 +64,12 @@ and use your extension.
|
||||
with the absolute path to your extension's installation directory. This
|
||||
allows your extension to work regardless of where it's installed.
|
||||
|
||||
### `example.js`
|
||||
### `example.ts`
|
||||
|
||||
This file contains the source code for your MCP server. It's a simple Node.js
|
||||
server that uses the `@modelcontextprotocol/sdk`.
|
||||
|
||||
```javascript
|
||||
```typescript
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
@@ -129,15 +118,16 @@ await server.connect(transport);
|
||||
This server defines a single tool called `fetch_posts` that fetches data from a
|
||||
public API.
|
||||
|
||||
### `package.json`
|
||||
### `package.json` and `tsconfig.json`
|
||||
|
||||
This is the standard configuration file for a Node.js project. It defines
|
||||
dependencies and scripts.
|
||||
These are standard configuration files for a TypeScript project. The
|
||||
`package.json` file defines dependencies and a `build` script, and
|
||||
`tsconfig.json` configures the TypeScript compiler.
|
||||
|
||||
## Step 3: Link your extension
|
||||
## Step 3: Build and link your extension
|
||||
|
||||
Before you can use the extension, you need to link it to your Gemini CLI
|
||||
installation for local development.
|
||||
Before you can use the extension, you need to compile the TypeScript code and
|
||||
link the extension to your Gemini CLI installation for local development.
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
@@ -146,7 +136,16 @@ installation for local development.
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Link the extension:**
|
||||
2. **Build the server:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will compile `example.ts` into `dist/example.js`, which is the file
|
||||
referenced in your `gemini-extension.json`.
|
||||
|
||||
3. **Link the extension:**
|
||||
|
||||
The `link` command creates a symbolic link from the Gemini CLI extensions
|
||||
directory to your development directory. This means any changes you make
|
||||
@@ -213,7 +212,7 @@ need this for extensions built to expose commands and prompts.
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
@@ -266,7 +265,7 @@ primary ways of releasing extensions are via a Git repository or through GitHub
|
||||
Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the
|
||||
[Extension Releasing Guide](./releasing.md).
|
||||
[Extension Releasing Guide](./extension-releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
+358
-25
@@ -1,44 +1,377 @@
|
||||
# Gemini CLI extensions
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks, and
|
||||
agent skills into a familiar and user-friendly format. With extensions, you can
|
||||
_This documentation is up-to-date with the v0.4.0 release._
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, Agent Skills, and custom
|
||||
commands into a familiar and user-friendly format. With extensions, you can
|
||||
expand the capabilities of Gemini CLI and share those capabilities with others.
|
||||
They are designed to be easily installable and shareable.
|
||||
They're designed to be easily installable and shareable.
|
||||
|
||||
To see examples of extensions, you can browse a gallery of
|
||||
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
|
||||
|
||||
## Managing extensions
|
||||
See [getting started docs](getting-started-extensions.md) for a guide on
|
||||
creating your first extension.
|
||||
|
||||
You can verify your installed extensions and their status using the interactive
|
||||
command:
|
||||
See [releasing docs](extension-releasing.md) for an advanced guide on setting up
|
||||
GitHub releases.
|
||||
|
||||
```bash
|
||||
/extensions list
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `gemini extensions`
|
||||
commands.
|
||||
|
||||
Note that these commands are not supported from within the CLI, although you can
|
||||
list installed extensions using the `/extensions list` subcommand.
|
||||
|
||||
Note that all of these commands will only be reflected in active CLI sessions on
|
||||
restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a
|
||||
GitHub URL or a local path.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run
|
||||
`gemini extensions update` to pull in changes from both locally-defined
|
||||
extensions and those on GitHub.
|
||||
|
||||
NOTE: If you are installing an extension from GitHub, you'll need to have `git`
|
||||
installed on your machine. See
|
||||
[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
for help.
|
||||
|
||||
```
|
||||
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
|
||||
```
|
||||
|
||||
or in noninteractive mode:
|
||||
- `<source>`: The github URL or local path of the extension to install.
|
||||
- `--ref`: The git ref to install from.
|
||||
- `--auto-update`: Enable auto-update for this extension.
|
||||
- `--pre-release`: Enable pre-release versions for this extension.
|
||||
- `--consent`: Acknowledge the security risks of installing an extension and
|
||||
skip the confirmation prompt.
|
||||
|
||||
```bash
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall one or more extensions, run
|
||||
`gemini extensions uninstall <name...>`:
|
||||
|
||||
```
|
||||
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an
|
||||
extension entirely or for specific workspace.
|
||||
|
||||
```
|
||||
gemini extensions disable <name> [--scope <scope>]
|
||||
```
|
||||
|
||||
- `<name>`: The name of the extension to disable.
|
||||
- `--scope`: The scope to disable the extension in (`user` or `workspace`).
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can enable extensions using `gemini extensions enable <name>`. You can also
|
||||
enable an extension for a specific workspace using
|
||||
`gemini extensions enable <name> --scope=workspace` from within that workspace.
|
||||
|
||||
```
|
||||
gemini extensions enable <name> [--scope <scope>]
|
||||
```
|
||||
|
||||
- `<name>`: The name of the extension to enable.
|
||||
- `--scope`: The scope to enable the extension in (`user` or `workspace`).
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can
|
||||
explicitly update to the latest version (as reflected in the
|
||||
`gemini-extension.json` `version` field) with `gemini extensions update <name>`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
gemini extensions update --all
|
||||
```
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`,
|
||||
`exclude-tools` and `mcp-server`. You can view these examples
|
||||
[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of
|
||||
your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new <path> [template]
|
||||
```
|
||||
|
||||
- `<path>`: The path to create the extension in.
|
||||
- `[template]`: The boilerplate template to use.
|
||||
|
||||
### Link a local extension
|
||||
|
||||
The `gemini extensions link` command will create a symbolic link from the
|
||||
extension installation directory to the development path.
|
||||
|
||||
This is useful so you don't have to run `gemini extensions update` every time
|
||||
you make changes you'd like to test.
|
||||
|
||||
```
|
||||
gemini extensions link <path>
|
||||
```
|
||||
|
||||
- `<path>`: The path of the extension to link.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `gemini-extension.json` file.
|
||||
For example:
|
||||
|
||||
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
The `gemini-extension.json` file contains the configuration for the extension.
|
||||
The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers settings in a
|
||||
[`settings.json` file](../get-started/configuration.md). If both an extension
|
||||
and a `settings.json` file settings an MCP server with the same name, the
|
||||
server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
To define settings, add a `settings` array to your `gemini-extension.json` file.
|
||||
Each object in the array should have the following properties:
|
||||
|
||||
- `name`: A user-friendly name for the setting.
|
||||
- `description`: A description of the setting and what it's used for.
|
||||
- `envVar`: The name of the environment variable that the setting will be stored
|
||||
as.
|
||||
- `sensitive`: Optional boolean. If true, obfuscates the input the user provides
|
||||
and stores the secret in keychain storage. **Example**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-api-extension",
|
||||
"version": "1.0.0",
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"description": "Your API key for the service.",
|
||||
"envVar": "MY_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When a user installs this extension, they will be prompted to enter their API
|
||||
key. The value will be saved to a `.env` file in the extension's directory
|
||||
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
|
||||
|
||||
You can view a list of an extension's settings by running:
|
||||
|
||||
```
|
||||
gemini extensions list
|
||||
```
|
||||
|
||||
## Installation
|
||||
and you can update a given setting using:
|
||||
|
||||
To install a real extension, you can use the `extensions install` command with a
|
||||
GitHub repository URL in noninteractive mode. For example:
|
||||
|
||||
```bash
|
||||
gemini extensions install https://github.com/gemini-cli-extensions/workspace
|
||||
```
|
||||
gemini extensions config <extension name> [setting name] [--scope <scope>]
|
||||
```
|
||||
|
||||
## Next steps
|
||||
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
|
||||
optional and will default to `user`.
|
||||
|
||||
- [Writing extensions](writing-extensions.md): Learn how to create your first
|
||||
extension.
|
||||
- [Extensions reference](reference.md): Deeply understand the extension format,
|
||||
commands, and configuration.
|
||||
- [Best practices](best-practices.md): Learn strategies for building great
|
||||
extensions.
|
||||
- [Extensions releasing](releasing.md): Learn how to share your extensions with
|
||||
the world.
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
|
||||
TOML files in a `commands/` subdirectory within the extension directory. These
|
||||
commands follow the same format as user and project custom commands and use
|
||||
standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
Extensions can bundle [Agent Skills](../cli/skills.md) to provide on-demand
|
||||
expertise and specialized workflows. To include skills in your extension, place
|
||||
them in a `skills/` subdirectory within the extension directory. Each skill must
|
||||
follow the [Agent Skills structure](../cli/skills.md#folder-structure),
|
||||
including a `SKILL.md` file.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `security-toolkit` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/security-toolkit/
|
||||
├── gemini-extension.json
|
||||
└── skills/
|
||||
├── audit/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/
|
||||
│ └── scan.py
|
||||
└── hardening/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Upon installation, these skills will be discovered by Gemini CLI and can be
|
||||
activated during a session when the model identifies a task matching their
|
||||
descriptions.
|
||||
|
||||
Extension skills have the lowest precedence and will be overridden by user or
|
||||
workspace skills of the same name. They can be viewed and managed (enabled or
|
||||
disabled) using the [`/skills` command](../cli/skills.md#managing-skills).
|
||||
|
||||
### Hooks
|
||||
|
||||
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension
|
||||
must be defined in a `hooks/hooks.json` file within the extension directory.
|
||||
|
||||
> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The
|
||||
> CLI specifically looks for the `hooks/hooks.json` file.
|
||||
|
||||
#### Directory structure
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── hooks/
|
||||
└── hooks.json
|
||||
```
|
||||
|
||||
#### `hooks/hooks.json` format
|
||||
|
||||
The `hooks.json` file contains a `hooks` object where keys are
|
||||
[event names](../hooks/reference.md#supported-events) and values are arrays of
|
||||
[hook definitions](../hooks/reference.md#hook-definition).
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"before_agent": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${extensionPath}/scripts/setup.js",
|
||||
"name": "Extension Setup"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Supported variables
|
||||
|
||||
Just like `gemini-extension.json`, the `hooks/hooks.json` file supports
|
||||
[variable substitution](#variables). This is particularly useful for referencing
|
||||
scripts within the extension directory using `${extensionPath}`.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user
|
||||
or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix
|
||||
(e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]`
|
||||
tag)
|
||||
|
||||
### Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in both
|
||||
`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you
|
||||
need the current directory to run an MCP server or hook script using
|
||||
`"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
| `${process.execPath}` | The path to the Node.js binary executing the CLI. |
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# Extensions reference
|
||||
|
||||
This guide covers the `gemini extensions` commands and the structure of the
|
||||
`gemini-extension.json` configuration file.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `gemini extensions`
|
||||
commands.
|
||||
|
||||
Note that these commands (e.g. `gemini extensions install`) are not supported
|
||||
from within the CLI's **interactive mode**, although you can list installed
|
||||
extensions using the `/extensions list` slash command.
|
||||
|
||||
Note that all of these management operations (including updates to slash
|
||||
commands) will only be reflected in active CLI sessions on **restart**.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a
|
||||
GitHub URL or a local path.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run
|
||||
`gemini extensions update` to pull in changes from both locally-defined
|
||||
extensions and those on GitHub.
|
||||
|
||||
NOTE: If you are installing an extension from GitHub, you'll need to have `git`
|
||||
installed on your machine. See
|
||||
[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
for help.
|
||||
|
||||
```
|
||||
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
|
||||
```
|
||||
|
||||
- `<source>`: The github URL or local path of the extension to install.
|
||||
- `--ref`: The git ref to install from.
|
||||
- `--auto-update`: Enable auto-update for this extension.
|
||||
- `--pre-release`: Enable pre-release versions for this extension.
|
||||
- `--consent`: Acknowledge the security risks of installing an extension and
|
||||
skip the confirmation prompt.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall one or more extensions, run
|
||||
`gemini extensions uninstall <name...>`:
|
||||
|
||||
```
|
||||
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an
|
||||
extension entirely or for specific workspace.
|
||||
|
||||
```
|
||||
gemini extensions disable <name> [--scope <scope>]
|
||||
```
|
||||
|
||||
- `<name>`: The name of the extension to disable.
|
||||
- `--scope`: The scope to disable the extension in (`user` or `workspace`).
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can enable extensions using `gemini extensions enable <name>`. You can also
|
||||
enable an extension for a specific workspace using
|
||||
`gemini extensions enable <name> --scope=workspace` from within that workspace.
|
||||
|
||||
```
|
||||
gemini extensions enable <name> [--scope <scope>]
|
||||
```
|
||||
|
||||
- `<name>`: The name of the extension to enable.
|
||||
- `--scope`: The scope to enable the extension in (`user` or `workspace`).
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can
|
||||
explicitly update to the latest version (as reflected in the
|
||||
`gemini-extension.json` `version` field) with `gemini extensions update <name>`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
gemini extensions update --all
|
||||
```
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`,
|
||||
`exclude-tools` and `mcp-server`. You can view these examples
|
||||
[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of
|
||||
your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new <path> [template]
|
||||
```
|
||||
|
||||
- `<path>`: The path to create the extension in.
|
||||
- `[template]`: The boilerplate template to use.
|
||||
|
||||
### Link a local extension
|
||||
|
||||
The `gemini extensions link` command will create a symbolic link from the
|
||||
extension installation directory to the development path.
|
||||
|
||||
This is useful so you don't have to run `gemini extensions update` every time
|
||||
you make changes you'd like to test.
|
||||
|
||||
```
|
||||
gemini extensions link <path>
|
||||
```
|
||||
|
||||
- `<path>`: The path of the extension to link.
|
||||
|
||||
## Extension format
|
||||
|
||||
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `gemini-extension.json` file.
|
||||
For example:
|
||||
|
||||
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
The `gemini-extension.json` file contains the configuration for the extension.
|
||||
The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "My awesome extension",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `description`: A short description of the extension. This will be displayed on
|
||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers settingsd in a
|
||||
[`settings.json` file](../get-started/configuration.md). If both an extension
|
||||
and a `settings.json` file settings an MCP server with the same name, the
|
||||
server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
To define settings, add a `settings` array to your `gemini-extension.json` file.
|
||||
Each object in the array should have the following properties:
|
||||
|
||||
- `name`: A user-friendly name for the setting.
|
||||
- `description`: A description of the setting and what it's used for.
|
||||
- `envVar`: The name of the environment variable that the setting will be stored
|
||||
as.
|
||||
- `sensitive`: Optional boolean. If true, obfuscates the input the user provides
|
||||
and stores the secret in keychain storage. **Example**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-api-extension",
|
||||
"version": "1.0.0",
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"description": "Your API key for the service.",
|
||||
"envVar": "MY_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When a user installs this extension, they will be prompted to enter their API
|
||||
key. The value will be saved to a `.env` file in the extension's directory
|
||||
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
|
||||
|
||||
You can view a list of an extension's settings by running:
|
||||
|
||||
```
|
||||
gemini extensions list
|
||||
```
|
||||
|
||||
and you can update a given setting using:
|
||||
|
||||
```
|
||||
gemini extensions config <extension name> [setting name] [--scope <scope>]
|
||||
```
|
||||
|
||||
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
|
||||
optional and will default to `user`.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
|
||||
TOML files in a `commands/` subdirectory within the extension directory. These
|
||||
commands follow the same format as user and project custom commands and use
|
||||
standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Hooks
|
||||
|
||||
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension
|
||||
must be defined in a `hooks/hooks.json` file within the extension directory.
|
||||
|
||||
> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The
|
||||
> CLI specifically looks for the `hooks/hooks.json` file.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
Extensions can bundle [Agent Skills](../cli/skills.md) to provide specialized
|
||||
workflows. Skills must be placed in a `skills/` directory within the extension.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── skills/
|
||||
└── security-audit/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Will expose a `security-audit` skill that the model can activate.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user
|
||||
or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix
|
||||
(e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]`
|
||||
tag)
|
||||
|
||||
## Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in `gemini-extension.json`.
|
||||
This can be useful if e.g., you need the current directory to run an MCP server
|
||||
using an argument like `"args": ["${extensionPath}${/}dist${/}server.js"]`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
@@ -650,13 +650,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
@@ -844,7 +837,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
@@ -862,11 +855,43 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
|
||||
- **Description:** Enable the Codebase Investigator agent.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.maxNumTurns`** (number):
|
||||
- **Description:** Maximum number of turns for the Codebase Investigator
|
||||
agent.
|
||||
- **Default:** `10`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.maxTimeMinutes`** (number):
|
||||
- **Description:** Maximum time for the Codebase Investigator agent (in
|
||||
minutes).
|
||||
- **Default:** `3`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.thinkingBudget`** (number):
|
||||
- **Description:** The thinking budget for the Codebase Investigator agent.
|
||||
- **Default:** `8192`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.model`** (string):
|
||||
- **Description:** The model to use for the Codebase Investigator agent.
|
||||
- **Default:** `"auto"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
|
||||
(useful for remote sessions).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.cliHelpAgentSettings.enabled`** (boolean):
|
||||
- **Description:** Enable the CLI Help Agent.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.plan`** (boolean):
|
||||
- **Description:** Enable planning features (Plan Mode and tools).
|
||||
- **Default:** `false`
|
||||
@@ -884,7 +909,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **`hooksConfig.enabled`** (boolean):
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
|
||||
+431
-252
@@ -1,4 +1,4 @@
|
||||
# Hooks Best Practices
|
||||
# Hooks on Gemini CLI: Best practices
|
||||
|
||||
This guide covers security considerations, performance optimization, debugging
|
||||
techniques, and privacy considerations for developing and deploying hooks in
|
||||
@@ -15,20 +15,21 @@ using parallel operations:
|
||||
// Sequential operations are slower
|
||||
const data1 = await fetch(url1).then((r) => r.json());
|
||||
const data2 = await fetch(url2).then((r) => r.json());
|
||||
const data3 = await fetch(url3).then((r) => r.json());
|
||||
|
||||
// Prefer parallel operations for better performance
|
||||
// Start requests concurrently
|
||||
const p1 = fetch(url1).then((r) => r.json());
|
||||
const p2 = fetch(url2).then((r) => r.json());
|
||||
const p3 = fetch(url3).then((r) => r.json());
|
||||
|
||||
// Wait for all results
|
||||
const [data1, data2] = await Promise.all([p1, p2]);
|
||||
const [data1, data2, data3] = await Promise.all([p1, p2, p3]);
|
||||
```
|
||||
|
||||
### Cache expensive operations
|
||||
|
||||
Store results between invocations to avoid repeated computation, especially for
|
||||
hooks that run frequently (like `BeforeTool` or `AfterModel`).
|
||||
Store results between invocations to avoid repeated computation:
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
@@ -53,7 +54,6 @@ async function main() {
|
||||
const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache
|
||||
|
||||
if (cache[cacheKey]) {
|
||||
// Write JSON to stdout
|
||||
console.log(JSON.stringify(cache[cacheKey]));
|
||||
return;
|
||||
}
|
||||
@@ -70,20 +70,32 @@ async function main() {
|
||||
### Use appropriate events
|
||||
|
||||
Choose hook events that match your use case to avoid unnecessary execution.
|
||||
`AfterAgent` fires once per agent loop completion, while `AfterModel` fires
|
||||
after every LLM call (potentially multiple times per loop):
|
||||
|
||||
- **`AfterAgent`**: Fires **once** per turn after the model finishes its final
|
||||
response. Use this for quality validation (Retries) or final logging.
|
||||
- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for
|
||||
real-time redaction, PII filtering, or monitoring output as it streams.
|
||||
|
||||
If you only need to check the final completion, use `AfterAgent` to save
|
||||
performance.
|
||||
```json
|
||||
// If checking final completion, use AfterAgent instead of AfterModel
|
||||
{
|
||||
"hooks": {
|
||||
"AfterAgent": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "final-checker",
|
||||
"command": "./check-completion.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Filter with matchers
|
||||
|
||||
Use specific matchers to avoid unnecessary hook execution. Instead of matching
|
||||
all tools with `*`, specify only the tools you need. This saves the overhead of
|
||||
spawning a process for irrelevant events.
|
||||
all tools with `*`, specify only the tools you need:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -91,7 +103,6 @@ spawning a process for irrelevant events.
|
||||
"hooks": [
|
||||
{
|
||||
"name": "validate-writes",
|
||||
"type": "command",
|
||||
"command": "./validate.sh"
|
||||
}
|
||||
]
|
||||
@@ -100,32 +111,30 @@ spawning a process for irrelevant events.
|
||||
|
||||
### Optimize JSON parsing
|
||||
|
||||
For large inputs (like `AfterModel` receiving a large context), standard JSON
|
||||
parsing can be slow. If you only need one field, consider streaming parsers or
|
||||
lightweight extraction logic, though for most shell scripts `jq` is sufficient.
|
||||
For large inputs, use streaming JSON parsers to avoid loading everything into
|
||||
memory:
|
||||
|
||||
```javascript
|
||||
// Standard approach: parse entire input
|
||||
const input = JSON.parse(await readStdin());
|
||||
const content = input.tool_input.content;
|
||||
|
||||
// For very large inputs: stream and extract only needed fields
|
||||
const { createReadStream } = require('fs');
|
||||
const JSONStream = require('JSONStream');
|
||||
|
||||
const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content'));
|
||||
let content = '';
|
||||
stream.on('data', (chunk) => {
|
||||
content += chunk;
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### The "Strict JSON" rule
|
||||
|
||||
The most common cause of hook failure is "polluting" the standard output.
|
||||
|
||||
- **stdout** is for **JSON only**.
|
||||
- **stderr** is for **logs and text**.
|
||||
|
||||
**Good:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "Starting check..." >&2 # <--- Redirect to stderr
|
||||
echo '{"decision": "allow"}'
|
||||
|
||||
```
|
||||
|
||||
### Log to files
|
||||
|
||||
Since hooks run in the background, writing to a dedicated log file is often the
|
||||
easiest way to debug complex logic.
|
||||
Write debug information to dedicated log files:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
@@ -142,9 +151,6 @@ log "Received input: ${input:0:100}..."
|
||||
# Hook logic here
|
||||
|
||||
log "Hook completed successfully"
|
||||
# Always output valid JSON to stdout at the end, even if just empty
|
||||
echo "{}"
|
||||
|
||||
```
|
||||
|
||||
### Use stderr for errors
|
||||
@@ -156,7 +162,6 @@ try {
|
||||
const result = dangerousOperation();
|
||||
console.log(JSON.stringify({ result }));
|
||||
} catch (error) {
|
||||
// Write the error description to stderr so the user/agent sees it
|
||||
console.error(`Hook error: ${error.message}`);
|
||||
process.exit(2); // Blocking error
|
||||
}
|
||||
@@ -164,8 +169,7 @@ try {
|
||||
|
||||
### Test hooks independently
|
||||
|
||||
Run hook scripts manually with sample JSON input to verify they behave as
|
||||
expected before hooking them up to the CLI.
|
||||
Run hook scripts manually with sample JSON input:
|
||||
|
||||
```bash
|
||||
# Create test input
|
||||
@@ -187,46 +191,33 @@ cat test-input.json | .gemini/hooks/my-hook.sh
|
||||
|
||||
# Check exit code
|
||||
echo "Exit code: $?"
|
||||
|
||||
```
|
||||
|
||||
### Check exit codes
|
||||
|
||||
Gemini CLI uses exit codes for high-level flow control:
|
||||
|
||||
- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for
|
||||
JSON decisions.
|
||||
- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the
|
||||
reason.
|
||||
- For **Agent/Model** events, this aborts the turn.
|
||||
- For **Tool** events, this blocks the tool but allows the agent to continue.
|
||||
- For **AfterAgent**, this triggers an automatic retry turn.
|
||||
|
||||
> **TIP**
|
||||
>
|
||||
> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a
|
||||
> **specific action**. Use `{"continue": false}` in your JSON output to **kill
|
||||
> the entire agent loop** immediately.
|
||||
Ensure your script returns the correct exit code:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -e # Exit on error
|
||||
|
||||
# Hook logic
|
||||
process_input() {
|
||||
# ...
|
||||
}
|
||||
|
||||
if process_input; then
|
||||
echo '{"decision": "allow"}'
|
||||
echo "Success message"
|
||||
exit 0
|
||||
else
|
||||
echo "Critical validation failure" >&2
|
||||
echo "Error message" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
### Enable telemetry
|
||||
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled. You can view
|
||||
these logs to debug execution flow.
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -236,10 +227,11 @@ these logs to debug execution flow.
|
||||
}
|
||||
```
|
||||
|
||||
View hook telemetry in logs to debug execution issues.
|
||||
|
||||
### Use hook panel
|
||||
|
||||
The `/hooks panel` command inside the CLI shows execution status and recent
|
||||
output:
|
||||
The `/hooks panel` command shows execution status and recent output:
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
@@ -263,64 +255,18 @@ Begin with basic logging hooks before implementing complex logic:
|
||||
# Simple logging hook to understand input structure
|
||||
input=$(cat)
|
||||
echo "$input" >> .gemini/hook-inputs.log
|
||||
# Always return valid JSON
|
||||
echo "{}"
|
||||
|
||||
```
|
||||
|
||||
### Documenting your hooks
|
||||
|
||||
Maintainability is critical for complex hook systems. Use descriptions and
|
||||
comments to help yourself and others understand why a hook exists.
|
||||
|
||||
**Use the `description` field**: This text is displayed in the `/hooks panel` UI
|
||||
and helps diagnose issues.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
|
||||
"description": "Scans code changes for API keys and secrets before writing"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add comments in hook scripts**: Explain performance expectations and
|
||||
dependencies.
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RAG Tool Filter Hook
|
||||
*
|
||||
* Reduces the tool space by extracting keywords from the user's request.
|
||||
*
|
||||
* Performance: ~500ms average
|
||||
* Dependencies: @google/generative-ai
|
||||
*/
|
||||
echo "Logged input"
|
||||
```
|
||||
|
||||
### Use JSON libraries
|
||||
|
||||
Parse JSON with proper libraries instead of text processing.
|
||||
Parse JSON with proper libraries instead of text processing:
|
||||
|
||||
**Bad:**
|
||||
|
||||
```bash
|
||||
# Fragile text parsing
|
||||
tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
|
||||
|
||||
```
|
||||
|
||||
**Good:**
|
||||
@@ -328,7 +274,6 @@ tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
|
||||
```bash
|
||||
# Robust JSON parsing
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name')
|
||||
|
||||
```
|
||||
|
||||
### Make scripts executable
|
||||
@@ -338,7 +283,6 @@ Always make hook scripts executable:
|
||||
```bash
|
||||
chmod +x .gemini/hooks/*.sh
|
||||
chmod +x .gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
### Version control
|
||||
@@ -348,7 +292,7 @@ Commit hooks to share with your team:
|
||||
```bash
|
||||
git add .gemini/hooks/
|
||||
git add .gemini/settings.json
|
||||
|
||||
git commit -m "Add project hooks for security and testing"
|
||||
```
|
||||
|
||||
**`.gitignore` considerations:**
|
||||
@@ -362,10 +306,295 @@ git add .gemini/settings.json
|
||||
# Keep hook scripts
|
||||
!.gemini/hooks/*.sh
|
||||
!.gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
## Hook security
|
||||
### Document behavior
|
||||
|
||||
Add descriptions to help others understand your hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
|
||||
"description": "Scans code changes for API keys, passwords, and other secrets before writing"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add comments in hook scripts:
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RAG Tool Filter Hook
|
||||
*
|
||||
* This hook reduces the tool space from 100+ tools to ~15 relevant ones
|
||||
* by extracting keywords from the user's request and filtering tools
|
||||
* based on semantic similarity.
|
||||
*
|
||||
* Performance: ~500ms average, cached tool embeddings
|
||||
* Dependencies: @google/generative-ai
|
||||
*/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook not executing
|
||||
|
||||
**Check hook name in `/hooks panel`:**
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
```
|
||||
|
||||
Verify the hook appears in the list and is enabled.
|
||||
|
||||
**Verify matcher pattern:**
|
||||
|
||||
```bash
|
||||
# Test regex pattern
|
||||
echo "write_file|replace" | grep -E "write_.*|replace"
|
||||
```
|
||||
|
||||
**Check disabled list:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["my-hook-name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ensure script is executable:**
|
||||
|
||||
```bash
|
||||
ls -la .gemini/hooks/my-hook.sh
|
||||
chmod +x .gemini/hooks/my-hook.sh
|
||||
```
|
||||
|
||||
**Verify script path:**
|
||||
|
||||
```bash
|
||||
# Check path expansion
|
||||
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
|
||||
|
||||
# Verify file exists
|
||||
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
|
||||
```
|
||||
|
||||
### Hook timing out
|
||||
|
||||
**Check configured timeout:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "slow-hook",
|
||||
"timeout": 60000
|
||||
}
|
||||
```
|
||||
|
||||
**Optimize slow operations:**
|
||||
|
||||
```javascript
|
||||
// Before: Sequential operations (slow)
|
||||
for (const item of items) {
|
||||
await processItem(item);
|
||||
}
|
||||
|
||||
// After: Parallel operations (fast)
|
||||
await Promise.all(items.map((item) => processItem(item)));
|
||||
```
|
||||
|
||||
**Use caching:**
|
||||
|
||||
```javascript
|
||||
const cache = new Map();
|
||||
|
||||
async function getCachedData(key) {
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
const data = await fetchData(key);
|
||||
cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Consider splitting into multiple faster hooks:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "quick-check",
|
||||
"command": "./quick-validation.sh",
|
||||
"timeout": 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "deep-check",
|
||||
"command": "./deep-analysis.sh",
|
||||
"timeout": 30000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid JSON output
|
||||
|
||||
**Validate JSON before outputting:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
output='{"decision": "allow"}'
|
||||
|
||||
# Validate JSON
|
||||
if echo "$output" | jq empty 2>/dev/null; then
|
||||
echo "$output"
|
||||
else
|
||||
echo "Invalid JSON generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**Ensure proper quoting and escaping:**
|
||||
|
||||
```javascript
|
||||
// Bad: Unescaped string interpolation
|
||||
const message = `User said: ${userInput}`;
|
||||
console.log(JSON.stringify({ message }));
|
||||
|
||||
// Good: Automatic escaping
|
||||
console.log(JSON.stringify({ message: `User said: ${userInput}` }));
|
||||
```
|
||||
|
||||
**Check for binary data or control characters:**
|
||||
|
||||
```javascript
|
||||
function sanitizeForJSON(str) {
|
||||
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars
|
||||
}
|
||||
|
||||
const cleanContent = sanitizeForJSON(content);
|
||||
console.log(JSON.stringify({ content: cleanContent }));
|
||||
```
|
||||
|
||||
### Exit code issues
|
||||
|
||||
**Verify script returns correct codes:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e # Exit on error
|
||||
|
||||
# Processing logic
|
||||
if validate_input; then
|
||||
echo "Success"
|
||||
exit 0
|
||||
else
|
||||
echo "Validation failed" >&2
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
**Check for unintended errors:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Don't use 'set -e' if you want to handle errors explicitly
|
||||
# set -e
|
||||
|
||||
if ! command_that_might_fail; then
|
||||
# Handle error
|
||||
echo "Command failed but continuing" >&2
|
||||
fi
|
||||
|
||||
# Always exit explicitly
|
||||
exit 0
|
||||
```
|
||||
|
||||
**Use trap for cleanup:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cleanup() {
|
||||
# Cleanup logic
|
||||
rm -f /tmp/hook-temp-*
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Hook logic here
|
||||
```
|
||||
|
||||
### Environment variables not available
|
||||
|
||||
**Check if variable is set:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ -z "$GEMINI_PROJECT_DIR" ]; then
|
||||
echo "GEMINI_PROJECT_DIR not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$CUSTOM_VAR" ]; then
|
||||
echo "Warning: CUSTOM_VAR not set, using default" >&2
|
||||
CUSTOM_VAR="default-value"
|
||||
fi
|
||||
```
|
||||
|
||||
**Debug available variables:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# List all environment variables
|
||||
env > .gemini/hook-env.log
|
||||
|
||||
# Check specific variables
|
||||
echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log
|
||||
echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log
|
||||
echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+<set>}" >> .gemini/hook-env.log
|
||||
```
|
||||
|
||||
**Use .env files:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Load .env file if it exists
|
||||
if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
|
||||
source "$GEMINI_PROJECT_DIR/.env"
|
||||
fi
|
||||
```
|
||||
|
||||
## Using Hooks Securely
|
||||
|
||||
### Threat Model
|
||||
|
||||
@@ -392,10 +621,11 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
it).
|
||||
5. **Trust**: The hook is marked as "trusted" for this project.
|
||||
|
||||
> **Modification detection**: If the `command` string of a project hook is
|
||||
> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat
|
||||
> it as a **new, untrusted hook** and warn you again. This prevents malicious
|
||||
> actors from silently swapping a verified command for a malicious one.
|
||||
> [!IMPORTANT] **Modification Detection**: If the `command` string of a project
|
||||
> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will
|
||||
> treat it as a **new, untrusted hook** and warn you again. This prevents
|
||||
> malicious actors from silently swapping a verified command for a malicious
|
||||
> one.
|
||||
|
||||
### Risks
|
||||
|
||||
@@ -416,134 +646,32 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
publishers, well-known community members).
|
||||
- Be cautious with obfuscated scripts or compiled binaries from unknown sources.
|
||||
|
||||
#### Sanitize environment
|
||||
#### Sanitize Environment
|
||||
|
||||
Hooks inherit the environment of the Gemini CLI process, which may include
|
||||
sensitive API keys. Gemini CLI provides a
|
||||
[redaction system](/docs/get-started/configuration#environment-variable-redaction)
|
||||
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
|
||||
`TOKEN`).
|
||||
sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you
|
||||
should be cautious.
|
||||
|
||||
> **Disabled by Default**: Environment redaction is currently **OFF by
|
||||
> default**. We strongly recommend enabling it if you are running third-party
|
||||
> hooks or working in sensitive environments.
|
||||
- **Avoid printing environment variables** to stdout/stderr unless necessary.
|
||||
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
|
||||
excluded from version control.
|
||||
|
||||
**Impact on hooks:**
|
||||
|
||||
- **Security**: Prevents your hook scripts from accidentally leaking secrets.
|
||||
- **Troubleshooting**: If your hook depends on a specific environment variable
|
||||
that is being blocked, you must explicitly allow it in `settings.json`.
|
||||
**System Administrators:** You can enforce environment variable redaction by
|
||||
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"environmentVariableRedaction": {
|
||||
"enabled": true,
|
||||
"allowed": ["MY_REQUIRED_TOOL_KEY"]
|
||||
"blocked": ["MY_SECRET_KEY"],
|
||||
"allowed": ["SAFE_VAR"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**System administrators:** You can enforce redaction for all users in the system
|
||||
configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook not executing
|
||||
|
||||
**Check hook name in `/hooks panel`:** Verify the hook appears in the list and
|
||||
is enabled.
|
||||
|
||||
**Verify matcher pattern:**
|
||||
|
||||
```bash
|
||||
# Test regex pattern
|
||||
echo "write_file|replace" | grep -E "write_.*|replace"
|
||||
|
||||
```
|
||||
|
||||
**Check disabled list:** Verify the hook is not listed in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["my-hook-name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ensure script is executable**: For macOS and Linux users, verify the script
|
||||
has execution permissions:
|
||||
|
||||
```bash
|
||||
ls -la .gemini/hooks/my-hook.sh
|
||||
chmod +x .gemini/hooks/my-hook.sh
|
||||
```
|
||||
|
||||
**Verify script path:** Ensure the path in `settings.json` resolves correctly.
|
||||
|
||||
```bash
|
||||
# Check path expansion
|
||||
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
|
||||
|
||||
# Verify file exists
|
||||
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
|
||||
```
|
||||
|
||||
### Hook timing out
|
||||
|
||||
**Check configured timeout:** The default is 60000ms (1 minute). You can
|
||||
increase this in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "slow-hook",
|
||||
"timeout": 120000
|
||||
}
|
||||
```
|
||||
|
||||
**Optimize slow operations:** Move heavy processing to background tasks or use
|
||||
caching.
|
||||
|
||||
### Invalid JSON output
|
||||
|
||||
**Validate JSON before outputting:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
output='{"decision": "allow"}'
|
||||
|
||||
# Validate JSON
|
||||
if echo "$output" | jq empty 2>/dev/null; then
|
||||
echo "$output"
|
||||
else
|
||||
echo "Invalid JSON generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
### Environment variables not available
|
||||
|
||||
**Check if variable is set:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "$GEMINI_PROJECT_DIR" ]; then
|
||||
echo "GEMINI_PROJECT_DIR not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
**Debug available variables:**
|
||||
|
||||
```bash
|
||||
env > .gemini/hook-env.log
|
||||
```
|
||||
|
||||
## Authoring secure hooks
|
||||
## Authoring Secure Hooks
|
||||
|
||||
When writing your own hooks, follow these practices to ensure they are robust
|
||||
and secure.
|
||||
@@ -585,7 +713,6 @@ defaults to 60 seconds, but you should set stricter limits for fast hooks.
|
||||
"hooks": [
|
||||
{
|
||||
"name": "fast-validator",
|
||||
"type": "command",
|
||||
"command": "./hooks/validate.sh",
|
||||
"timeout": 5000 // 5 seconds
|
||||
}
|
||||
@@ -639,17 +766,40 @@ function containsSecret(content) {
|
||||
|
||||
## Privacy considerations
|
||||
|
||||
Hook inputs and outputs may contain sensitive information.
|
||||
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
|
||||
the `telemetry.logPrompts` setting for hook data logging.
|
||||
|
||||
### What data is collected
|
||||
|
||||
Hook telemetry may include inputs (prompts, code) and outputs (decisions,
|
||||
reasons) unless disabled.
|
||||
Hook telemetry may include:
|
||||
|
||||
- **Hook inputs:** User prompts, tool arguments, file contents
|
||||
- **Hook outputs:** Hook responses, decision reasons, added context
|
||||
- **Standard streams:** stdout and stderr from hook processes
|
||||
- **Execution metadata:** Hook name, event type, duration, success/failure
|
||||
|
||||
### Privacy settings
|
||||
|
||||
**Disable PII logging:** If you are working with sensitive data, disable prompt
|
||||
logging in your settings:
|
||||
**Enabled (default):**
|
||||
|
||||
Full hook I/O is logged to telemetry. Use this when:
|
||||
|
||||
- Developing and debugging hooks
|
||||
- Telemetry is redirected to a trusted enterprise system
|
||||
- You understand and accept the privacy implications
|
||||
|
||||
**Disabled:**
|
||||
|
||||
Only metadata is logged (event name, duration, success/failure). Hook inputs and
|
||||
outputs are excluded. Use this when:
|
||||
|
||||
- Sending telemetry to third-party systems
|
||||
- Working with sensitive data
|
||||
- Privacy regulations require minimizing data collection
|
||||
|
||||
### Configuration
|
||||
|
||||
**Disable PII logging in settings:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -659,19 +809,48 @@ logging in your settings:
|
||||
}
|
||||
```
|
||||
|
||||
**Suppress Output:** Individual hooks can request their metadata be hidden from
|
||||
logs and telemetry by returning `"suppressOutput": true` in their JSON response.
|
||||
**Disable via environment variable:**
|
||||
|
||||
> **Note**
|
||||
|
||||
> `suppressOutput` only affects background logging. Any `systemMessage` or
|
||||
> `reason` included in the JSON will still be displayed to the user in the
|
||||
> terminal.
|
||||
```bash
|
||||
export GEMINI_TELEMETRY_LOG_PROMPTS=false
|
||||
```
|
||||
|
||||
### Sensitive data in hooks
|
||||
|
||||
If your hooks process sensitive data:
|
||||
|
||||
1. **Minimize logging:** Don't write sensitive data to log files.
|
||||
2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing
|
||||
to stderr.
|
||||
1. **Minimize logging:** Don't write sensitive data to log files
|
||||
2. **Sanitize outputs:** Remove sensitive data before outputting
|
||||
3. **Use secure storage:** Encrypt sensitive data at rest
|
||||
4. **Limit access:** Restrict hook script permissions
|
||||
|
||||
**Example sanitization:**
|
||||
|
||||
```javascript
|
||||
function sanitizeOutput(data) {
|
||||
const sanitized = { ...data };
|
||||
|
||||
// Remove sensitive fields
|
||||
delete sanitized.apiKey;
|
||||
delete sanitized.password;
|
||||
|
||||
// Redact sensitive strings
|
||||
if (sanitized.content) {
|
||||
sanitized.content = sanitized.content.replace(
|
||||
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
|
||||
'[REDACTED]',
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(sanitizeOutput(hookOutput)));
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Hooks Reference](index.md) - Complete API reference
|
||||
- [Writing Hooks](writing-hooks.md) - Tutorial and examples
|
||||
- [Configuration](../get-started/configuration.md) - Gemini CLI settings
|
||||
- [Hooks Design Document](../hooks-design.md) - Technical architecture
|
||||
|
||||
+668
-123
@@ -4,115 +4,109 @@ Hooks are scripts or programs that Gemini CLI executes at specific points in the
|
||||
agentic loop, allowing you to intercept and customize behavior without modifying
|
||||
the CLI's source code.
|
||||
|
||||
## Availability
|
||||
> [!NOTE] **Hooks are currently an experimental feature.**
|
||||
>
|
||||
> To use hooks, you must explicitly enable them in your `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "tools": { "enableHooks": true },
|
||||
> "hooks": { "enabled": true }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Both of these are needed in this experimental phase.
|
||||
|
||||
> **Experimental Feature**: Hooks are currently enabled by default only in the
|
||||
> **Preview** and **Nightly** release channels.
|
||||
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
|
||||
first hook and a comprehensive example.
|
||||
|
||||
If you are on the Stable channel, you must explicitly enable the hooks system in
|
||||
your `settings.json`:
|
||||
See [hooks reference](reference.md) for the technical specification of the I/O
|
||||
schemas.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooksConfig": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
|
||||
your first hook with comprehensive examples.
|
||||
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
|
||||
specification of I/O schemas and exit codes.
|
||||
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
|
||||
performance, and debugging.
|
||||
See [best practices](best-practices.md) for guidelines on security, performance,
|
||||
and debugging.
|
||||
|
||||
## What are hooks?
|
||||
|
||||
With hooks, you can:
|
||||
|
||||
- **Add context:** Inject relevant information before the model processes a
|
||||
request
|
||||
- **Validate actions:** Review and block potentially dangerous operations
|
||||
- **Enforce policies:** Implement security and compliance requirements
|
||||
- **Log interactions:** Track tool usage and model responses
|
||||
- **Optimize behavior:** Dynamically adjust tool selection or model parameters
|
||||
|
||||
Hooks run synchronously as part of the agent loop—when a hook event fires,
|
||||
Gemini CLI waits for all matching hooks to complete before continuing.
|
||||
|
||||
With hooks, you can:
|
||||
## Security and Risks
|
||||
|
||||
- **Add context:** Inject relevant information (like git history) before the
|
||||
model processes a request.
|
||||
- **Validate actions:** Review tool arguments and block potentially dangerous
|
||||
operations.
|
||||
- **Enforce policies:** Implement security scanners and compliance checks.
|
||||
- **Log interactions:** Track tool usage and model responses for auditing.
|
||||
- **Optimize behavior:** Dynamically filter available tools or adjust model
|
||||
parameters.
|
||||
> **Warning: Hooks execute arbitrary code with your user privileges.**
|
||||
>
|
||||
> By configuring hooks, you are explicitly allowing Gemini CLI to run shell
|
||||
> commands on your machine. Malicious or poorly configured hooks can:
|
||||
|
||||
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
|
||||
remote servers.
|
||||
- **Modify system**: Delete files, install malware, or change system settings.
|
||||
- **Consume resources**: Run infinite loops or crash your system.
|
||||
|
||||
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
|
||||
particularly risky when opening third-party projects or extensions from
|
||||
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
|
||||
project hook (identified by its name and command), but it is **your
|
||||
responsibility** to review these hooks (and any installed extensions) before
|
||||
trusting them.
|
||||
|
||||
> **Note:** Extension hooks are subject to a mandatory security warning and
|
||||
> consent flow during extension installation or update if hooks are detected.
|
||||
> You must explicitly approve the installation or update of any extension that
|
||||
> contains hooks.
|
||||
|
||||
See [Security Considerations](best-practices.md#using-hooks-securely) for a
|
||||
detailed threat model and mitigation strategies.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Hook events
|
||||
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle.
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle. The following
|
||||
table lists all available hook events:
|
||||
|
||||
| Event | When It Fires | Impact | Common Use Cases |
|
||||
| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- |
|
||||
| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context |
|
||||
| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state |
|
||||
| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns |
|
||||
| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution |
|
||||
| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses |
|
||||
| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions |
|
||||
| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection |
|
||||
| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops |
|
||||
| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results |
|
||||
| `PreCompress` | Before context compression | Advisory | Save state, notify user |
|
||||
| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging |
|
||||
| Event | When It Fires | Common Use Cases |
|
||||
| --------------------- | --------------------------------------------- | ------------------------------------------ |
|
||||
| `SessionStart` | When a session begins | Initialize resources, load context |
|
||||
| `SessionEnd` | When a session ends | Clean up, save state |
|
||||
| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts |
|
||||
| `AfterAgent` | When agent loop ends | Review output, force continuation |
|
||||
| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions |
|
||||
| `AfterModel` | After receiving LLM response | Filter responses, log interactions |
|
||||
| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection |
|
||||
| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops |
|
||||
| `AfterTool` | After a tool executes | Process results, run tests |
|
||||
| `PreCompress` | Before context compression | Save state, notify user |
|
||||
| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions |
|
||||
|
||||
### Global mechanics
|
||||
### Hook types
|
||||
|
||||
Understanding these core principles is essential for building robust hooks.
|
||||
Gemini CLI currently supports **command hooks** that run shell commands or
|
||||
scripts:
|
||||
|
||||
#### Strict JSON requirements (The "Golden Rule")
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh",
|
||||
"timeout": 30000
|
||||
}
|
||||
```
|
||||
|
||||
Hooks communicate via `stdin` (Input) and `stdout` (Output).
|
||||
**Note:** Plugin hooks (npm packages) are planned for a future release.
|
||||
|
||||
1. **Silence is Mandatory**: Your script **must not** print any plain text to
|
||||
`stdout` other than the final JSON object. **Even a single `echo` or `print`
|
||||
call before the JSON will break parsing.**
|
||||
2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will
|
||||
fail. The CLI will default to "Allow" and treat the entire output as a
|
||||
`systemMessage`.
|
||||
3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g.,
|
||||
`echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse
|
||||
it as JSON.
|
||||
### Matchers
|
||||
|
||||
#### Exit codes
|
||||
|
||||
Gemini CLI uses exit codes to determine the high-level outcome of a hook
|
||||
execution:
|
||||
|
||||
| Exit Code | Label | Behavioral Impact |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). |
|
||||
| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. |
|
||||
| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. |
|
||||
|
||||
#### Matchers
|
||||
|
||||
You can filter which specific tools or triggers fire your hook using the
|
||||
`matcher` field.
|
||||
|
||||
- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular
|
||||
Expressions**. (e.g., `"write_.*"`).
|
||||
- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`).
|
||||
- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences.
|
||||
|
||||
## Configuration
|
||||
|
||||
Hooks are configured in `settings.json`. Gemini CLI merges configurations from
|
||||
multiple layers in the following order of precedence (highest to lowest):
|
||||
|
||||
1. **Project settings**: `.gemini/settings.json` in the current directory.
|
||||
2. **User settings**: `~/.gemini/settings.json`.
|
||||
3. **System settings**: `/etc/gemini-cli/settings.json`.
|
||||
4. **Extensions**: Hooks defined by installed extensions.
|
||||
|
||||
### Configuration schema
|
||||
For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools
|
||||
trigger the hook:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -120,12 +114,382 @@ multiple layers in the following order of precedence (highest to lowest):
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
/* hooks for write operations */
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Matcher patterns:**
|
||||
|
||||
- **Exact match:** `"read_file"` matches only `read_file`
|
||||
- **Regex:** `"write_.*|replace"` matches `write_file`, `replace`
|
||||
- **Wildcard:** `"*"` or `""` matches all tools
|
||||
|
||||
**Session event matchers:**
|
||||
|
||||
- **SessionStart:** `startup`, `resume`, `clear`
|
||||
- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit`
|
||||
- **PreCompress:** `manual`, `auto`
|
||||
- **Notification:** `ToolPermission`
|
||||
|
||||
## Hook input/output contract
|
||||
|
||||
### Command hook communication
|
||||
|
||||
Hooks communicate via:
|
||||
|
||||
- **Input:** JSON on stdin
|
||||
- **Output:** Exit code + stdout/stderr
|
||||
|
||||
### Exit codes
|
||||
|
||||
- **0:** Success - stdout shown to user (or injected as context for some events)
|
||||
- **2:** Blocking error - stderr shown to agent/user, operation may be blocked
|
||||
- **Other:** Non-blocking warning - logged but execution continues
|
||||
|
||||
### Common input fields
|
||||
|
||||
Every hook receives these base fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "abc123",
|
||||
"transcript_path": "/path/to/transcript.jsonl",
|
||||
"cwd": "/path/to/project",
|
||||
"hook_event_name": "BeforeTool",
|
||||
"timestamp": "2025-12-01T10:30:00Z"
|
||||
// ... event-specific fields
|
||||
}
|
||||
```
|
||||
|
||||
### Event-specific fields
|
||||
|
||||
#### BeforeTool
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "write_file",
|
||||
"tool_input": {
|
||||
"file_path": "/path/to/file.ts",
|
||||
"content": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output (JSON on stdout):**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny|ask|block",
|
||||
"reason": "Explanation shown to agent",
|
||||
"systemMessage": "Message shown to user"
|
||||
}
|
||||
```
|
||||
|
||||
Or simple exit codes:
|
||||
|
||||
- Exit 0 = allow (stdout shown to user)
|
||||
- Exit 2 = deny (stderr shown to agent)
|
||||
|
||||
#### AfterTool
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "read_file",
|
||||
"tool_input": { "file_path": "..." },
|
||||
"tool_response": "file contents..."
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "AfterTool",
|
||||
"additionalContext": "Extra context for agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeAgent
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "Fix the authentication bug"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeAgent",
|
||||
"additionalContext": "Recent project decisions: ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeModel
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [{ "role": "user", "content": "Hello" }],
|
||||
"config": { "temperature": 0.7 },
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO",
|
||||
"allowedFunctionNames": ["read_file", "write_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeModel",
|
||||
"llm_request": {
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Additional instructions..." },
|
||||
{ "role": "user", "content": "Hello" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### AfterModel
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [
|
||||
/* ... */
|
||||
],
|
||||
"config": {
|
||||
/* ... */
|
||||
},
|
||||
"toolConfig": {
|
||||
/* ... */
|
||||
}
|
||||
},
|
||||
"llm_response": {
|
||||
"text": "string",
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": ["array of content parts"]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "AfterModel",
|
||||
"llm_response": {
|
||||
"candidate": {
|
||||
/* modified response */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeToolSelection
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [
|
||||
/* ... */
|
||||
],
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO",
|
||||
"allowedFunctionNames": [
|
||||
/* 100+ tools */
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeToolSelection",
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": ["read_file", "write_file", "replace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or simple output (comma-separated tool names sets mode to ANY):
|
||||
|
||||
```bash
|
||||
echo "read_file,write_file,replace"
|
||||
```
|
||||
|
||||
#### SessionStart
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "startup|resume|clear"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "Loaded 5 project memories"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### SessionEnd
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "exit|clear|logout|prompt_input_exit|other"
|
||||
}
|
||||
```
|
||||
|
||||
No structured output expected (but stdout/stderr logged).
|
||||
|
||||
#### PreCompress
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": "manual|auto"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemMessage": "Compression starting..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Notification
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "ToolPermission",
|
||||
"message": "string",
|
||||
"details": {
|
||||
/* notification details */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemMessage": "Notification logged"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Hook definitions are configured in `settings.json` files using the `hooks`
|
||||
object. Configuration can be specified at multiple levels with defined
|
||||
precedence rules.
|
||||
|
||||
### Configuration layers
|
||||
|
||||
Hook configurations are applied in the following order of execution (lower
|
||||
numbers run first):
|
||||
|
||||
1. **Project settings:** `.gemini/settings.json` in your project directory
|
||||
(highest priority)
|
||||
2. **User settings:** `~/.gemini/settings.json`
|
||||
3. **System settings:** `/etc/gemini-cli/settings.json`
|
||||
4. **Extensions:** Internal hooks defined by installed extensions (lowest
|
||||
priority). See [Extensions documentation](../extensions/index.md#hooks) for
|
||||
details on how extensions define and configure hooks.
|
||||
|
||||
#### Deduplication and shadowing
|
||||
|
||||
If multiple hooks with the identical **name** and **command** are discovered
|
||||
across different configuration layers, Gemini CLI deduplicates them. The hook
|
||||
from the higher-priority layer (e.g., Project) will be kept, and others will be
|
||||
ignored.
|
||||
|
||||
Within each level, hooks run in the order they are declared in the
|
||||
configuration.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"EventName": [
|
||||
{
|
||||
"matcher": "pattern",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "security-check",
|
||||
"name": "hook-identifier",
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh",
|
||||
"timeout": 5000
|
||||
"command": "./path/to/script.sh",
|
||||
"description": "What this hook does",
|
||||
"timeout": 30000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -134,45 +498,226 @@ multiple layers in the following order of precedence (highest to lowest):
|
||||
}
|
||||
```
|
||||
|
||||
#### Hook configuration fields
|
||||
**Configuration properties:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------ | :----- | :-------- | :------------------------------------------------------------------- |
|
||||
| `type` | string | **Yes** | The execution engine. Currently only `"command"` is supported. |
|
||||
| `command` | string | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). |
|
||||
| `name` | string | No | A friendly name for identifying the hook in logs and CLI commands. |
|
||||
| `timeout` | number | No | Execution timeout in milliseconds (default: 60000). |
|
||||
| `description` | string | No | A brief explanation of the hook's purpose. |
|
||||
|
||||
---
|
||||
- **`name`** (string, recommended): Unique identifier for the hook used in
|
||||
`/hooks enable/disable` commands. If omitted, the `command` path is used as
|
||||
the identifier.
|
||||
- **`type`** (string, required): Hook type - currently only `"command"` is
|
||||
supported
|
||||
- **`command`** (string, required): Path to the script or command to execute
|
||||
- **`description`** (string, optional): Human-readable description shown in
|
||||
`/hooks panel`
|
||||
- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000)
|
||||
- **`matcher`** (string, optional): Pattern to filter when hook runs (event
|
||||
matchers only)
|
||||
|
||||
### Environment variables
|
||||
|
||||
Hooks are executed with a sanitized environment.
|
||||
Hooks have access to:
|
||||
|
||||
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
|
||||
- `GEMINI_SESSION_ID`: The unique ID for the current session.
|
||||
- `GEMINI_CWD`: The current working directory.
|
||||
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
|
||||
|
||||
## Security and risks
|
||||
|
||||
> **Warning: Hooks execute arbitrary code with your user privileges.** By
|
||||
> configuring hooks, you are allowing scripts to run shell commands on your
|
||||
> machine.
|
||||
|
||||
**Project-level hooks** are particularly risky when opening untrusted projects.
|
||||
Gemini CLI **fingerprints** project hooks. If a hook's name or command changes
|
||||
(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will
|
||||
be warned before it executes.
|
||||
|
||||
See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely)
|
||||
for a detailed threat model.
|
||||
- `GEMINI_PROJECT_DIR`: Project root directory
|
||||
- `GEMINI_SESSION_ID`: Current session ID
|
||||
- `GEMINI_API_KEY`: Gemini API key (if configured)
|
||||
- All other environment variables from the parent process
|
||||
|
||||
## Managing hooks
|
||||
|
||||
Use the CLI commands to manage hooks without editing JSON manually:
|
||||
### View registered hooks
|
||||
|
||||
- **View hooks:** `/hooks panel`
|
||||
- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all`
|
||||
- **Toggle individual:** `/hooks enable <name>` or `/hooks disable <name>`
|
||||
Use the `/hooks panel` command to view all registered hooks:
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
```
|
||||
|
||||
This command displays:
|
||||
|
||||
- All configured hooks organized by event
|
||||
- Hook source (user, project, system)
|
||||
- Hook type (command or plugin)
|
||||
- Individual hook status (enabled/disabled)
|
||||
|
||||
### Enable and disable all hooks at once
|
||||
|
||||
You can enable or disable all hooks at once using commands:
|
||||
|
||||
```bash
|
||||
/hooks enable-all
|
||||
/hooks disable-all
|
||||
```
|
||||
|
||||
These commands provide a shortcut to enable or disable all configured hooks
|
||||
without managing them individually. The `enable-all` command removes all hooks
|
||||
from the `hooks.disabled` array, while `disable-all` adds all configured hooks
|
||||
to the disabled list. Changes take effect immediately without requiring a
|
||||
restart.
|
||||
|
||||
### Enable and disable individual hooks
|
||||
|
||||
You can enable or disable individual hooks using commands:
|
||||
|
||||
```bash
|
||||
/hooks enable hook-name
|
||||
/hooks disable hook-name
|
||||
```
|
||||
|
||||
These commands allow you to control hook execution without editing configuration
|
||||
files. The hook name should match the `name` field in your hook configuration.
|
||||
Changes made via these commands are persisted to your settings. The settings are
|
||||
saved to workspace scope if available, otherwise to your global user settings
|
||||
(`~/.gemini/settings.json`).
|
||||
|
||||
### Disabled hooks configuration
|
||||
|
||||
To permanently disable hooks, add them to the `hooks.disabled` array in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["secret-scanner", "auto-test"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks
|
||||
from all configuration levels (user, project, system) are combined and
|
||||
deduplicated, meaning a hook disabled at any level remains disabled.
|
||||
|
||||
## Migration from Claude Code
|
||||
|
||||
If you have hooks configured for Claude Code, you can migrate them:
|
||||
|
||||
```bash
|
||||
gemini hooks migrate --from-claude
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- Reads `.claude/settings.json`
|
||||
- Converts event names (`PreToolUse` → `BeforeTool`, etc.)
|
||||
- Translates tool names (`Bash` → `run_shell_command`, `replace` → `replace`)
|
||||
- Updates matcher patterns
|
||||
- Writes to `.gemini/settings.json`
|
||||
|
||||
### Event name mapping
|
||||
|
||||
| Claude Code | Gemini CLI |
|
||||
| ------------------ | -------------- |
|
||||
| `PreToolUse` | `BeforeTool` |
|
||||
| `PostToolUse` | `AfterTool` |
|
||||
| `UserPromptSubmit` | `BeforeAgent` |
|
||||
| `Stop` | `AfterAgent` |
|
||||
| `Notification` | `Notification` |
|
||||
| `SessionStart` | `SessionStart` |
|
||||
| `SessionEnd` | `SessionEnd` |
|
||||
| `PreCompact` | `PreCompress` |
|
||||
|
||||
### Tool name mapping
|
||||
|
||||
| Claude Code | Gemini CLI |
|
||||
| ----------- | --------------------- |
|
||||
| `Bash` | `run_shell_command` |
|
||||
| `Edit` | `replace` |
|
||||
| `Read` | `read_file` |
|
||||
| `Write` | `write_file` |
|
||||
| `Glob` | `glob` |
|
||||
| `Grep` | `search_file_content` |
|
||||
| `LS` | `list_directory` |
|
||||
|
||||
## Tool and Event Matchers Reference
|
||||
|
||||
### Available tool names for matchers
|
||||
|
||||
The following built-in tools can be used in `BeforeTool` and `AfterTool` hook
|
||||
matchers:
|
||||
|
||||
#### File operations
|
||||
|
||||
- `read_file` - Read a single file
|
||||
- `read_many_files` - Read multiple files at once
|
||||
- `write_file` - Create or overwrite a file
|
||||
- `replace` - Edit file content with find/replace
|
||||
|
||||
#### File system
|
||||
|
||||
- `list_directory` - List directory contents
|
||||
- `glob` - Find files matching a pattern
|
||||
- `search_file_content` - Search within file contents
|
||||
|
||||
#### Execution
|
||||
|
||||
- `run_shell_command` - Execute shell commands
|
||||
|
||||
#### Web and external
|
||||
|
||||
- `google_web_search` - Google Search with grounding
|
||||
- `web_fetch` - Fetch web page content
|
||||
|
||||
#### Agent features
|
||||
|
||||
- `write_todos` - Manage TODO items
|
||||
- `save_memory` - Save information to memory
|
||||
- `delegate_to_agent` - Delegate tasks to sub-agents
|
||||
|
||||
#### Example matchers
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "write_file|replace" // File editing tools
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "read_.*" // All read operations
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "run_shell_command" // Only shell commands
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "*" // All tools
|
||||
}
|
||||
```
|
||||
|
||||
### Event-specific matchers
|
||||
|
||||
#### SessionStart event matchers
|
||||
|
||||
- `startup` - Fresh session start
|
||||
- `resume` - Resuming a previous session
|
||||
- `clear` - Session cleared
|
||||
|
||||
#### SessionEnd event matchers
|
||||
|
||||
- `exit` - Normal exit
|
||||
- `clear` - Session cleared
|
||||
- `logout` - User logged out
|
||||
- `prompt_input_exit` - Exit from prompt input
|
||||
- `other` - Other reasons
|
||||
|
||||
#### PreCompress event matchers
|
||||
|
||||
- `manual` - Manually triggered compression
|
||||
- `auto` - Automatically triggered compression
|
||||
|
||||
#### Notification event matchers
|
||||
|
||||
- `ToolPermission` - Tool permission notifications
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example
|
||||
- [Best Practices](best-practices.md) - Security, performance, and debugging
|
||||
- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt
|
||||
shortcuts
|
||||
- [Configuration](../get-started/configuration.md) - Gemini CLI configuration
|
||||
options
|
||||
- [Hooks Design Document](../hooks-design.md) - Technical architecture details
|
||||
|
||||
+135
-279
@@ -1,322 +1,178 @@
|
||||
# Hooks reference
|
||||
# Hooks Reference
|
||||
|
||||
This document provides the technical specification for Gemini CLI hooks,
|
||||
including JSON schemas and API details.
|
||||
including the JSON schemas for input and output, exit code behaviors, and the
|
||||
stable model API.
|
||||
|
||||
## Global hook mechanics
|
||||
## Communication Protocol
|
||||
|
||||
- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and
|
||||
`stderr` for logs and feedback.
|
||||
- **Exit codes**:
|
||||
- `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.**
|
||||
- `2`: System Block. The action is blocked; `stderr` is used as the rejection
|
||||
reason.
|
||||
- `Other`: Warning. A non-fatal failure occurred; the CLI continues with a
|
||||
warning.
|
||||
- **Silence is Mandatory**: Your script **must not** print any plain text to
|
||||
`stdout` other than the final JSON.
|
||||
Hooks communicate with Gemini CLI via standard streams and exit codes:
|
||||
|
||||
- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`.
|
||||
- **Output**: The hook sends a JSON object (or plain text) to `stdout`.
|
||||
- **Exit Codes**: Used to signal success or blocking errors.
|
||||
|
||||
### Exit Code Behavior
|
||||
|
||||
| Exit Code | Meaning | Behavior |
|
||||
| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- |
|
||||
| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. |
|
||||
| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. |
|
||||
| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. |
|
||||
|
||||
---
|
||||
|
||||
## Configuration schema
|
||||
## Input Schema (`stdin`)
|
||||
|
||||
Hooks are defined in `settings.json` within the `hooks` object. Each event
|
||||
(e.g., `BeforeTool`) contains an array of **hook definitions**.
|
||||
Every hook receives a base JSON object. Extra fields are added depending on the
|
||||
specific event.
|
||||
|
||||
### Hook definition
|
||||
### Base Fields (All Events)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----------- | :-------- | :------- | :-------------------------------------------------------------------------------------- |
|
||||
| `matcher` | `string` | No | A regex (for tools) or exact string (for lifecycle) to filter when the hook runs. |
|
||||
| `sequential` | `boolean` | No | If `true`, hooks in this group run one after another. If `false`, they run in parallel. |
|
||||
| `hooks` | `array` | **Yes** | An array of **hook configurations**. |
|
||||
| Field | Type | Description |
|
||||
| :---------------- | :------- | :---------------------------------------------------- |
|
||||
| `session_id` | `string` | Unique identifier for the current CLI session. |
|
||||
| `transcript_path` | `string` | Path to the session's JSON transcript (if available). |
|
||||
| `cwd` | `string` | The current working directory. |
|
||||
| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). |
|
||||
| `timestamp` | `string` | ISO 8601 timestamp of the event. |
|
||||
|
||||
### Hook configuration
|
||||
### Event-Specific Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------ | :------- | :-------- | :------------------------------------------------------------------- |
|
||||
| `type` | `string` | **Yes** | The execution engine. Currently only `"command"` is supported. |
|
||||
| `command` | `string` | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). |
|
||||
| `name` | `string` | No | A friendly name for identifying the hook in logs and CLI commands. |
|
||||
| `timeout` | `number` | No | Execution timeout in milliseconds (default: 60000). |
|
||||
| `description` | `string` | No | A brief explanation of the hook's purpose. |
|
||||
#### Tool Events (`BeforeTool`, `AfterTool`)
|
||||
|
||||
- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`,
|
||||
`run_shell_command`).
|
||||
- `tool_input`: (`object`) The arguments passed to the tool.
|
||||
- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool
|
||||
execution.
|
||||
- `mcp_context`: (`object`, **optional**) Present only for MCP tool invocations.
|
||||
Contains server identity information:
|
||||
- `server_name`: (`string`) The configured name of the MCP server.
|
||||
- `tool_name`: (`string`) The original tool name from the MCP server.
|
||||
- `command`: (`string`, optional) For stdio transport, the command used to
|
||||
start the server.
|
||||
- `args`: (`string[]`, optional) For stdio transport, the command arguments.
|
||||
- `cwd`: (`string`, optional) For stdio transport, the working directory.
|
||||
- `url`: (`string`, optional) For SSE/HTTP transport, the server URL.
|
||||
- `tcp`: (`string`, optional) For WebSocket transport, the TCP address.
|
||||
|
||||
#### Agent Events (`BeforeAgent`, `AfterAgent`)
|
||||
|
||||
- `prompt`: (`string`) The user's submitted prompt.
|
||||
- `prompt_response`: (`string`, **AfterAgent only**) The final response text
|
||||
from the model.
|
||||
- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook
|
||||
is already handling a continuation.
|
||||
|
||||
#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`)
|
||||
|
||||
- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request.
|
||||
See [Stable Model API](#stable-model-api).
|
||||
- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation
|
||||
of the incoming response.
|
||||
|
||||
#### Session & Notification Events
|
||||
|
||||
- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger
|
||||
source.
|
||||
- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`,
|
||||
**SessionEnd only**) The reason for session end.
|
||||
- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the
|
||||
compression event.
|
||||
- `notification_type`: (`ToolPermission`, **Notification only**) The type of
|
||||
notification being fired.
|
||||
- `message`: (`string`, **Notification only**) The notification message.
|
||||
- `details`: (`object`, **Notification only**) Payload-specific details for the
|
||||
notification.
|
||||
|
||||
---
|
||||
|
||||
## Base input schema
|
||||
## Output Schema (`stdout`)
|
||||
|
||||
All hooks receive these common fields via `stdin`:
|
||||
If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
|
||||
|
||||
```typescript
|
||||
{
|
||||
"session_id": string, // Unique ID for the current session
|
||||
"transcript_path": string, // Absolute path to session transcript JSON
|
||||
"cwd": string, // Current working directory
|
||||
"hook_event_name": string, // The firing event (e.g. "BeforeTool")
|
||||
"timestamp": string // ISO 8601 execution time
|
||||
}
|
||||
```
|
||||
### Common Output Fields
|
||||
|
||||
---
|
||||
| Field | Type | Description |
|
||||
| :------------------- | :-------- | :------------------------------------------------------------------------------------- |
|
||||
| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. |
|
||||
| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. |
|
||||
| `systemMessage` | `string` | Message displayed in Gemini CLI terminal to provide warning or context to the **user** |
|
||||
| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. |
|
||||
| `stopReason` | `string` | Message shown to the user when `continue` is `false`. |
|
||||
| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. |
|
||||
| `hookSpecificOutput` | `object` | Container for event-specific data (see below). |
|
||||
|
||||
## Common output fields
|
||||
### `hookSpecificOutput` Reference
|
||||
|
||||
Most hooks support these fields in their `stdout` JSON:
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--------------- | :-------- | :----------------------------------------------------------------------------- |
|
||||
| `systemMessage` | `string` | Displayed immediately to the user in the terminal. |
|
||||
| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. |
|
||||
| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. |
|
||||
| `stopReason` | `string` | Displayed to the user when `continue` is `false`. |
|
||||
| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. |
|
||||
| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. |
|
||||
|
||||
---
|
||||
|
||||
## Tool hooks
|
||||
|
||||
### Matchers and tool names
|
||||
|
||||
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
|
||||
compared against the name of the tool being executed.
|
||||
|
||||
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
|
||||
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
|
||||
of available tool names.
|
||||
- **MCP Tools**: Tools from MCP servers follow the naming pattern
|
||||
`mcp__<server_name>__<tool_name>`.
|
||||
- **Regex Support**: Matchers support regular expressions (e.g.,
|
||||
`matcher: "read_.*"` matches all file reading tools).
|
||||
|
||||
### `BeforeTool`
|
||||
|
||||
Fires before a tool is invoked. Used for argument validation, security checks,
|
||||
and parameter rewriting.
|
||||
|
||||
- **Input Fields**:
|
||||
- `tool_name`: (`string`) The name of the tool being called.
|
||||
- `tool_input`: (`object`) The raw arguments generated by the model.
|
||||
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
|
||||
executing.
|
||||
- `reason`: Required if denied. This text is sent **to the agent** as a tool
|
||||
error, allowing it to respond or retry.
|
||||
- `hookSpecificOutput.tool_input`: An object that **merges with and
|
||||
overrides** the model's arguments before execution.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
|
||||
`reason` sent to the agent. **The turn continues.**
|
||||
|
||||
### `AfterTool`
|
||||
|
||||
Fires after a tool executes. Used for result auditing, context injection, or
|
||||
hiding sensitive output from the agent.
|
||||
|
||||
- **Input Fields**:
|
||||
- `tool_name`: (`string`)
|
||||
- `tool_input`: (`object`) The original arguments.
|
||||
- `tool_response`: (`object`) The result containing `llmContent`,
|
||||
`returnDisplay`, and optional `error`.
|
||||
- `mcp_context`: (`object`)
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
|
||||
- `reason`: Required if denied. This text **replaces** the tool result sent
|
||||
back to the model.
|
||||
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||
tool result for the agent.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
|
||||
replacement content sent to the agent. **The turn continues.**
|
||||
|
||||
---
|
||||
|
||||
## Agent hooks
|
||||
|
||||
### `BeforeAgent`
|
||||
|
||||
Fires after a user submits a prompt, but before the agent begins planning. Used
|
||||
for prompt validation or injecting dynamic context.
|
||||
|
||||
- **Input Fields**:
|
||||
- `prompt`: (`string`) The original text submitted by the user.
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||
prompt for this turn only.
|
||||
- `decision`: Set to `"deny"` to block the turn and **discard the user's
|
||||
message** (it will not appear in history).
|
||||
- `continue`: Set to `false` to block the turn but **save the message to
|
||||
history**.
|
||||
- `reason`: Required if denied or stopped.
|
||||
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
|
||||
context. Same as `decision: "deny"`.
|
||||
|
||||
### `AfterAgent`
|
||||
|
||||
Fires once per turn after the model generates its final response. Primary use
|
||||
case is response validation and automatic retries.
|
||||
|
||||
- **Input Fields**:
|
||||
- `prompt`: (`string`) The user's original request.
|
||||
- `prompt_response`: (`string`) The final text generated by the agent.
|
||||
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
|
||||
part of a retry sequence.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
|
||||
- `reason`: Required if denied. This text is sent **to the agent as a new
|
||||
prompt** to request a correction.
|
||||
- `continue`: Set to `false` to **stop the session** without retrying.
|
||||
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||
preserving UI display.
|
||||
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
|
||||
turn using `stderr` as the feedback prompt.
|
||||
|
||||
---
|
||||
|
||||
## Model hooks
|
||||
|
||||
### `BeforeModel`
|
||||
|
||||
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
|
||||
request format.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
|
||||
(generation params).
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
|
||||
outgoing request (e.g., changing models or temperature).
|
||||
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
|
||||
provided, the CLI skips the LLM call entirely and uses this as the response.
|
||||
- `decision`: Set to `"deny"` to block the request and abort the turn.
|
||||
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
|
||||
`stderr` as the error message.
|
||||
|
||||
### `BeforeToolSelection`
|
||||
|
||||
Fires before the LLM decides which tools to call. Used to filter the available
|
||||
toolset or force specific tool modes.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) Same format as `BeforeModel`.
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
|
||||
- `"NONE"`: Disables all tools (Wins over other hooks).
|
||||
- `"ANY"`: Forces at least one tool call.
|
||||
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
|
||||
of tool names.
|
||||
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
|
||||
- **Limitations**: Does **not** support `decision`, `continue`, or
|
||||
`systemMessage`.
|
||||
|
||||
### `AfterModel`
|
||||
|
||||
Fires immediately after an LLM response chunk is received. Used for real-time
|
||||
redaction or PII filtering.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) The original request.
|
||||
- `llm_response`: (`object`) The model's response (or a single chunk during
|
||||
streaming).
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
|
||||
response chunk.
|
||||
- `decision`: Set to `"deny"` to discard the response chunk and block the
|
||||
turn.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Note on Streaming**: Fired for **every chunk** generated by the model.
|
||||
Modifying the response only affects the current chunk.
|
||||
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
|
||||
output. Uses `stderr` as the error message.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle & system hooks
|
||||
|
||||
### `SessionStart`
|
||||
|
||||
Fires on application startup, resuming a session, or after a `/clear` command.
|
||||
Used for loading initial context.
|
||||
|
||||
- **Input fields**:
|
||||
- `source`: (`"startup" | "resume" | "clear"`)
|
||||
- **Relevant output fields**:
|
||||
- `hookSpecificOutput.additionalContext`: (`string`)
|
||||
- **Interactive**: Injected as the first turn in history.
|
||||
- **Non-interactive**: Prepended to the user's prompt.
|
||||
- `systemMessage`: Shown at the start of the session.
|
||||
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
|
||||
is never blocked.
|
||||
|
||||
### `SessionEnd`
|
||||
|
||||
Fires when the CLI exits or a session is cleared. Used for cleanup or final
|
||||
telemetry.
|
||||
|
||||
- **Input Fields**:
|
||||
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed to the user during shutdown.
|
||||
- **Best Effort**: The CLI **will not wait** for this hook to complete and
|
||||
ignores all flow-control fields (`continue`, `decision`).
|
||||
|
||||
### `Notification`
|
||||
|
||||
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
|
||||
external logging or cross-platform alerts.
|
||||
|
||||
- **Input Fields**:
|
||||
- `notification_type`: (`"ToolPermission"`)
|
||||
- `message`: Summary of the alert.
|
||||
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
|
||||
path).
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed alongside the system alert.
|
||||
- **Observability Only**: This hook **cannot** block alerts or grant permissions
|
||||
automatically. Flow-control fields are ignored.
|
||||
|
||||
### `PreCompress`
|
||||
|
||||
Fires before the CLI summarizes history to save tokens. Used for logging or
|
||||
state saving.
|
||||
|
||||
- **Input Fields**:
|
||||
- `trigger`: (`"auto" | "manual"`)
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed to the user before compression.
|
||||
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
|
||||
compression process. Flow-control fields are ignored.
|
||||
| Field | Supported Events | Description |
|
||||
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
|
||||
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
|
||||
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
|
||||
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
|
||||
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
|
||||
|
||||
---
|
||||
|
||||
## Stable Model API
|
||||
|
||||
Gemini CLI uses these structures to ensure hooks don't break across SDK updates.
|
||||
Gemini CLI uses a decoupled format for model interactions to ensure hooks remain
|
||||
stable even if the underlying Gemini SDK changes.
|
||||
|
||||
**LLMRequest**:
|
||||
### `LLMRequest` Object
|
||||
|
||||
Used in `BeforeModel` and `BeforeToolSelection`.
|
||||
|
||||
> 💡 **Note**: In v1, model hooks are primarily text-focused. Non-text parts
|
||||
> (like images or function calls) provided in the `content` array will be
|
||||
> simplified to their string representation by the translator.
|
||||
|
||||
```typescript
|
||||
{
|
||||
"model": string,
|
||||
"messages": Array<{
|
||||
"role": "user" | "model" | "system",
|
||||
"content": string // Non-text parts are filtered out for hooks
|
||||
"content": string | Array<{ "type": string, [key: string]: any }>
|
||||
}>,
|
||||
"config": { "temperature": number, ... },
|
||||
"toolConfig": { "mode": string, "allowedFunctionNames": string[] }
|
||||
"config"?: {
|
||||
"temperature"?: number,
|
||||
"maxOutputTokens"?: number,
|
||||
"topP"?: number,
|
||||
"topK"?: number
|
||||
},
|
||||
"toolConfig"?: {
|
||||
"mode"?: "AUTO" | "ANY" | "NONE",
|
||||
"allowedFunctionNames"?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**LLMResponse**:
|
||||
### `LLMResponse` Object
|
||||
|
||||
Used in `AfterModel` and as a synthetic response in `BeforeModel`.
|
||||
|
||||
```typescript
|
||||
{
|
||||
"text"?: string,
|
||||
"candidates": Array<{
|
||||
"content": { "role": "model", "parts": string[] },
|
||||
"finishReason": string
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": string[]
|
||||
},
|
||||
"finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER",
|
||||
"index"?: number,
|
||||
"safetyRatings"?: Array<{
|
||||
"category": string,
|
||||
"probability": string,
|
||||
"blocked"?: boolean
|
||||
}>
|
||||
}>,
|
||||
"usageMetadata": { "totalTokenCount": number }
|
||||
"usageMetadata"?: {
|
||||
"promptTokenCount"?: number,
|
||||
"candidatesTokenCount"?: number,
|
||||
"totalTokenCount"?: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+826
-232
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -102,10 +102,10 @@ This documentation is organized into the following sections:
|
||||
|
||||
- **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI
|
||||
with new functionality.
|
||||
- **[Writing extensions](./extensions/writing-extensions.md):** Learn how to
|
||||
build your own extension.
|
||||
- **[Extension releasing](./extensions/releasing.md):** How to release Gemini
|
||||
CLI extensions.
|
||||
- **[Get Started with extensions](./extensions/getting-started-extensions.md):**
|
||||
Learn how to build your own extension.
|
||||
- **[Extension releasing](./extensions/extension-releasing.md):** How to release
|
||||
Gemini CLI extensions.
|
||||
|
||||
### Hooks
|
||||
|
||||
|
||||
+4
-12
@@ -192,20 +192,12 @@
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "Writing extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
"label": "Get started with extensions",
|
||||
"slug": "docs/extensions/getting-started-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Extensions reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
},
|
||||
{
|
||||
"label": "Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Extensions releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
"label": "Extension releasing",
|
||||
"slug": "docs/extensions/extension-releasing"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -733,7 +733,7 @@ The MCP integration tracks several states:
|
||||
|
||||
## Important notes
|
||||
|
||||
### Security considerations
|
||||
### Security sonsiderations
|
||||
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
@@ -1038,29 +1038,6 @@ gemini mcp remove my-server
|
||||
This will find and delete the "my-server" entry from the `mcpServers` object in
|
||||
the appropriate `settings.json` file based on the scope (`-s, --scope`).
|
||||
|
||||
### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`)
|
||||
|
||||
Temporarily disable an MCP server without removing its configuration, or
|
||||
re-enable a previously disabled server.
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
gemini mcp enable <name> [--session]
|
||||
gemini mcp disable <name> [--session]
|
||||
```
|
||||
|
||||
**Options (flags):**
|
||||
|
||||
- `--session`: Apply change only for this session (not persisted to file).
|
||||
|
||||
Disabled servers appear in `/mcp` status as "Disabled" but won't connect or
|
||||
provide tools. Enablement state is stored in
|
||||
`~/.gemini/mcp-server-enablement.json`.
|
||||
|
||||
The same commands are available as slash commands during an active session:
|
||||
`/mcp enable <name>` and `/mcp disable <name>`.
|
||||
|
||||
## Instructions
|
||||
|
||||
Gemini CLI supports
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ let esbuild;
|
||||
try {
|
||||
esbuild = (await import('esbuild')).default;
|
||||
} catch (_error) {
|
||||
console.error('esbuild not available - cannot build bundle');
|
||||
process.exit(1);
|
||||
console.warn('esbuild not available, skipping bundle step');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('generalist_agent', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -25,7 +25,14 @@ describe('generalist_agent', () => {
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
const foundToolCall = await rig.waitForToolCall(
|
||||
'delegate_to_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
|
||||
+12
-1
@@ -47,7 +47,18 @@ describe('subagent eval test cases', () => {
|
||||
'README.md': 'TODO: update the README.',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
await rig.expectToolCallSuccess(['docs-agent']);
|
||||
await rig.expectToolCallSuccess(
|
||||
['delegate_to_agent'],
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'docs-agent';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
// Minimal 1x1 PNG image base64
|
||||
const DUMMY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
describe('Linux Clipboard Integration', () => {
|
||||
let rig: TestRig;
|
||||
let dummyImagePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
// Create a dummy image file for testing
|
||||
dummyImagePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-test-clipboard-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
try {
|
||||
if (fs.existsSync(dummyImagePath)) {
|
||||
fs.unlinkSync(dummyImagePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
// Only run this test on Linux
|
||||
const runIfLinux = os.platform() === 'linux' ? it : it.skip;
|
||||
|
||||
runIfLinux(
|
||||
'should paste image from system clipboard when Ctrl+V is pressed',
|
||||
async () => {
|
||||
// 1. Setup rig
|
||||
await rig.setup('linux-clipboard-paste');
|
||||
|
||||
// 2. Inject image into system clipboard
|
||||
// We attempt both Wayland and X11 tools.
|
||||
let clipboardSet = false;
|
||||
|
||||
// Try wl-copy (Wayland)
|
||||
let sessionType = '';
|
||||
const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], {
|
||||
input: fs.readFileSync(dummyImagePath),
|
||||
});
|
||||
if (wlCopy.status === 0) {
|
||||
clipboardSet = true;
|
||||
sessionType = 'wayland';
|
||||
} else {
|
||||
// Try xclip (X11)
|
||||
try {
|
||||
execSync(
|
||||
`xclip -selection clipboard -t image/png -i "${dummyImagePath}"`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
clipboardSet = true;
|
||||
sessionType = 'x11';
|
||||
} catch {
|
||||
// Both failed
|
||||
}
|
||||
}
|
||||
|
||||
if (!clipboardSet) {
|
||||
console.warn(
|
||||
'Skipping test: Could not access system clipboard (wl-copy or xclip required)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Launch CLI and simulate Ctrl+V
|
||||
// We send the control character \u0016 (SYN) which corresponds to Ctrl+V
|
||||
// Note: The CLI must be running and accepting input.
|
||||
// The TestRig usually sends args/stdin and waits for exit or output.
|
||||
// To properly test "interactive" pasting, we need the rig to support sending input *while* running.
|
||||
// Assuming rig.run with 'stdin' sends it immediately.
|
||||
// The CLI treats stdin as typed input if it's interactive.
|
||||
|
||||
// We append a small delay or a newline to ensure processing?
|
||||
// Ctrl+V (\u0016) followed by a newline (\r) to submit?
|
||||
// Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output).
|
||||
// If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path.
|
||||
|
||||
const result = await rig.run({
|
||||
stdin: '\u0016\r', // Ctrl+V then Enter
|
||||
env: { XDG_SESSION_TYPE: sessionType },
|
||||
});
|
||||
|
||||
// 4. Verify Output
|
||||
// Expect the CLI to have processed the image and echoed back the path (or the prompt containing it)
|
||||
// The output usually contains the user's input echoed back + model response.
|
||||
// The pasted image path should look like @.../clipboard-....png
|
||||
expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -155,84 +155,6 @@ describe('Hooks Agent Flow', () => {
|
||||
// The fake response contains "Hello World"
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('should process clearContext in AfterAgent hook output', async () => {
|
||||
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-agent.responses',
|
||||
),
|
||||
});
|
||||
|
||||
// BeforeModel hook to track message counts across LLM calls
|
||||
const messageCountFile = join(rig.testDir!, 'message-counts.json');
|
||||
const beforeModelScript = `
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
||||
const messageCount = input.llm_request?.contents?.length || 0;
|
||||
let counts = [];
|
||||
try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {}
|
||||
counts.push(messageCount);
|
||||
fs.writeFileSync('${messageCountFile}', JSON.stringify(counts));
|
||||
console.log(JSON.stringify({ decision: 'allow' }));
|
||||
`;
|
||||
const beforeModelScriptPath = join(
|
||||
rig.testDir!,
|
||||
'before_model_counter.cjs',
|
||||
);
|
||||
writeFileSync(beforeModelScriptPath, beforeModelScript);
|
||||
|
||||
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${beforeModelScriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
AfterAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rig.run({ args: 'Hello test' });
|
||||
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const afterAgentLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_event_name === 'AfterAgent',
|
||||
);
|
||||
|
||||
expect(afterAgentLog).toBeDefined();
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('clearContext');
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('true');
|
||||
expect(result).toContain('Security policy triggered');
|
||||
|
||||
// Verify context was cleared: second call should not have more messages than first
|
||||
const countsRaw = rig.readFile('message-counts.json');
|
||||
const counts = JSON.parse(countsRaw) as number[];
|
||||
expect(counts.length).toBeGreaterThanOrEqual(2);
|
||||
expect(counts[1]).toBeLessThanOrEqual(counts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-step Loops', () => {
|
||||
|
||||
Generated
+1024
-246
File diff suppressed because it is too large
Load Diff
@@ -26,14 +26,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agent/executor.js', () => ({
|
||||
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
|
||||
@@ -97,7 +97,6 @@ export async function loadConfig(
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
ptyInfo: 'auto',
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
|
||||
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
const DEMO_QUESTIONS: Question[] = [
|
||||
{
|
||||
question: 'What type of project are you building?',
|
||||
header: 'Project Type',
|
||||
options: [
|
||||
{ label: 'Web Application', description: 'React, Next.js, or similar' },
|
||||
{ label: 'CLI Tool', description: 'Command-line interface with Node.js' },
|
||||
{ label: 'Library', description: 'NPM package or shared utility' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which features should be enabled?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Add static typing' },
|
||||
{ label: 'ESLint', description: 'Add linting and formatting' },
|
||||
{ label: 'Unit Tests', description: 'Add Vitest setup' },
|
||||
{ label: 'CI/CD', description: 'Add GitHub Actions' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
{
|
||||
question: 'What is the project name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'my-awesome-project',
|
||||
},
|
||||
{
|
||||
question: 'Initialize git repository?',
|
||||
header: 'Git',
|
||||
type: QuestionType.YESNO,
|
||||
},
|
||||
];
|
||||
|
||||
const Demo = () => {
|
||||
const [result, setResult] = useState<null | { [key: string]: string }>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color="red">
|
||||
Dialog was cancelled. Project initialization aborted.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
>
|
||||
<Text bold color="green">
|
||||
Success! Project Configuration:
|
||||
</Text>
|
||||
{DEMO_QUESTIONS.map((q, i) => (
|
||||
<Box key={i} marginTop={1}>
|
||||
<Text color="gray">{q.header}: </Text>
|
||||
<Text>{result[i] || '(not answered)'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeypressProvider>
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text bold marginBottom={1}>
|
||||
AskUserDialog Demo
|
||||
</Text>
|
||||
<AskUserDialog
|
||||
questions={DEMO_QUESTIONS}
|
||||
onSubmit={setResult}
|
||||
onCancel={() => setCancelled(true)}
|
||||
/>
|
||||
</Box>
|
||||
</KeypressProvider>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Demo />);
|
||||
@@ -40,7 +40,7 @@
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^8.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
@@ -73,6 +73,7 @@
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -511,5 +511,8 @@ describe('migrate command', () => {
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +244,9 @@ export async function handleMigrateFromClaude() {
|
||||
debugLogger.log(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
debugLogger.log(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('mcp command', () => {
|
||||
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(5);
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify that the specific subcommands are registered
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
@@ -70,8 +70,6 @@ describe('mcp command', () => {
|
||||
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
||||
expect(commandNames).toContain('remove <name>');
|
||||
expect(commandNames).toContain('list');
|
||||
expect(commandNames).toContain('enable <name>');
|
||||
expect(commandNames).toContain('disable <name>');
|
||||
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||
1,
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { CommandModule, Argv } from 'yargs';
|
||||
import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
import { enableCommand, disableCommand } from './mcp/enableDisable.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
@@ -25,8 +24,6 @@ export const mcpCommand: CommandModule = {
|
||||
.command(defer(addCommand, 'mcp'))
|
||||
.command(defer(removeCommand, 'mcp'))
|
||||
.command(defer(listCommand, 'mcp'))
|
||||
.command(defer(enableCommand, 'mcp'))
|
||||
.command(defer(disableCommand, 'mcp'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getMcpServersFromConfig } from './list.js';
|
||||
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const RED = '\x1b[31m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
interface Args {
|
||||
name: string;
|
||||
session?: boolean;
|
||||
}
|
||||
|
||||
async function handleEnable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Check settings blocks
|
||||
const settings = loadSettings();
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Use 'gemini extensions enable ${server.extension.name}' to manage this extension.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canLoadServer(name, {
|
||||
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
|
||||
allowedList: settings.merged.mcp?.allowed,
|
||||
excludedList: settings.merged.mcp?.excluded,
|
||||
});
|
||||
|
||||
if (
|
||||
!result.allowed &&
|
||||
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
|
||||
) {
|
||||
debugLogger.log(`${RED}Error:${RESET} ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.clearSessionDisable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`);
|
||||
} else {
|
||||
await manager.enable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`);
|
||||
}
|
||||
|
||||
if (result.blockType === 'admin') {
|
||||
debugLogger.log(
|
||||
`${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Use 'gemini extensions disable ${server.extension.name}' to manage this extension.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.disableForSession(name);
|
||||
debugLogger.log(
|
||||
`${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`,
|
||||
);
|
||||
} else {
|
||||
await manager.disable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule<object, Args> = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Clear session-only disable',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
export const disableCommand: CommandModule<object, Args> = {
|
||||
command: 'disable <name>',
|
||||
describe: 'Disable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Disable for current session only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
|
||||
@@ -371,21 +371,6 @@ describe('parseArguments', () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should include a startup message when converting positional query to interactive prompt', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'hello'];
|
||||
|
||||
try {
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
expect(argv.startupMessages).toContain(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
} finally {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1968,7 +1953,7 @@ describe('loadCliConfig interactive', () => {
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be interactive if positional prompt words are provided with other flags', async () => {
|
||||
it('should not be interactive if positional prompt words are provided with other flags', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1977,10 +1962,10 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
it('should not be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -1996,13 +1981,13 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
// Verify the question is preserved for one-shot execution
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
expect(argv.promptInteractive).toBe('Hello world');
|
||||
expect(argv.prompt).toBe('Hello world');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be interactive if positional prompt words are provided with extensions flag', async () => {
|
||||
it('should not be interactive if positional prompt words are provided with extensions flag', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', '-e', 'none', 'hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -2011,9 +1996,8 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(argv.query).toBe('hello');
|
||||
expect(argv.promptInteractive).toBe('hello');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2026,9 +2010,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
expect(argv.prompt).toBe('hello world how are you');
|
||||
});
|
||||
|
||||
it('should handle multiple positional words with flags', async () => {
|
||||
@@ -2051,9 +2035,8 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(argv.query).toBe('write a function to sort array');
|
||||
expect(argv.promptInteractive).toBe('write a function to sort array');
|
||||
expect(argv.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
@@ -2089,9 +2072,8 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2233,19 +2215,6 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: {
|
||||
// @ts-expect-error: testing invalid value
|
||||
approvalMode: 'yolo',
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -2323,67 +2292,6 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Persistent approvalMode setting', () => {
|
||||
it('should use approvalMode from settings when no CLI flags are set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(
|
||||
ServerConfig.ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize --approval-mode flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'default' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(
|
||||
ServerConfig.ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize --yolo flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
experimental: { plan: false },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
await expect(
|
||||
loadCliConfig(settings, 'test-session', argv),
|
||||
).rejects.toThrow(
|
||||
'Approval mode "plan" is only available when experimental.plan is enabled.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig fileFiltering', () => {
|
||||
@@ -2800,9 +2708,9 @@ describe('PolicyEngine nonInteractive wiring', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set nonInteractive to true when -p flag is used', async () => {
|
||||
it('should set nonInteractive to true in one-shot mode', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', '-p', 'echo hello'];
|
||||
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -48,12 +47,12 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
|
||||
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
@@ -84,7 +83,6 @@ export interface CliArgs {
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
@@ -94,12 +92,11 @@ export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const startupMessages: string[] = [];
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('gemini')
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
)
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
@@ -111,7 +108,7 @@ export async function parseArguments(
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
description:
|
||||
'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.',
|
||||
'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.',
|
||||
})
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
@@ -123,8 +120,7 @@ export async function parseArguments(
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description:
|
||||
'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).',
|
||||
description: 'Prompt. Appended to input on stdin (if any).',
|
||||
})
|
||||
.option('prompt-interactive', {
|
||||
alias: 'i',
|
||||
@@ -345,12 +341,11 @@ export async function parseArguments(
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
// Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands)
|
||||
if (q && !result['prompt']) {
|
||||
if (process.stdin.isTTY) {
|
||||
startupMessages.push(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
const hasExplicitInteractive =
|
||||
result['promptInteractive'] === '' || !!result['promptInteractive'];
|
||||
if (hasExplicitInteractive) {
|
||||
result['promptInteractive'] = q;
|
||||
} else {
|
||||
result['prompt'] = q;
|
||||
@@ -359,7 +354,6 @@ export async function parseArguments(
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -467,7 +461,7 @@ export async function loadCliConfig(
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
@@ -502,15 +496,9 @@ export async function loadCliConfig(
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
const rawApprovalMode =
|
||||
argv.approvalMode ||
|
||||
(argv.yolo ? 'yolo' : undefined) ||
|
||||
((settings.tools?.approvalMode as string) !== 'yolo'
|
||||
? settings.tools.approvalMode
|
||||
: undefined);
|
||||
|
||||
if (rawApprovalMode) {
|
||||
switch (rawApprovalMode) {
|
||||
if (argv.approvalMode) {
|
||||
// New --approval-mode flag takes precedence
|
||||
switch (argv.approvalMode) {
|
||||
case 'yolo':
|
||||
approvalMode = ApprovalMode.YOLO;
|
||||
break;
|
||||
@@ -530,11 +518,13 @@ export async function loadCliConfig(
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid approval mode: ${rawApprovalMode}. Valid values are: yolo, auto_edit, plan, default`,
|
||||
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
// Fallback to legacy --yolo flag behavior
|
||||
approvalMode =
|
||||
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
// Override approval mode if disableYoloMode is set.
|
||||
@@ -582,12 +572,12 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// -p/--prompt forces non-interactive (headless) mode
|
||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt && !argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -675,12 +665,6 @@ export async function loadCliConfig(
|
||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||
const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true;
|
||||
|
||||
// Create MCP enablement manager and callbacks
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpEnablementCallbacks = mcpEnabled
|
||||
? mcpEnablementManager.getEnablementCallbacks()
|
||||
: undefined;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
@@ -702,7 +686,6 @@ export async function loadCliConfig(
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
agents: settings.agents,
|
||||
@@ -776,11 +759,14 @@ export async function loadCliConfig(
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: coreEvents,
|
||||
eventEmitter: appEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
codebaseInvestigatorSettings:
|
||||
settings.experimental?.codebaseInvestigatorSettings,
|
||||
cliHelpAgentSettings: settings.experimental?.cliHelpAgentSettings,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
@@ -792,7 +778,7 @@ export async function loadCliConfig(
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
(settings.hooksConfig?.enabled ?? false),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
|
||||
@@ -68,6 +68,10 @@ import {
|
||||
ExtensionSettingScope,
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { glob } from 'glob';
|
||||
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||
import { McpPromptLoader } from '../services/McpPromptLoader.js';
|
||||
import { FileCommandLoader } from '../services/FileCommandLoader.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
|
||||
await this.checkCommandConflicts(localSourcePath, newExtensionName);
|
||||
|
||||
const previous = this.getExtensions().find(
|
||||
(installed) => installed.name === newExtensionName,
|
||||
);
|
||||
@@ -850,7 +857,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
if (scope !== SettingScope.Session) {
|
||||
const scopePath =
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : '/';
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : homedir();
|
||||
this.extensionEnablementManager.disable(name, true, scopePath);
|
||||
}
|
||||
await logExtensionDisable(
|
||||
@@ -885,7 +892,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
if (scope !== SettingScope.Session) {
|
||||
const scopePath =
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : '/';
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : homedir();
|
||||
this.extensionEnablementManager.enable(name, true, scopePath);
|
||||
}
|
||||
await logExtensionEnable(
|
||||
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
await this.maybeStartExtension(extension);
|
||||
}
|
||||
|
||||
private async checkCommandConflicts(
|
||||
localSourcePath: string,
|
||||
extensionName: string,
|
||||
): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
// 1. Get current commands
|
||||
const currentLoaders = [
|
||||
new McpPromptLoader(this.config ?? null),
|
||||
new BuiltinCommandLoader(this.config ?? null),
|
||||
new FileCommandLoader(this.config ?? null),
|
||||
];
|
||||
|
||||
const currentCommandsResults = await Promise.allSettled(
|
||||
currentLoaders.map((l) => l.loadCommands(signal)),
|
||||
);
|
||||
const currentCommandNames = new Set<string>();
|
||||
for (const result of currentCommandsResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
result.value.forEach((cmd) => {
|
||||
// If it's an update, don't count existing commands from the SAME extension as conflicts
|
||||
if (cmd.extensionName !== extensionName) {
|
||||
currentCommandNames.add(cmd.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get commands from the new/updated extension
|
||||
const extensionCommandsDir = path.join(localSourcePath, 'commands');
|
||||
if (!fs.existsSync(extensionCommandsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await glob('**/*.toml', {
|
||||
cwd: extensionCommandsDir,
|
||||
nodir: true,
|
||||
dot: true,
|
||||
follow: true,
|
||||
});
|
||||
|
||||
const conflicts: Array<{
|
||||
commandName: string;
|
||||
renamedName: string;
|
||||
}> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = file.substring(0, file.length - 5); // length of '.toml'
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
if (currentCommandNames.has(baseCommandName)) {
|
||||
conflicts.push({
|
||||
commandName: baseCommandName,
|
||||
renamedName: `${extensionName}.${baseCommandName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
const conflictList = conflicts
|
||||
.map(
|
||||
(c) =>
|
||||
` - '/${c.commandName}' (will be renamed to '/${c.renamedName}')`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const warning = `WARNING: Installing extension '${extensionName}' will cause the following command conflicts:\n${conflictList}\n\nDo you want to continue installation?`;
|
||||
|
||||
if (!(await this.requestConsent(warning))) {
|
||||
throw new Error('Installation cancelled due to command conflicts.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
|
||||
@@ -872,7 +872,6 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooksConfig.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
@@ -2032,25 +2031,6 @@ ${INSTALL_WARNING_MESSAGE}`,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable an extension globally at the user scope', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.disableExtension('ext1', SettingScope.User);
|
||||
|
||||
// Should be disabled in home dir
|
||||
expect(
|
||||
isEnabled({ name: 'ext1', enabledForPath: userExtensionsDir }),
|
||||
).toBe(false);
|
||||
// Should be disabled in some other random path (global)
|
||||
expect(isEnabled({ name: 'ext1', enabledForPath: '/tmp/other' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableExtension', () => {
|
||||
@@ -2119,29 +2099,6 @@ ${INSTALL_WARNING_MESSAGE}`,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable an extension globally at the user scope', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
await extensionManager.disableExtension('ext1', SettingScope.User);
|
||||
expect(isEnabled({ name: 'ext1', enabledForPath: '/tmp/other' })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await extensionManager.enableExtension('ext1', SettingScope.User);
|
||||
|
||||
expect(isEnabled({ name: 'ext1', enabledForPath: '/tmp/other' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isEnabled({ name: 'ext1', enabledForPath: userExtensionsDir }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export enum Command {
|
||||
REVERSE_SEARCH = 'history.search.start',
|
||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||
REWIND = 'history.rewind',
|
||||
|
||||
// Navigation
|
||||
NAVIGATION_UP = 'nav.up',
|
||||
@@ -189,7 +188,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
[Command.REWIND]: [{ key: 'double escape' }],
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
|
||||
@@ -319,7 +317,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.REVERSE_SEARCH,
|
||||
Command.SUBMIT_REVERSE_SEARCH,
|
||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||
Command.REWIND,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -416,7 +413,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
[Command.REWIND]: 'Browse and rewind previous interactions.',
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
isInSettingsList,
|
||||
type McpServerEnablementState,
|
||||
type McpServerEnablementConfig,
|
||||
type McpServerDisplayState,
|
||||
type EnablementCallbacks,
|
||||
type ServerLoadResult,
|
||||
} from './mcpServerEnablement.js';
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
isInSettingsList,
|
||||
type EnablementCallbacks,
|
||||
} from './mcpServerEnablement.js';
|
||||
|
||||
let inMemoryFs: Record<string, string> = {};
|
||||
|
||||
function createMockEnablement(
|
||||
sessionDisabled: boolean,
|
||||
fileEnabled: boolean,
|
||||
): EnablementCallbacks {
|
||||
return {
|
||||
isSessionDisabled: () => sessionDisabled,
|
||||
isFileEnabled: () => Promise.resolve(fileEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
function setupFsMocks(): void {
|
||||
vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
|
||||
const content = inMemoryFs[filePath.toString()];
|
||||
if (content === undefined) {
|
||||
const error = new Error(`ENOENT: ${filePath}`);
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => {
|
||||
inMemoryFs[filePath.toString()] = data.toString();
|
||||
});
|
||||
vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined);
|
||||
}
|
||||
|
||||
describe('McpServerEnablementManager', () => {
|
||||
let manager: McpServerEnablementManager;
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryFs = {};
|
||||
setupFsMocks();
|
||||
McpServerEnablementManager.resetInstance();
|
||||
manager = McpServerEnablementManager.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
McpServerEnablementManager.resetInstance();
|
||||
});
|
||||
|
||||
it('should enable/disable servers with persistence', async () => {
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
await manager.disable('server');
|
||||
expect(await manager.isFileEnabled('server')).toBe(false);
|
||||
await manager.enable('server');
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle session disable separately', async () => {
|
||||
manager.disableForSession('server');
|
||||
expect(manager.isSessionDisabled('server')).toBe(true);
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
expect(await manager.isEffectivelyEnabled('server')).toBe(false);
|
||||
manager.clearSessionDisable('server');
|
||||
expect(await manager.isEffectivelyEnabled('server')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
await manager.disable('PlayWright');
|
||||
expect(await manager.isFileEnabled('playwright')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct display state', async () => {
|
||||
await manager.disable('file-disabled');
|
||||
manager.disableForSession('session-disabled');
|
||||
|
||||
expect(await manager.getDisplayState('enabled')).toEqual({
|
||||
enabled: true,
|
||||
isSessionDisabled: false,
|
||||
isPersistentDisabled: false,
|
||||
});
|
||||
expect(
|
||||
(await manager.getDisplayState('file-disabled')).isPersistentDisabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await manager.getDisplayState('session-disabled')).isSessionDisabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should share session state across getInstance calls', () => {
|
||||
const instance1 = McpServerEnablementManager.getInstance();
|
||||
const instance2 = McpServerEnablementManager.getInstance();
|
||||
|
||||
instance1.disableForSession('test-server');
|
||||
|
||||
expect(instance2.isSessionDisabled('test-server')).toBe(true);
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canLoadServer', () => {
|
||||
it('blocks when admin has disabled MCP', async () => {
|
||||
const result = await canLoadServer('s', { adminMcpEnabled: false });
|
||||
expect(result.blockType).toBe('admin');
|
||||
});
|
||||
|
||||
it('blocks when server is not in allowlist', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
allowedList: ['other'],
|
||||
});
|
||||
expect(result.blockType).toBe('allowlist');
|
||||
});
|
||||
|
||||
it('blocks when server is in excludelist', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
excludedList: ['s'],
|
||||
});
|
||||
expect(result.blockType).toBe('excludelist');
|
||||
});
|
||||
|
||||
it('blocks when server is session-disabled', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
enablement: createMockEnablement(true, true),
|
||||
});
|
||||
expect(result.blockType).toBe('session');
|
||||
});
|
||||
|
||||
it('blocks when server is file-disabled', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
enablement: createMockEnablement(false, false),
|
||||
});
|
||||
expect(result.blockType).toBe('enablement');
|
||||
});
|
||||
|
||||
it('allows when admin MCP is enabled and no restrictions', async () => {
|
||||
const result = await canLoadServer('s', { adminMcpEnabled: true });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('allows when server passes all checks', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
allowedList: ['s'],
|
||||
enablement: createMockEnablement(false, true),
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper functions', () => {
|
||||
it('normalizeServerId lowercases and trims', () => {
|
||||
expect(normalizeServerId(' PlayWright ')).toBe('playwright');
|
||||
});
|
||||
|
||||
it('isInSettingsList supports ext: backward compat', () => {
|
||||
expect(isInSettingsList('playwright', ['playwright']).found).toBe(true);
|
||||
expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true);
|
||||
expect(
|
||||
isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,357 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Storage, coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Stored in JSON file - represents persistent enablement state.
|
||||
*/
|
||||
export interface McpServerEnablementState {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File config format - map of server ID to enablement state.
|
||||
*/
|
||||
export interface McpServerEnablementConfig {
|
||||
[serverId: string]: McpServerEnablementState;
|
||||
}
|
||||
|
||||
/**
|
||||
* For UI display - combines file and session state.
|
||||
*/
|
||||
export interface McpServerDisplayState {
|
||||
/** Effective state (considering session override) */
|
||||
enabled: boolean;
|
||||
/** True if disabled via --session flag */
|
||||
isSessionDisabled: boolean;
|
||||
/** True if disabled in file */
|
||||
isPersistentDisabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback types for enablement checks (passed from CLI to core).
|
||||
*/
|
||||
export interface EnablementCallbacks {
|
||||
isSessionDisabled: (serverId: string) => boolean;
|
||||
isFileEnabled: (serverId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of canLoadServer check.
|
||||
*/
|
||||
export interface ServerLoadResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a server ID to canonical lowercase form.
|
||||
*/
|
||||
export function normalizeServerId(serverId: string): string {
|
||||
return serverId.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a server ID is in a settings list (with backward compatibility).
|
||||
* Handles case-insensitive matching and plain name fallback for ext: servers.
|
||||
*/
|
||||
export function isInSettingsList(
|
||||
serverId: string,
|
||||
list: string[],
|
||||
): { found: boolean; deprecationWarning?: string } {
|
||||
const normalizedId = normalizeServerId(serverId);
|
||||
const normalizedList = list.map(normalizeServerId);
|
||||
|
||||
// Exact canonical match
|
||||
if (normalizedList.includes(normalizedId)) {
|
||||
return { found: true };
|
||||
}
|
||||
|
||||
// Backward compat: for ext: servers, check if plain name matches
|
||||
if (normalizedId.startsWith('ext:')) {
|
||||
const plainName = normalizedId.split(':').pop();
|
||||
if (plainName && normalizedList.includes(plainName)) {
|
||||
return {
|
||||
found: true,
|
||||
deprecationWarning:
|
||||
`Settings reference '${plainName}' matches extension server '${serverId}'. ` +
|
||||
`Update your settings to use the full identifier '${serverId}' instead.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for whether a server can be loaded.
|
||||
* Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers.
|
||||
*
|
||||
* Uses callbacks instead of direct enablementManager reference to keep
|
||||
* packages/core independent of packages/cli.
|
||||
*/
|
||||
export async function canLoadServer(
|
||||
serverId: string,
|
||||
config: {
|
||||
adminMcpEnabled: boolean;
|
||||
allowedList?: string[];
|
||||
excludedList?: string[];
|
||||
enablement?: EnablementCallbacks;
|
||||
},
|
||||
): Promise<ServerLoadResult> {
|
||||
const normalizedId = normalizeServerId(serverId);
|
||||
|
||||
// 1. Admin kill switch
|
||||
if (!config.adminMcpEnabled) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
'MCP servers are disabled by administrator. Check admin settings or contact your admin.',
|
||||
blockType: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Allowlist check
|
||||
if (config.allowedList && config.allowedList.length > 0) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.allowedList,
|
||||
);
|
||||
if (deprecationWarning) {
|
||||
coreEvents.emitFeedback('warning', deprecationWarning);
|
||||
}
|
||||
if (!found) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`,
|
||||
blockType: 'allowlist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Excludelist check
|
||||
if (config.excludedList) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.excludedList,
|
||||
);
|
||||
if (deprecationWarning) {
|
||||
coreEvents.emitFeedback('warning', deprecationWarning);
|
||||
}
|
||||
if (found) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`,
|
||||
blockType: 'excludelist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Session disable check (before file-based enablement)
|
||||
if (config.enablement?.isSessionDisabled(normalizedId)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`,
|
||||
blockType: 'session',
|
||||
};
|
||||
}
|
||||
|
||||
// 5. File-based enablement check
|
||||
if (
|
||||
config.enablement &&
|
||||
!(await config.enablement.isFileEnabled(normalizedId))
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`,
|
||||
blockType: 'enablement',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json';
|
||||
|
||||
/**
|
||||
* McpServerEnablementManager
|
||||
*
|
||||
* Manages the enabled/disabled state of MCP servers.
|
||||
* Uses a simplified format compared to ExtensionEnablementManager.
|
||||
* Supports both persistent (file) and session-only (in-memory) states.
|
||||
*
|
||||
* NOTE: Use getInstance() to get the singleton instance. This ensures
|
||||
* session state (sessionDisabled Set) is shared across all code paths.
|
||||
*/
|
||||
export class McpServerEnablementManager {
|
||||
private static instance: McpServerEnablementManager | null = null;
|
||||
|
||||
private readonly configFilePath: string;
|
||||
private readonly configDir: string;
|
||||
private readonly sessionDisabled = new Set<string>();
|
||||
|
||||
/**
|
||||
* Get the singleton instance.
|
||||
*/
|
||||
static getInstance(): McpServerEnablementManager {
|
||||
if (!McpServerEnablementManager.instance) {
|
||||
McpServerEnablementManager.instance = new McpServerEnablementManager();
|
||||
}
|
||||
return McpServerEnablementManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing only).
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
McpServerEnablementManager.instance = null;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.configDir = Storage.getGlobalGeminiDir();
|
||||
this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is enabled in FILE (persistent config only).
|
||||
* Does NOT include session state.
|
||||
*/
|
||||
async isFileEnabled(serverName: string): Promise<boolean> {
|
||||
const config = await this.readConfig();
|
||||
const state = config[normalizeServerId(serverName)];
|
||||
return state?.enabled ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is session-disabled.
|
||||
*/
|
||||
isSessionDisabled(serverName: string): boolean {
|
||||
return this.sessionDisabled.has(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check effective enabled state (combines file + session).
|
||||
* Convenience method; canLoadServer() uses separate callbacks for granular blockType.
|
||||
*/
|
||||
async isEffectivelyEnabled(serverName: string): Promise<boolean> {
|
||||
if (this.isSessionDisabled(serverName)) {
|
||||
return false;
|
||||
}
|
||||
return this.isFileEnabled(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a server persistently.
|
||||
* Removes the server from config file (defaults to enabled).
|
||||
*/
|
||||
async enable(serverName: string): Promise<void> {
|
||||
const normalizedName = normalizeServerId(serverName);
|
||||
const config = await this.readConfig();
|
||||
|
||||
if (normalizedName in config) {
|
||||
delete config[normalizedName];
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a server persistently.
|
||||
* Adds server to config file with enabled: false.
|
||||
*/
|
||||
async disable(serverName: string): Promise<void> {
|
||||
const config = await this.readConfig();
|
||||
config[normalizeServerId(serverName)] = { enabled: false };
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a server for current session only (in-memory).
|
||||
*/
|
||||
disableForSession(serverName: string): void {
|
||||
this.sessionDisabled.add(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session disable for a server.
|
||||
*/
|
||||
clearSessionDisable(serverName: string): void {
|
||||
this.sessionDisabled.delete(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display state for a specific server (for UI).
|
||||
*/
|
||||
async getDisplayState(serverName: string): Promise<McpServerDisplayState> {
|
||||
const isSessionDisabled = this.isSessionDisabled(serverName);
|
||||
const isPersistentDisabled = !(await this.isFileEnabled(serverName));
|
||||
|
||||
return {
|
||||
enabled: !isSessionDisabled && !isPersistentDisabled,
|
||||
isSessionDisabled,
|
||||
isPersistentDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all display states (for UI listing).
|
||||
*/
|
||||
async getAllDisplayStates(
|
||||
serverIds: string[],
|
||||
): Promise<Record<string, McpServerDisplayState>> {
|
||||
const result: Record<string, McpServerDisplayState> = {};
|
||||
for (const serverId of serverIds) {
|
||||
result[normalizeServerId(serverId)] =
|
||||
await this.getDisplayState(serverId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enablement callbacks for passing to core.
|
||||
*/
|
||||
getEnablementCallbacks(): EnablementCallbacks {
|
||||
return {
|
||||
isSessionDisabled: (id) => this.isSessionDisabled(id),
|
||||
isFileEnabled: (id) => this.isFileEnabled(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config from file asynchronously.
|
||||
*/
|
||||
private async readConfig(): Promise<McpServerEnablementConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configFilePath, 'utf-8');
|
||||
return JSON.parse(content) as McpServerEnablementConfig;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to read MCP server enablement config.',
|
||||
error,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write config to file asynchronously.
|
||||
*/
|
||||
private async writeConfig(config: McpServerEnablementConfig): Promise<void> {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -2012,56 +2012,6 @@ describe('Settings Loading and Merging', () => {
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings to agents overrides', () => {
|
||||
const userSettingsContent = {
|
||||
experimental: {
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
maxNumTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
thinkingBudget: 16384,
|
||||
model: 'gemini-1.5-pro',
|
||||
},
|
||||
cliHelpAgentSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify migration to agents.overrides
|
||||
expect(settings.user.settings.agents?.overrides).toMatchObject({
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
runConfig: {
|
||||
maxTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-1.5-pro',
|
||||
generateContentConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 16384,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cli_help: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSettings', () => {
|
||||
|
||||
@@ -804,14 +804,6 @@ export function migrateDeprecatedSettings(
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -860,100 +852,3 @@ export function saveModelChange(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateExperimentalSettings(
|
||||
settings: Settings,
|
||||
loadedSettings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
removeDeprecated: boolean,
|
||||
): boolean {
|
||||
const experimentalSettings = settings.experimental as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (experimentalSettings) {
|
||||
const agentsSettings = {
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
};
|
||||
const agentsOverrides = {
|
||||
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
if (experimentalSettings['codebaseInvestigatorSettings']) {
|
||||
const old = experimentalSettings[
|
||||
'codebaseInvestigatorSettings'
|
||||
] as Record<string, unknown>;
|
||||
const override = {
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
const runConfig = {
|
||||
...(override['runConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['maxNumTurns'] !== undefined)
|
||||
runConfig['maxTurns'] = old['maxNumTurns'];
|
||||
if (old['maxTimeMinutes'] !== undefined)
|
||||
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
|
||||
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
|
||||
|
||||
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
||||
const modelConfig = {
|
||||
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
||||
if (old['thinkingBudget'] !== undefined) {
|
||||
const generateContentConfig = {
|
||||
...(modelConfig['generateContentConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
const thinkingConfig = {
|
||||
...(generateContentConfig['thinkingConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
|
||||
generateContentConfig['thinkingConfig'] = thinkingConfig;
|
||||
modelConfig['generateContentConfig'] = generateContentConfig;
|
||||
}
|
||||
override['modelConfig'] = modelConfig;
|
||||
}
|
||||
|
||||
agentsOverrides['codebase_investigator'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
if (experimentalSettings['cliHelpAgentSettings']) {
|
||||
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const override = {
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
agentsOverrides['cli_help'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
loadedSettings.setValue(scope, 'agents', agentsSettings);
|
||||
|
||||
if (removeDeprecated) {
|
||||
const newExperimental = { ...experimentalSettings };
|
||||
delete newExperimental['codebaseInvestigatorSettings'];
|
||||
delete newExperimental['cliHelpAgentSettings'];
|
||||
loadedSettings.setValue(scope, 'experimental', newExperimental);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
@@ -1023,24 +1024,6 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
@@ -1446,7 +1429,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1478,6 +1461,66 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable Agent Skills (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
codebaseInvestigatorSettings: {
|
||||
type: 'object',
|
||||
label: 'Codebase Investigator Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Configuration for Codebase Investigator.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Codebase Investigator',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable the Codebase Investigator agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxNumTurns: {
|
||||
type: 'number',
|
||||
label: 'Codebase Investigator Max Num Turns',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 10,
|
||||
description:
|
||||
'Maximum number of turns for the Codebase Investigator agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxTimeMinutes: {
|
||||
type: 'number',
|
||||
label: 'Max Time (Minutes)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 3,
|
||||
description:
|
||||
'Maximum time for the Codebase Investigator agent (in minutes).',
|
||||
showInDialog: false,
|
||||
},
|
||||
thinkingBudget: {
|
||||
type: 'number',
|
||||
label: 'Thinking Budget',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 8192,
|
||||
description:
|
||||
'The thinking budget for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
label: 'Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: GEMINI_MODEL_ALIAS_AUTO,
|
||||
description:
|
||||
'The model to use for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
label: 'Use OSC 52 Paste',
|
||||
@@ -1488,6 +1531,26 @@ const SETTINGS_SCHEMA = {
|
||||
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
|
||||
showInDialog: true,
|
||||
},
|
||||
cliHelpAgentSettings: {
|
||||
type: 'object',
|
||||
label: 'CLI Help Agent Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Configuration for CLI Help Agent.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable CLI Help Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable the CLI Help Agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
type: 'boolean',
|
||||
label: 'Plan',
|
||||
@@ -1583,7 +1646,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
default: false,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
|
||||
@@ -155,12 +155,8 @@ describe('Settings Repro', () => {
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
enableSubagents: false,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
},
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -6,20 +6,18 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import {
|
||||
type Config,
|
||||
ValidationRequiredError,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
};
|
||||
});
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
AuthType: {
|
||||
OAUTH: 'oauth',
|
||||
},
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
}));
|
||||
|
||||
const AuthType = {
|
||||
OAUTH: 'oauth',
|
||||
} as const;
|
||||
|
||||
describe('auth', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -39,12 +37,10 @@ describe('auth', () => {
|
||||
it('should return null on successful auth', async () => {
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
});
|
||||
|
||||
it('should return error message on failed auth', async () => {
|
||||
@@ -52,25 +48,9 @@ describe('auth', () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
);
|
||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if refreshAuth throws ValidationRequiredError', async () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(
|
||||
new ValidationRequiredError('Validation required'),
|
||||
);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
ValidationRequiredError,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
@@ -30,11 +29,6 @@ export async function performInitialAuth(
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationRequiredError) {
|
||||
// Don't treat validation required as a fatal auth error during startup.
|
||||
// This allows the React UI to load and show the ValidationDialog.
|
||||
return null;
|
||||
}
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1135,8 +1135,6 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -326,12 +324,6 @@ export async function main() {
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
if (argv.startupMessages) {
|
||||
argv.startupMessages.forEach((msg) => {
|
||||
coreEvents.emitFeedback('info', msg);
|
||||
});
|
||||
}
|
||||
|
||||
// Check for invalid input combinations early to prevent crashes
|
||||
if (argv.promptInteractive && !process.stdin.isTTY) {
|
||||
writeToStderr(
|
||||
@@ -381,7 +373,6 @@ export async function main() {
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
let initialAuthFailed = false;
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
try {
|
||||
if (
|
||||
@@ -408,19 +399,9 @@ export async function main() {
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationCancelledError) {
|
||||
// User cancelled verification, exit immediately.
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
// If validation is required, we don't treat it as a fatal failure.
|
||||
// We allow the app to start, and the React-based ValidationDialog
|
||||
// will handle it.
|
||||
if (!(err instanceof ValidationRequiredError)) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
initialAuthFailed = true;
|
||||
}
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,10 +427,6 @@ export async function main() {
|
||||
// another way to decouple refreshAuth from requiring a config.
|
||||
|
||||
if (sandboxConfig) {
|
||||
if (initialAuthFailed) {
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
}
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY) {
|
||||
stdinData = await readStdin();
|
||||
@@ -687,8 +664,9 @@ export async function main() {
|
||||
const additionalContext = result.getAdditionalContext();
|
||||
if (additionalContext) {
|
||||
// Prepend context to input (System Context -> Stdin -> Question)
|
||||
const wrappedContext = `<hook_context>${additionalContext}</hook_context>`;
|
||||
input = input ? `${wrappedContext}\n\n${input}` : wrappedContext;
|
||||
input = input
|
||||
? `${additionalContext}\n\n${input}`
|
||||
: additionalContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,7 +728,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
|
||||
@@ -85,7 +85,6 @@ vi.mock('./services/CommandService.js', () => ({
|
||||
|
||||
vi.mock('./services/FileCommandLoader.js');
|
||||
vi.mock('./services/McpPromptLoader.js');
|
||||
vi.mock('./services/BuiltinCommandLoader.js');
|
||||
|
||||
describe('runNonInteractive', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -1185,9 +1184,7 @@ describe('runNonInteractive', () => {
|
||||
'./services/FileCommandLoader.js'
|
||||
);
|
||||
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
|
||||
const { BuiltinCommandLoader } = await import(
|
||||
'./services/BuiltinCommandLoader.js'
|
||||
);
|
||||
|
||||
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||
@@ -1212,17 +1209,13 @@ describe('runNonInteractive', () => {
|
||||
expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
|
||||
expect(McpPromptLoader).toHaveBeenCalledTimes(1);
|
||||
expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
|
||||
expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
|
||||
|
||||
// Check that instances were passed to CommandService.create
|
||||
expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1);
|
||||
const loadersArg = mockCommandServiceCreate.mock.calls[0][0];
|
||||
expect(loadersArg).toHaveLength(3);
|
||||
expect(loadersArg[0]).toBe(
|
||||
vi.mocked(BuiltinCommandLoader).mock.instances[0],
|
||||
);
|
||||
expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
|
||||
expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
|
||||
expect(loadersArg).toHaveLength(2);
|
||||
expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
|
||||
expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
|
||||
});
|
||||
|
||||
it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CommandService } from './services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from './services/McpPromptLoader.js';
|
||||
import type { CommandContext } from './ui/commands/types.js';
|
||||
@@ -41,11 +40,7 @@ export const handleSlashCommand = async (
|
||||
}
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
[
|
||||
new BuiltinCommandLoader(config),
|
||||
new McpPromptLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
],
|
||||
[new McpPromptLoader(config), new FileCommandLoader(config)],
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
|
||||
@@ -27,7 +27,6 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
@@ -107,7 +106,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
...(this.config?.getMcpEnabled() === false
|
||||
|
||||
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CommandService } from './CommandService.js';
|
||||
import { type ICommandLoader } from './types.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||
name,
|
||||
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
|
||||
describe('CommandService', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
CommandService.clearEmittedFeedbacksForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -237,6 +238,32 @@ describe('CommandService', () => {
|
||||
expect(syncExtension?.extensionName).toBe('git-helper');
|
||||
});
|
||||
|
||||
it('should emit feedback when an extension command is renamed', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
description: '[firebase] Deploy to Firebase',
|
||||
};
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([extensionCommand]);
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining(
|
||||
"Extension command '/deploy' from 'firebase' was renamed to '/firebase.deploy'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user/project command override correctly', async () => {
|
||||
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('help', CommandKind.FILE);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
|
||||
@@ -20,6 +20,16 @@ import type { ICommandLoader } from './types.js';
|
||||
* system to be extended with new sources without modifying the service itself.
|
||||
*/
|
||||
export class CommandService {
|
||||
private static emittedFeedbacks = new Set<string>();
|
||||
|
||||
/**
|
||||
* Clears the set of emitted feedback messages.
|
||||
* This should ONLY be used in tests to ensure isolation between test cases.
|
||||
*/
|
||||
static clearEmittedFeedbacksForTest(): void {
|
||||
CommandService.emittedFeedbacks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor to enforce the use of the async factory.
|
||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||
@@ -77,6 +87,12 @@ export class CommandService {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const feedbackMsg = `Extension command '/${cmd.name}' from '${cmd.extensionName}' was renamed to '/${renamedName}' due to a conflict with an existing command.`;
|
||||
if (!CommandService.emittedFeedbacks.has(feedbackMsg)) {
|
||||
coreEvents.emitFeedback('info', feedbackMsg);
|
||||
CommandService.emittedFeedbacks.add(feedbackMsg);
|
||||
}
|
||||
|
||||
finalName = renamedName;
|
||||
}
|
||||
|
||||
|
||||
@@ -1337,69 +1337,4 @@ describe('FileCommandLoader', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sanitization', () => {
|
||||
it('sanitizes command names from filenames containing control characters', async () => {
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test\twith\nnewlines.toml': 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
// Non-alphanumeric characters (except - and .) become underscores
|
||||
expect(commands[0].name).toBe('test_with_newlines');
|
||||
});
|
||||
|
||||
it('truncates excessively long filenames', async () => {
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
const longName = 'a'.repeat(60) + '.toml';
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
[longName]: 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name.length).toBe(50);
|
||||
expect(commands[0].name).toBe('a'.repeat(47) + '...');
|
||||
});
|
||||
|
||||
it('sanitizes descriptions containing newlines and ANSI codes', async () => {
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml':
|
||||
'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
// Newlines and tabs become spaces, ANSI is stripped
|
||||
expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text');
|
||||
});
|
||||
|
||||
it('truncates long descriptions', async () => {
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
const longDesc = 'd'.repeat(150);
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`,
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].description.length).toBe(100);
|
||||
expect(commands[0].description).toBe('d'.repeat(97) + '...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
||||
import { sanitizeForListDisplay } from '../ui/utils/textUtils.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
path: string;
|
||||
@@ -45,7 +44,7 @@ interface CommandDirectory {
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
*/
|
||||
const TomlCommandDefSchema = z.object({
|
||||
export const TomlCommandDefSchema = z.object({
|
||||
prompt: z.string({
|
||||
required_error: "The 'prompt' field is required.",
|
||||
invalid_type_error: "The 'prompt' field must be a string.",
|
||||
@@ -231,25 +230,15 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
);
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
// Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores.
|
||||
// Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts.
|
||||
.map((segment) => {
|
||||
let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_');
|
||||
|
||||
// Truncate excessively long segments to prevent UI overflow
|
||||
if (sanitized.length > 50) {
|
||||
sanitized = sanitized.substring(0, 47) + '...';
|
||||
}
|
||||
return sanitized;
|
||||
})
|
||||
// Sanitize each path segment to prevent ambiguity. Since ':' is our
|
||||
// namespace separator, we replace any literal colons in filenames
|
||||
// with underscores to avoid naming conflicts.
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
// Add extension name tag for extension commands
|
||||
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
||||
let description = validDef.description || defaultDescription;
|
||||
|
||||
description = sanitizeForListDisplay(description, 100);
|
||||
|
||||
if (extensionName) {
|
||||
description = `[${extensionName}] ${description}`;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,6 @@ export const createMockCommandContext = (
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
setExtensionsUpdateState: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* A fake implementation of PersistentState for testing.
|
||||
* It keeps state in memory and provides spies for get and set.
|
||||
*/
|
||||
export class FakePersistentState {
|
||||
private data: Record<string, unknown> = {};
|
||||
|
||||
get = vi.fn().mockImplementation((key: string) => this.data[key]);
|
||||
|
||||
set = vi.fn().mockImplementation((key: string, value: unknown) => {
|
||||
this.data[key] = value;
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to reset the fake state between tests.
|
||||
*/
|
||||
reset() {
|
||||
this.data = {};
|
||||
this.get.mockClear();
|
||||
this.set.mockClear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to clear mock call history without wiping data.
|
||||
*/
|
||||
mockClear() {
|
||||
this.get.mockClear();
|
||||
this.set.mockClear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set initial data for the fake.
|
||||
*/
|
||||
setData(data: Record<string, unknown>) {
|
||||
this.data = { ...data };
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { act, useState } from 'react';
|
||||
import os from 'node:os';
|
||||
import { LoadedSettings, type Settings } from '../config/settings.js';
|
||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
@@ -28,15 +27,7 @@ import {
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
vi.mock('../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
export const render = (
|
||||
@@ -93,27 +84,21 @@ export const simulateClick = async (
|
||||
});
|
||||
};
|
||||
|
||||
let mockConfigInternal: Config | undefined;
|
||||
|
||||
const getMockConfigInternal = (): Config => {
|
||||
if (!mockConfigInternal) {
|
||||
mockConfigInternal = makeFakeConfig({
|
||||
targetDir: os.tmpdir(),
|
||||
enableEventDrivenScheduler: true,
|
||||
});
|
||||
}
|
||||
return mockConfigInternal;
|
||||
const mockConfig = {
|
||||
getModel: () => 'gemini-pro',
|
||||
getTargetDir: () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
getDebugMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getEnableInteractiveShell: () => true,
|
||||
getPreviewFeatures: () => false,
|
||||
};
|
||||
|
||||
const configProxy = new Proxy({} as Config, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getTargetDir') {
|
||||
return () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
|
||||
}
|
||||
const internal = getMockConfigInternal();
|
||||
if (prop in internal) {
|
||||
return internal[prop as keyof typeof internal];
|
||||
const configProxy = new Proxy(mockConfig, {
|
||||
get(target, prop) {
|
||||
if (prop in target) {
|
||||
return target[prop as keyof typeof target];
|
||||
}
|
||||
throw new Error(`mockConfig does not have property ${String(prop)}`);
|
||||
},
|
||||
@@ -154,11 +139,6 @@ const baseMockUiState = {
|
||||
terminalBackgroundColor: undefined,
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
version: '1.2.3',
|
||||
startupWarnings: [],
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
@@ -171,8 +151,6 @@ const mockUIActions: UIActions = {
|
||||
exitPrivacyNotice: vi.fn(),
|
||||
closeSettingsDialog: vi.fn(),
|
||||
closeModelDialog: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
openPermissionsDialog: vi.fn(),
|
||||
openSessionBrowser: vi.fn(),
|
||||
closeSessionBrowser: vi.fn(),
|
||||
@@ -198,7 +176,6 @@ const mockUIActions: UIActions = {
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
@@ -212,8 +189,6 @@ export const renderWithProviders = (
|
||||
config = configProxy as unknown as Config,
|
||||
useAlternateBuffer = true,
|
||||
uiActions,
|
||||
persistentState,
|
||||
appState = mockAppState,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
@@ -223,11 +198,6 @@ export const renderWithProviders = (
|
||||
config?: Config;
|
||||
useAlternateBuffer?: boolean;
|
||||
uiActions?: Partial<UIActions>;
|
||||
persistentState?: {
|
||||
get?: typeof persistentStateMock.get;
|
||||
set?: typeof persistentStateMock.set;
|
||||
};
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
|
||||
const baseState: UIState = new Proxy(
|
||||
@@ -248,15 +218,6 @@ export const renderWithProviders = (
|
||||
},
|
||||
) as UIState;
|
||||
|
||||
if (persistentState?.get) {
|
||||
persistentStateMock.get.mockImplementation(persistentState.get);
|
||||
}
|
||||
if (persistentState?.set) {
|
||||
persistentStateMock.set.mockImplementation(persistentState.set);
|
||||
}
|
||||
|
||||
persistentStateMock.mockClear();
|
||||
|
||||
const terminalWidth = width ?? baseState.terminalWidth;
|
||||
let finalSettings = settings;
|
||||
if (useAlternateBuffer !== undefined) {
|
||||
@@ -284,41 +245,36 @@ export const renderWithProviders = (
|
||||
.flatMap((item) => item.tools);
|
||||
|
||||
const renderResult = render(
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<StreamingContext.Provider value={finalUiState.streamingState}>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>,
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<StreamingContext.Provider value={finalUiState.streamingState}>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
terminalWidth,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { renderWithProviders } from '../test-utils/render.js';
|
||||
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { App } from './App.js';
|
||||
import { type UIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingState, ToolCallStatus } from './types.js';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { render } from '../test-utils/render.js';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { App } from './App.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingState } from './types.js';
|
||||
import { ConfigContext } from './contexts/ConfigContext.js';
|
||||
import { AppContext, type AppState } from './contexts/AppContext.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
@@ -51,20 +53,12 @@ vi.mock('./components/Footer.js', () => ({
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
const mockUIState: Partial<UIState> = {
|
||||
streamingState: StreamingState.Idle,
|
||||
quittingMessages: null,
|
||||
dialogsVisible: false,
|
||||
mainControlsRef: {
|
||||
current: null,
|
||||
} as unknown as React.MutableRefObject<DOMElement | null>,
|
||||
rootUiRef: {
|
||||
current: null,
|
||||
} as unknown as React.MutableRefObject<DOMElement | null>,
|
||||
mainControlsRef: { current: null },
|
||||
rootUiRef: { current: null },
|
||||
historyManager: {
|
||||
addItem: vi.fn(),
|
||||
history: [],
|
||||
@@ -74,18 +68,49 @@ describe('App', () => {
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
pendingGeminiHistoryItems: [],
|
||||
bannerData: {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
const mockSettingsFile: SettingsFile = {
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
path: '/mock/path',
|
||||
};
|
||||
|
||||
const mockLoadedSettings = new LoadedSettings(
|
||||
mockSettingsFile,
|
||||
mockSettingsFile,
|
||||
mockSettingsFile,
|
||||
mockSettingsFile,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
|
||||
const mockAppState: AppState = {
|
||||
version: '1.0.0',
|
||||
startupWarnings: [],
|
||||
};
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
|
||||
render(
|
||||
<AppContext.Provider value={mockAppState}>
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<SettingsContext.Provider value={mockLoadedSettings}>
|
||||
<UIStateContext.Provider value={state}>
|
||||
{ui}
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>,
|
||||
);
|
||||
|
||||
it('should render main content and composer when not quitting', () => {
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
useAlternateBuffer: false,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
@@ -98,10 +123,7 @@ describe('App', () => {
|
||||
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: quittingUIState,
|
||||
useAlternateBuffer: false,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
|
||||
|
||||
expect(lastFrame()).toContain('Quitting...');
|
||||
});
|
||||
@@ -114,13 +136,15 @@ describe('App', () => {
|
||||
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: quittingUIState,
|
||||
useAlternateBuffer: true,
|
||||
});
|
||||
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
|
||||
|
||||
expect(lastFrame()).toContain('HistoryItemDisplay');
|
||||
expect(lastFrame()).toContain('Quitting...');
|
||||
|
||||
// Reset settings
|
||||
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
|
||||
});
|
||||
|
||||
it('should render dialog manager when dialogs are visible', () => {
|
||||
@@ -129,9 +153,7 @@ describe('App', () => {
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: dialogUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
@@ -150,9 +172,7 @@ describe('App', () => {
|
||||
[stateKey]: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, uiState);
|
||||
|
||||
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
|
||||
},
|
||||
@@ -161,88 +181,37 @@ describe('App', () => {
|
||||
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
|
||||
|
||||
expect(lastFrame()).toContain(`Notifications
|
||||
Footer
|
||||
MainContent
|
||||
Composer`);
|
||||
expect(lastFrame()).toContain(
|
||||
'Notifications\nFooter\nMainContent\nComposer',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render DefaultAppLayout when screen reader is not enabled', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
|
||||
|
||||
expect(lastFrame()).toContain(`MainContent
|
||||
Notifications
|
||||
Composer`);
|
||||
});
|
||||
|
||||
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'ls',
|
||||
description: 'list directory',
|
||||
status: ToolCallStatus.Confirming,
|
||||
resultDisplay: '',
|
||||
confirmationDetails: {
|
||||
type: 'exec' as const,
|
||||
title: 'Confirm execution',
|
||||
command: 'ls',
|
||||
rootCommand: 'ls',
|
||||
rootCommands: ['ls'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const stateWithConfirmingTool = {
|
||||
...mockUIState,
|
||||
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
|
||||
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
|
||||
} as UIState;
|
||||
|
||||
const configWithExperiment = {
|
||||
...makeFakeConfig(),
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: stateWithConfirmingTool,
|
||||
config: configWithExperiment,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
||||
expect(lastFrame()).toContain('1 of 1');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders default layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders screen reader layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -251,9 +220,7 @@ Composer`);
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: dialogUIState,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import { cleanup } from 'ink-testing-library';
|
||||
import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
|
||||
import {
|
||||
type Config,
|
||||
makeFakeConfig,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock coreEvents
|
||||
@@ -1276,12 +1274,8 @@ describe('AppContainer State Management', () => {
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
lastOutputTime: 0,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
@@ -1315,136 +1309,6 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for redirected commands after 2 mins', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with redirection active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'ls > out' },
|
||||
},
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds - should still NOT be Action Required
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWritesMid = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain(
|
||||
'✋ Action Required',
|
||||
);
|
||||
|
||||
// Fast-forward to 2 minutes (120000ms)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
|
||||
const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain(
|
||||
'⏲ Working…',
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for silent non-redirected commands after 1 min', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with NO output since operation started (silent)
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||
call[0].includes('\x1b]0;'),
|
||||
);
|
||||
const lastTitle = titleWrites[titleWrites.length - 1][0];
|
||||
// Should show Working… (⏲) instead of Action Required (✋)
|
||||
expect(lastTitle).toContain('⏲ Working…');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT show Action Required in title if shell is streaming output', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
@@ -1463,7 +1327,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = startTime + 1000;
|
||||
let lastOutputTime = 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -1489,7 +1353,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = startTime + 21000;
|
||||
lastOutputTime = 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -2148,77 +2012,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Configuration Dialog Integration', () => {
|
||||
it('should initialize with dialog closed and no agent selected', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
|
||||
expect(capturedUIState.selectedAgentName).toBeUndefined();
|
||||
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
|
||||
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should update state when openAgentConfigDialog is called', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const agentDefinition = { name: 'test-agent' };
|
||||
act(() => {
|
||||
capturedUIActions.openAgentConfigDialog(
|
||||
'test-agent',
|
||||
'Test Agent',
|
||||
agentDefinition as unknown as AgentDefinition,
|
||||
);
|
||||
});
|
||||
|
||||
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
|
||||
expect(capturedUIState.selectedAgentName).toBe('test-agent');
|
||||
expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent');
|
||||
expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should clear state when closeAgentConfigDialog is called', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const agentDefinition = { name: 'test-agent' };
|
||||
act(() => {
|
||||
capturedUIActions.openAgentConfigDialog(
|
||||
'test-agent',
|
||||
'Test Agent',
|
||||
agentDefinition as unknown as AgentDefinition,
|
||||
);
|
||||
});
|
||||
|
||||
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
capturedUIActions.closeAgentConfigDialog();
|
||||
});
|
||||
|
||||
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
|
||||
expect(capturedUIState.selectedAgentName).toBeUndefined();
|
||||
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
|
||||
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreEvents Integration', () => {
|
||||
it('subscribes to UserFeedback and drains backlog on mount', async () => {
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
type UserFeedbackPayload,
|
||||
type AgentDefinition,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -63,8 +62,6 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -97,7 +94,6 @@ import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
@@ -108,7 +104,6 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
|
||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useMcpStatus } from './hooks/useMcpStatus.js';
|
||||
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
@@ -132,10 +127,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -148,16 +143,6 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function isToolAwaitingConfirmation(
|
||||
pendingHistoryItems: HistoryItemWithoutId[],
|
||||
) {
|
||||
return pendingHistoryItems
|
||||
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
||||
.some((item) =>
|
||||
item.tools.some((tool) => ToolCallStatus.Confirming === tool.status),
|
||||
);
|
||||
}
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
startupWarnings?: string[];
|
||||
@@ -220,8 +205,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
null,
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
@@ -271,34 +254,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setPermissionsDialogProps(null);
|
||||
}, []);
|
||||
|
||||
const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false);
|
||||
const [selectedAgentName, setSelectedAgentName] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [selectedAgentDefinition, setSelectedAgentDefinition] = useState<
|
||||
AgentDefinition | undefined
|
||||
>();
|
||||
|
||||
const openAgentConfigDialog = useCallback(
|
||||
(name: string, displayName: string, definition: AgentDefinition) => {
|
||||
setSelectedAgentName(name);
|
||||
setSelectedAgentDisplayName(displayName);
|
||||
setSelectedAgentDefinition(definition);
|
||||
setIsAgentConfigDialogOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeAgentConfigDialog = useCallback(() => {
|
||||
setIsAgentConfigDialogOpen(false);
|
||||
setSelectedAgentName(undefined);
|
||||
setSelectedAgentDisplayName(undefined);
|
||||
setSelectedAgentDefinition(undefined);
|
||||
}, []);
|
||||
|
||||
const toggleDebugProfiler = useCallback(
|
||||
() => setShowDebugProfiler((prev) => !prev),
|
||||
[],
|
||||
@@ -363,9 +318,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (additionalContext && geminiClient) {
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: `<hook_context>${additionalContext}</hook_context>` },
|
||||
],
|
||||
parts: [{ text: additionalContext }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -418,20 +371,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setAdminSettingsChanged(true);
|
||||
};
|
||||
|
||||
const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => {
|
||||
setNewAgents(payload.agents);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.off(
|
||||
CoreEvent.AdminSettingsChanged,
|
||||
handleAdminSettingsChanged,
|
||||
);
|
||||
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -538,7 +485,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
onAuthError,
|
||||
apiKeyDefaultValue,
|
||||
reloadApiKey,
|
||||
} = useAuthCommand(settings, config, initializationResult.authError);
|
||||
} = useAuthCommand(settings, config);
|
||||
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
|
||||
{},
|
||||
);
|
||||
@@ -560,7 +507,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
historyManager,
|
||||
userTier,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: () => setAuthState(AuthState.Updating),
|
||||
});
|
||||
|
||||
// Derive auth state variables for backward compatibility with UIStateContext
|
||||
@@ -570,7 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
|
||||
const { loadHistoryForResume, isResuming } = useSessionResume({
|
||||
const { loadHistoryForResume } = useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
@@ -610,9 +556,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
await config.refreshAuth(authType);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
if (e instanceof ChangeAuthRequestedError) {
|
||||
return;
|
||||
}
|
||||
onAuthError(
|
||||
`Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
@@ -735,7 +678,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
setQuittingMessages(messages);
|
||||
@@ -749,7 +691,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -758,7 +699,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openAgentConfigDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
setShowPrivacyNotice,
|
||||
@@ -767,7 +707,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -875,7 +814,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
@@ -907,17 +845,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
|
||||
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
|
||||
activePtyId,
|
||||
const isShellAwaitingFocus =
|
||||
!!activePtyId &&
|
||||
!embeddedShellFocused &&
|
||||
config.isInteractiveShellEnabled();
|
||||
const showShellActionRequired = useInactivityTimer(
|
||||
isShellAwaitingFocus,
|
||||
lastOutputTime,
|
||||
streamingState,
|
||||
pendingToolCalls,
|
||||
embeddedShellFocused,
|
||||
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
|
||||
});
|
||||
|
||||
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
|
||||
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
);
|
||||
|
||||
// Auto-accept indicator
|
||||
const showApprovalModeIndicator = useApprovalModeIndicator({
|
||||
@@ -927,8 +863,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isActive: !embeddedShellFocused,
|
||||
});
|
||||
|
||||
const { isMcpReady } = useMcpStatus(config);
|
||||
|
||||
const {
|
||||
messageQueue,
|
||||
addMessage,
|
||||
@@ -939,7 +873,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
isMcpReady,
|
||||
});
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
@@ -948,11 +881,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
...pendingSlashCommandHistoryItems,
|
||||
...pendingGeminiHistoryItems,
|
||||
];
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
return; // Don't clear - user may be composing a follow-up message
|
||||
}
|
||||
if (isToolExecuting(pendingHistoryItems)) {
|
||||
buffer.setText(''); // Clear for Ctrl+C cancellation
|
||||
buffer.setText(''); // Just clear the prompt
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -981,31 +911,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
|
||||
if (isSlash || (isIdle && isMcpReady)) {
|
||||
void submitQuery(submittedValue);
|
||||
} else {
|
||||
// Check messageQueue.length === 0 to only notify on the first queued item
|
||||
if (isIdle && !isMcpReady && messageQueue.length === 0) {
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
|
||||
);
|
||||
}
|
||||
addMessage(submittedValue);
|
||||
}
|
||||
addMessage(submittedValue);
|
||||
addInput(submittedValue); // Track input for up-arrow history
|
||||
},
|
||||
[
|
||||
addMessage,
|
||||
addInput,
|
||||
submitQuery,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
],
|
||||
[addMessage, addInput],
|
||||
);
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
@@ -1025,10 +934,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
* - Any future streaming states not explicitly allowed
|
||||
*/
|
||||
const isInputActive =
|
||||
isConfigInitialized &&
|
||||
!initError &&
|
||||
!isProcessing &&
|
||||
!isResuming &&
|
||||
!!slashCommands &&
|
||||
(streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.Responding) &&
|
||||
@@ -1328,11 +1235,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
settings.merged.ui.customWittyPhrases,
|
||||
!!activePtyId && !embeddedShellFocused,
|
||||
lastOutputTime,
|
||||
retryStatus,
|
||||
});
|
||||
);
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
@@ -1462,8 +1371,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
@@ -1479,8 +1387,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
showShellActionRequired,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
settings.merged.ui.dynamicWindowTitle,
|
||||
settings.merged.ui.hideWindowTitle,
|
||||
@@ -1564,7 +1471,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthenticating ||
|
||||
isAuthDialogOpen ||
|
||||
@@ -1574,8 +1480,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
!!newAgents;
|
||||
isAuthDialogOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1658,10 +1564,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
selectedAgentDefinition,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
slashCommands,
|
||||
@@ -1681,7 +1583,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
isTrustedFolder,
|
||||
@@ -1738,7 +1639,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1756,10 +1656,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
selectedAgentDefinition,
|
||||
isPermissionsDialogOpen,
|
||||
permissionsDialogProps,
|
||||
slashCommands,
|
||||
@@ -1779,7 +1675,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
isTrustedFolder,
|
||||
@@ -1839,7 +1734,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
settingsNonce,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1861,8 +1755,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
@@ -1891,26 +1783,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
},
|
||||
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||
const registry = config.getAgentRegistry();
|
||||
try {
|
||||
await Promise.all(
|
||||
newAgents.map((agent) => registry.acknowledgeAgent(agent)),
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to acknowledge agents:', error);
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to acknowledge agents: ${getErrorMessage(error)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
setNewAgents(null);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1924,8 +1796,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
@@ -1950,9 +1820,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setBannerVisible,
|
||||
setEmbeddedShellFocused,
|
||||
setAuthContext,
|
||||
newAgents,
|
||||
config,
|
||||
historyManager,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,44 +3,7 @@
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
Composer
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
@@ -51,87 +14,8 @@ Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
DialogManager
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
│ ls │
|
||||
│ │
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Composer
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
DialogManager"
|
||||
`;
|
||||
|
||||
@@ -74,12 +74,11 @@ describe('AuthDialog', () => {
|
||||
onAuthError: (error: string | null) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
};
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('CLOUD_SHELL', undefined as unknown as string);
|
||||
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string);
|
||||
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string);
|
||||
vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string);
|
||||
process.env = {};
|
||||
|
||||
props = {
|
||||
config: {
|
||||
@@ -101,7 +100,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('Environment Variable Effects on Auth Options', () => {
|
||||
@@ -139,9 +138,7 @@ describe('AuthDialog', () => {
|
||||
])(
|
||||
'correctly shows/hides COMPUTE_ADC options $desc',
|
||||
({ env, shouldContain, shouldNotContain }) => {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value as string);
|
||||
}
|
||||
process.env = { ...env };
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
for (const item of shouldContain) {
|
||||
@@ -181,14 +178,14 @@ describe('AuthDialog', () => {
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key');
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_API_KEY env var',
|
||||
@@ -246,7 +243,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('skips API key dialog on initial setup if env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
@@ -261,7 +258,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('skips API key dialog if env var is present but empty', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
||||
process.env['GEMINI_API_KEY'] = ''; // Empty string
|
||||
// props.settings.merged.security.auth.selectedType is undefined here
|
||||
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
@@ -291,7 +288,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
|
||||
// Simulate that the user has already authenticated once
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
@@ -34,16 +34,12 @@ export function validateAuthMethodWithSettings(
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
initialAuthError: string | null = null,
|
||||
) => {
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
const [authState, setAuthState] = useState<AuthState>(
|
||||
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<string | null>(initialAuthError);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
@@ -39,7 +39,6 @@ describe('aboutCommand', () => {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getUserTierName: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -98,7 +97,6 @@ describe('aboutCommand', () => {
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: 'test-ide',
|
||||
userEmail: 'test-email@example.com',
|
||||
tier: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,21 +156,4 @@ describe('aboutCommand', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the tier when getUserTierName returns a value', async () => {
|
||||
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
|
||||
'Enterprise Tier',
|
||||
);
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'Enterprise Tier',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,6 @@ export const aboutCommand: SlashCommand = {
|
||||
});
|
||||
const userEmail = cachedAccount ?? undefined;
|
||||
|
||||
const tier = context.services.config?.getUserTierName();
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion,
|
||||
@@ -56,7 +54,6 @@ export const aboutCommand: SlashCommand = {
|
||||
gcpProject,
|
||||
ideClient,
|
||||
userEmail,
|
||||
tier,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem);
|
||||
|
||||
@@ -218,21 +218,6 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if config is not available for enable', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
const enableCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'enable',
|
||||
);
|
||||
const result = await enableCommand!.action!(contextWithoutConfig, 'test');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable an agent successfully', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
@@ -323,137 +308,4 @@ describe('agentsCommand', () => {
|
||||
content: 'Usage: /agents disable <agent-name>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if config is not available for disable', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
const disableCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
);
|
||||
const result = await disableCommand!.action!(contextWithoutConfig, 'test');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('config sub-command', () => {
|
||||
it('should return dialog action for a valid agent', async () => {
|
||||
const mockDefinition = {
|
||||
name: 'test-agent',
|
||||
displayName: 'Test Agent',
|
||||
description: 'test desc',
|
||||
kind: 'local',
|
||||
};
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
||||
});
|
||||
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
expect(configCommand).toBeDefined();
|
||||
|
||||
const result = await configCommand!.action!(mockContext, 'test-agent');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'agentConfig',
|
||||
props: {
|
||||
name: 'test-agent',
|
||||
displayName: 'Test Agent',
|
||||
definition: mockDefinition,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use name as displayName if displayName is missing', async () => {
|
||||
const mockDefinition = {
|
||||
name: 'test-agent',
|
||||
description: 'test desc',
|
||||
kind: 'local',
|
||||
};
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
|
||||
});
|
||||
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
const result = await configCommand!.action!(mockContext, 'test-agent');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'agentConfig',
|
||||
props: {
|
||||
name: 'test-agent',
|
||||
displayName: 'test-agent', // Falls back to name
|
||||
definition: mockDefinition,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error if agent is not found', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getDiscoveredDefinition: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
const result = await configCommand!.action!(mockContext, 'non-existent');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: "Agent 'non-existent' not found.",
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage error if no agent name provided', async () => {
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
const result = await configCommand!.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /agents config <agent-name>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
const result = await configCommand!.action!(contextWithoutConfig, 'test');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide completions for discovered agents', async () => {
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getAllDiscoveredAgentNames: vi
|
||||
.fn()
|
||||
.mockReturnValue(['agent1', 'agent2', 'other']),
|
||||
});
|
||||
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
);
|
||||
expect(configCommand?.completion).toBeDefined();
|
||||
|
||||
const completions = await configCommand!.completion!(mockContext, 'age');
|
||||
expect(completions).toEqual(['agent1', 'agent2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,13 +62,7 @@ async function enableAction(
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
if (!config) return;
|
||||
|
||||
const agentName = args.trim();
|
||||
if (!agentName) {
|
||||
@@ -138,13 +132,7 @@ async function disableAction(
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
if (!config) return;
|
||||
|
||||
const agentName = args.trim();
|
||||
if (!agentName) {
|
||||
@@ -212,59 +200,6 @@ async function disableAction(
|
||||
};
|
||||
}
|
||||
|
||||
async function configAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const agentName = args.trim();
|
||||
if (!agentName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /agents config <agent-name>',
|
||||
};
|
||||
}
|
||||
|
||||
const agentRegistry = config.getAgentRegistry();
|
||||
if (!agentRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Agent registry not found.',
|
||||
};
|
||||
}
|
||||
|
||||
const definition = agentRegistry.getDiscoveredDefinition(agentName);
|
||||
if (!definition) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Agent '${agentName}' not found.`,
|
||||
};
|
||||
}
|
||||
|
||||
const displayName = definition.displayName || agentName;
|
||||
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'agentConfig',
|
||||
props: {
|
||||
name: agentName,
|
||||
displayName,
|
||||
definition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return [];
|
||||
@@ -286,15 +221,6 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) {
|
||||
return allAgents.filter((name: string) => name.startsWith(partialArg));
|
||||
}
|
||||
|
||||
function completeAllAgents(context: CommandContext, partialArg: string) {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const agentRegistry = config.getAgentRegistry();
|
||||
const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? [];
|
||||
return allAgents.filter((name: string) => name.startsWith(partialArg));
|
||||
}
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
description: 'Enable a disabled agent',
|
||||
@@ -313,15 +239,6 @@ const disableCommand: SlashCommand = {
|
||||
completion: completeAgentsToDisable,
|
||||
};
|
||||
|
||||
const configCommand: SlashCommand = {
|
||||
name: 'config',
|
||||
description: 'Configure an agent',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: configAction,
|
||||
completion: completeAllAgents,
|
||||
};
|
||||
|
||||
const agentsRefreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
description: 'Reload the agent registry',
|
||||
@@ -361,7 +278,6 @@ export const agentsCommand: SlashCommand = {
|
||||
agentsRefreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
configCommand,
|
||||
],
|
||||
action: async (context: CommandContext, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
|
||||
@@ -17,18 +17,9 @@ import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: vi.fn((p) => p),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/directoryUtils.js')>();
|
||||
@@ -51,14 +42,13 @@ describe('directoryCommand', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkspaceContext = {
|
||||
targetDir: path.resolve('/test/dir'),
|
||||
addDirectory: vi.fn(),
|
||||
addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
|
||||
getDirectories: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
path.resolve('/home/user/project1'),
|
||||
path.resolve('/home/user/project2'),
|
||||
path.normalize('/home/user/project1'),
|
||||
path.normalize('/home/user/project2'),
|
||||
]),
|
||||
} as unknown as WorkspaceContext;
|
||||
|
||||
@@ -68,7 +58,7 @@ describe('directoryCommand', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
addDirectoryContext: vi.fn(),
|
||||
}),
|
||||
getWorkingDir: () => path.resolve('/test/dir'),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}),
|
||||
@@ -101,9 +91,9 @@ describe('directoryCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `Current workspace directories:\n- ${path.resolve(
|
||||
text: `Current workspace directories:\n- ${path.normalize(
|
||||
'/home/user/project1',
|
||||
)}\n- ${path.resolve('/home/user/project2')}`,
|
||||
)}\n- ${path.normalize('/home/user/project2')}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -135,7 +125,7 @@ describe('directoryCommand', () => {
|
||||
});
|
||||
|
||||
it('should call addDirectory and show a success message for a single path', async () => {
|
||||
const newPath = path.resolve('/home/user/new-project');
|
||||
const newPath = path.normalize('/home/user/new-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
@@ -154,8 +144,8 @@ describe('directoryCommand', () => {
|
||||
});
|
||||
|
||||
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
|
||||
const newPath1 = path.resolve('/home/user/new-project1');
|
||||
const newPath2 = path.resolve('/home/user/new-project2');
|
||||
const newPath1 = path.normalize('/home/user/new-project1');
|
||||
const newPath2 = path.normalize('/home/user/new-project2');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath1, newPath2],
|
||||
failed: [],
|
||||
@@ -176,7 +166,7 @@ describe('directoryCommand', () => {
|
||||
|
||||
it('should show an error if addDirectory throws an exception', async () => {
|
||||
const error = new Error('Directory does not exist');
|
||||
const newPath = path.resolve('/home/user/invalid-project');
|
||||
const newPath = path.normalize('/home/user/invalid-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [],
|
||||
failed: [{ path: newPath, error }],
|
||||
@@ -194,7 +184,7 @@ describe('directoryCommand', () => {
|
||||
it('should add directory directly when folder trust is disabled', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
|
||||
const newPath = path.resolve('/home/user/new-project');
|
||||
const newPath = path.normalize('/home/user/new-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
@@ -208,7 +198,7 @@ describe('directoryCommand', () => {
|
||||
});
|
||||
|
||||
it('should show an info message for an already added directory', async () => {
|
||||
const existingPath = path.resolve('/home/user/project1');
|
||||
const existingPath = path.normalize('/home/user/project1');
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, existingPath);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
@@ -222,33 +212,9 @@ describe('directoryCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an info message for an already added directory specified as a relative path', async () => {
|
||||
const existingPath = path.resolve('/home/user/project1');
|
||||
const relativePath = './project1';
|
||||
const absoluteRelativePath = path.resolve(
|
||||
path.resolve('/test/dir'),
|
||||
relativePath,
|
||||
);
|
||||
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p === absoluteRelativePath) return existingPath;
|
||||
return p as string;
|
||||
});
|
||||
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, relativePath);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: `The following directories are already in the workspace:\n- ${relativePath}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a mix of successful and failed additions', async () => {
|
||||
const validPath = path.resolve('/home/user/valid-project');
|
||||
const invalidPath = path.resolve('/home/user/invalid-project');
|
||||
const validPath = path.normalize('/home/user/valid-project');
|
||||
const invalidPath = path.normalize('/home/user/invalid-project');
|
||||
const error = new Error('Directory does not exist');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [validPath],
|
||||
@@ -312,21 +278,6 @@ describe('directoryCommand', () => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
|
||||
expect(results).toEqual(['docs/, src/']);
|
||||
});
|
||||
|
||||
it('should filter out existing directories from suggestions', async () => {
|
||||
const existingPath = path.resolve(process.cwd(), 'existing');
|
||||
vi.mocked(mockWorkspaceContext.getDirectories).mockReturnValue([
|
||||
existingPath,
|
||||
]);
|
||||
vi.mocked(getDirectorySuggestions).mockResolvedValue([
|
||||
'existing/',
|
||||
'new/',
|
||||
]);
|
||||
|
||||
const results = await completion(mockContext, 'ex');
|
||||
|
||||
expect(results).toEqual(['new/']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,7 +286,10 @@ describe('directoryCommand', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(true);
|
||||
// isWorkspaceTrusted is no longer checked, so we don't need to mock it returning true
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
mockIsPathTrusted = vi.fn();
|
||||
const mockLoadedFolders = {
|
||||
isPathTrusted: mockIsPathTrusted,
|
||||
@@ -352,7 +306,7 @@ describe('directoryCommand', () => {
|
||||
it('should add a trusted directory', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(true);
|
||||
const newPath = path.resolve('/home/user/trusted-project');
|
||||
const newPath = path.normalize('/home/user/trusted-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
@@ -365,33 +319,26 @@ describe('directoryCommand', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return a custom dialog for an explicitly untrusted directory (upgrade flow)', async () => {
|
||||
it('should show an error for an untrusted directory', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(false); // DO_NOT_TRUST
|
||||
const newPath = path.resolve('/home/user/untrusted-project');
|
||||
mockIsPathTrusted.mockReturnValue(false);
|
||||
const newPath = path.normalize('/home/user/untrusted-project');
|
||||
|
||||
const result = await addCommand.action(mockContext, newPath);
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(mockWorkspaceContext.addDirectories).not.toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'custom_dialog',
|
||||
component: expect.objectContaining({
|
||||
type: expect.any(Function), // React component for MultiFolderTrustDialog
|
||||
}),
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining('explicitly untrusted'),
|
||||
}),
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error('Command did not return a result');
|
||||
}
|
||||
const component = (result as OpenCustomDialogActionReturn)
|
||||
.component as React.ReactElement<MultiFolderTrustDialogProps>;
|
||||
expect(component.props.folders.includes(newPath)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return a custom dialog for a directory with undefined trust', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(undefined);
|
||||
const newPath = path.resolve('/home/user/undefined-trust-project');
|
||||
const newPath = path.normalize('/home/user/undefined-trust-project');
|
||||
|
||||
const result = await addCommand.action(mockContext, newPath);
|
||||
|
||||
@@ -410,25 +357,6 @@ describe('directoryCommand', () => {
|
||||
.component as React.ReactElement<MultiFolderTrustDialogProps>;
|
||||
expect(component.props.folders.includes(newPath)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should prompt for directory even if workspace is untrusted', async () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
// Even if workspace is untrusted, we should still check directory trust
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
mockIsPathTrusted.mockReturnValue(undefined);
|
||||
const newPath = path.resolve('/home/user/new-project');
|
||||
|
||||
const result = await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'custom_dialog',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly expand a Windows-style home directory path', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
isFolderTrustEnabled,
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
|
||||
@@ -19,8 +20,6 @@ import {
|
||||
batchAddDirectories,
|
||||
} from '../utils/directoryUtils.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
async function finishAddingDirectories(
|
||||
config: Config,
|
||||
@@ -39,18 +38,16 @@ async function finishAddingDirectories(
|
||||
return;
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
@@ -95,38 +92,12 @@ export const directoryCommand: SlashCommand = {
|
||||
|
||||
const suggestions = await getDirectorySuggestions(trimmedLastPart);
|
||||
|
||||
// Filter out existing directories
|
||||
let filteredSuggestions = suggestions;
|
||||
if (context.services.config) {
|
||||
const workspaceContext =
|
||||
context.services.config.getWorkspaceContext();
|
||||
const existingDirs = new Set(
|
||||
workspaceContext.getDirectories().map((dir) => path.resolve(dir)),
|
||||
);
|
||||
|
||||
filteredSuggestions = suggestions.filter((s) => {
|
||||
const expanded = expandHomeDir(s);
|
||||
const absolute = path.resolve(expanded);
|
||||
|
||||
if (existingDirs.has(absolute)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
absolute.endsWith(path.sep) &&
|
||||
existingDirs.has(absolute.slice(0, -1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (parts.length > 1) {
|
||||
const prefix = parts.slice(0, -1).join(',') + ',';
|
||||
return filteredSuggestions.map((s) => prefix + leadingWhitespace + s);
|
||||
return suggestions.map((s) => prefix + leadingWhitespace + s);
|
||||
}
|
||||
|
||||
return filteredSuggestions.map((s) => leadingWhitespace + s);
|
||||
return suggestions.map((s) => leadingWhitespace + s);
|
||||
},
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
@@ -173,23 +144,12 @@ export const directoryCommand: SlashCommand = {
|
||||
const pathsToProcess: string[] = [];
|
||||
|
||||
for (const pathToAdd of pathsToAdd) {
|
||||
const trimmedPath = pathToAdd.trim();
|
||||
const expandedPath = expandHomeDir(trimmedPath);
|
||||
try {
|
||||
const absolutePath = path.resolve(
|
||||
workspaceContext.targetDir,
|
||||
expandedPath,
|
||||
);
|
||||
const resolvedPath = fs.realpathSync(absolutePath);
|
||||
if (currentWorkspaceDirs.includes(resolvedPath)) {
|
||||
alreadyAdded.push(trimmedPath);
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Path might not exist or be inaccessible.
|
||||
// We'll let batchAddDirectories handle it later.
|
||||
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||
if (currentWorkspaceDirs.includes(expandedPath)) {
|
||||
alreadyAdded.push(pathToAdd.trim());
|
||||
} else {
|
||||
pathsToProcess.push(pathToAdd.trim());
|
||||
}
|
||||
pathsToProcess.push(trimmedPath);
|
||||
}
|
||||
|
||||
if (alreadyAdded.length > 0) {
|
||||
@@ -205,36 +165,47 @@ export const directoryCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFolderTrustEnabled(settings.merged)) {
|
||||
if (
|
||||
isFolderTrustEnabled(settings.merged) &&
|
||||
isWorkspaceTrusted(settings.merged).isTrusted
|
||||
) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
const dirsToConfirm: string[] = [];
|
||||
const untrustedDirs: string[] = [];
|
||||
const undefinedTrustDirs: string[] = [];
|
||||
const trustedDirs: string[] = [];
|
||||
|
||||
for (const pathToAdd of pathsToProcess) {
|
||||
const expandedPath = path.resolve(expandHomeDir(pathToAdd.trim()));
|
||||
const expandedPath = expandHomeDir(pathToAdd.trim());
|
||||
const isTrusted = trustedFolders.isPathTrusted(expandedPath);
|
||||
// If explicitly trusted, add immediately.
|
||||
// If undefined or explicitly untrusted (DO_NOT_TRUST), prompt for confirmation.
|
||||
// This allows users to "upgrade" a DO_NOT_TRUST folder to trusted via the dialog.
|
||||
if (isTrusted === true) {
|
||||
trustedDirs.push(pathToAdd.trim());
|
||||
if (isTrusted === false) {
|
||||
untrustedDirs.push(pathToAdd.trim());
|
||||
} else if (isTrusted === undefined) {
|
||||
undefinedTrustDirs.push(pathToAdd.trim());
|
||||
} else {
|
||||
dirsToConfirm.push(pathToAdd.trim());
|
||||
trustedDirs.push(pathToAdd.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (untrustedDirs.length > 0) {
|
||||
errors.push(
|
||||
`The following directories are explicitly untrusted and cannot be added to a trusted workspace:\n- ${untrustedDirs.join(
|
||||
'\n- ',
|
||||
)}\nPlease use the permissions command to modify their trust level.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (trustedDirs.length > 0) {
|
||||
const result = batchAddDirectories(workspaceContext, trustedDirs);
|
||||
added.push(...result.added);
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
|
||||
if (dirsToConfirm.length > 0) {
|
||||
if (undefinedTrustDirs.length > 0) {
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: (
|
||||
<MultiFolderTrustDialog
|
||||
folders={dirsToConfirm}
|
||||
folders={undefinedTrustDirs}
|
||||
onComplete={context.ui.removeComponent}
|
||||
trustedDirs={added}
|
||||
errors={errors}
|
||||
|
||||
@@ -13,14 +13,10 @@ import type { CommandContext } from './types.js';
|
||||
import type { SubmitPromptActionReturn } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the 'fs' module
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('initCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
@@ -20,17 +20,9 @@ import {
|
||||
getErrorMessage,
|
||||
MCPOAuthTokenStorage,
|
||||
mcpServerRequiresOAuth,
|
||||
CoreEvent,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
normalizeServerId,
|
||||
canLoadServer,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
@@ -102,7 +94,8 @@ const authCommand: SlashCommand = {
|
||||
context.ui.addItem({ type: 'info', text: message });
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener);
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
try {
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
@@ -119,7 +112,12 @@ const authCommand: SlashCommand = {
|
||||
|
||||
const mcpServerUrl = server.httpUrl || server.url;
|
||||
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
|
||||
await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl);
|
||||
await authProvider.authenticate(
|
||||
serverName,
|
||||
oauthConfig,
|
||||
mcpServerUrl,
|
||||
appEvents,
|
||||
);
|
||||
|
||||
context.ui.addItem({
|
||||
type: 'info',
|
||||
@@ -156,7 +154,7 @@ const authCommand: SlashCommand = {
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
};
|
||||
} finally {
|
||||
coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener);
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
@@ -243,14 +241,6 @@ const listAction = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Get enablement state for all servers
|
||||
const enablementManager = McpServerEnablementManager.getInstance();
|
||||
const enablementState: HistoryItemMcpStatus['enablementState'] = {};
|
||||
for (const serverName of serverNames) {
|
||||
enablementState[serverName] =
|
||||
await enablementManager.getDisplayState(serverName);
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: mcpServers,
|
||||
@@ -273,7 +263,6 @@ const listAction = async (
|
||||
description: resource.description,
|
||||
})),
|
||||
authStatus,
|
||||
enablementState,
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
@@ -357,156 +346,6 @@ const refreshCommand: SlashCommand = {
|
||||
},
|
||||
};
|
||||
|
||||
async function handleEnableDisable(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
enable: boolean,
|
||||
): Promise<MessageActionReturn> {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const isSession = parts.includes('--session');
|
||||
const serverName = parts.filter((p) => p !== '--session')[0];
|
||||
const action = enable ? 'enable' : 'disable';
|
||||
|
||||
if (!serverName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Server name required. Usage: /mcp ${action} <server-name> [--session]`,
|
||||
};
|
||||
}
|
||||
|
||||
const name = normalizeServerId(serverName);
|
||||
|
||||
// Validate server exists
|
||||
const servers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Server '${serverName}' not found. Use /mcp list to see available servers.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Server '${serverName}' is provided by extension '${server.extension.name}'.\nUse '/extensions ${action} ${server.extension.name}' to manage this extension.`,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
|
||||
if (enable) {
|
||||
const settings = loadSettings();
|
||||
const result = await canLoadServer(name, {
|
||||
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
|
||||
allowedList: settings.merged.mcp?.allowed,
|
||||
excludedList: settings.merged.mcp?.excluded,
|
||||
});
|
||||
if (
|
||||
!result.allowed &&
|
||||
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
|
||||
) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: result.reason ?? 'Blocked by settings.',
|
||||
};
|
||||
}
|
||||
if (isSession) {
|
||||
manager.clearSessionDisable(name);
|
||||
} else {
|
||||
await manager.enable(name);
|
||||
}
|
||||
if (result.blockType === 'admin') {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'warning',
|
||||
text: 'MCP disabled by admin. Will load when enabled.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isSession) {
|
||||
manager.disableForSession(name);
|
||||
} else {
|
||||
await manager.disable(name);
|
||||
}
|
||||
}
|
||||
|
||||
const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`;
|
||||
|
||||
const mcpClientManager = config.getMcpClientManager();
|
||||
if (mcpClientManager) {
|
||||
context.ui.addItem(
|
||||
{ type: 'info', text: 'Restarting MCP servers...' },
|
||||
Date.now(),
|
||||
);
|
||||
await mcpClientManager.restart();
|
||||
}
|
||||
if (config.getGeminiClient()?.isInitialized())
|
||||
await config.getGeminiClient().setTools();
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return { type: 'message', messageType: 'info', content: msg };
|
||||
}
|
||||
|
||||
async function getEnablementCompletion(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
showEnabled: boolean,
|
||||
): Promise<string[]> {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
const servers = Object.keys(
|
||||
config.getMcpClientManager()?.getMcpServers() || {},
|
||||
);
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const results: string[] = [];
|
||||
for (const n of servers) {
|
||||
const state = await manager.getDisplayState(n);
|
||||
if (state.enabled === showEnabled && n.startsWith(partialArg)) {
|
||||
results.push(n);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
description: 'Enable a disabled MCP server',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (ctx, args) => handleEnableDisable(ctx, args, true),
|
||||
completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false),
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
description: 'Disable an MCP server',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (ctx, args) => handleEnableDisable(ctx, args, false),
|
||||
completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true),
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
description: 'Manage configured Model Context Protocol (MCP) servers',
|
||||
@@ -518,8 +357,6 @@ export const mcpCommand: SlashCommand = {
|
||||
schemaCommand,
|
||||
authCommand,
|
||||
refreshCommand,
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
};
|
||||
|
||||
@@ -109,9 +109,7 @@ describe('policiesCommand', () => {
|
||||
expect(content).toContain(
|
||||
'**DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
);
|
||||
expect(content).toContain(
|
||||
'**ALLOW** all tools (args match: `safe`) [Source: test.toml]',
|
||||
);
|
||||
expect(content).toContain('**ALLOW** all tools (args match: `safe`)');
|
||||
expect(content).toContain('**ASK_USER** all tools');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +36,7 @@ const categorizeRulesByMode = (
|
||||
const formatRule = (rule: PolicyRule, i: number) =>
|
||||
`${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` +
|
||||
(rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') +
|
||||
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') +
|
||||
(rule.source ? ` [Source: ${rule.source}]` : '');
|
||||
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '');
|
||||
|
||||
const formatSection = (title: string, rules: PolicyRule[]) =>
|
||||
`### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`;
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { rewindCommand } from './rewindCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||
import {
|
||||
type OpenCustomDialogActionReturn,
|
||||
type CommandContext,
|
||||
} from './types.js';
|
||||
import type { ReactElement } from 'react';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
const mockRewindTo = vi.fn();
|
||||
const mockRecordMessage = vi.fn();
|
||||
const mockSetHistory = vi.fn();
|
||||
const mockSendMessageStream = vi.fn();
|
||||
const mockGetChatRecordingService = vi.fn();
|
||||
const mockGetConversation = vi.fn();
|
||||
const mockRemoveComponent = vi.fn();
|
||||
const mockLoadHistory = vi.fn();
|
||||
const mockAddItem = vi.fn();
|
||||
const mockSetPendingItem = vi.fn();
|
||||
const mockResetContext = vi.fn();
|
||||
const mockSetInput = vi.fn();
|
||||
const mockRevertFileChanges = vi.fn();
|
||||
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/RewindViewer.js', () => ({
|
||||
RewindViewer: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useSessionBrowser.js', () => ({
|
||||
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
|
||||
uiHistory: [
|
||||
{ type: 'user', text: 'old user' },
|
||||
{ type: 'gemini', text: 'old gemini' },
|
||||
],
|
||||
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/rewindFileOps.js', () => ({
|
||||
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
|
||||
}));
|
||||
|
||||
interface RewindViewerProps {
|
||||
onRewind: (
|
||||
messageId: string,
|
||||
newText: string,
|
||||
outcome: RewindOutcome,
|
||||
) => Promise<void>;
|
||||
conversation: unknown;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
describe('rewindCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetConversation.mockReturnValue({
|
||||
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
mockRewindTo.mockReturnValue({
|
||||
messages: [], // Mocked rewound messages
|
||||
});
|
||||
|
||||
mockGetChatRecordingService.mockReturnValue({
|
||||
getConversation: mockGetConversation,
|
||||
rewindTo: mockRewindTo,
|
||||
recordMessage: mockRecordMessage,
|
||||
});
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () => ({
|
||||
getChatRecordingService: mockGetChatRecordingService,
|
||||
setHistory: mockSetHistory,
|
||||
sendMessageStream: mockSendMessageStream,
|
||||
}),
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContextManager: () => ({ refresh: mockResetContext }),
|
||||
getProjectRoot: mockGetProjectRoot,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
removeComponent: mockRemoveComponent,
|
||||
loadHistory: mockLoadHistory,
|
||||
addItem: mockAddItem,
|
||||
setPendingItem: mockSetPendingItem,
|
||||
},
|
||||
}) as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('should initialize successfully', async () => {
|
||||
const result = await rewindCommand.action!(mockContext, '');
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
});
|
||||
|
||||
it('should handle RewindOnly correctly', async () => {
|
||||
// 1. Run the command to get the component
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
|
||||
// Access onRewind from props
|
||||
const onRewind = component.props.onRewind;
|
||||
expect(onRewind).toBeDefined();
|
||||
|
||||
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||
expect(mockSetHistory).toHaveBeenCalled();
|
||||
expect(mockResetContext).toHaveBeenCalled();
|
||||
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({ text: 'old user', id: 1 }),
|
||||
expect.objectContaining({ text: 'old gemini', id: 2 }),
|
||||
],
|
||||
'New Prompt',
|
||||
);
|
||||
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify setInput was NOT called directly (it's handled via loadHistory now)
|
||||
expect(mockSetInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle RewindAndRevert correctly', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onRewind = component.props.onRewind;
|
||||
|
||||
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||
mockGetConversation(),
|
||||
'msg-id-123',
|
||||
);
|
||||
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
|
||||
expect(mockLoadHistory).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
'New Prompt',
|
||||
);
|
||||
});
|
||||
expect(mockSetInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle RevertOnly correctly', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onRewind = component.props.onRewind;
|
||||
|
||||
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevertFileChanges).toHaveBeenCalledWith(
|
||||
mockGetConversation(),
|
||||
'msg-id-123',
|
||||
);
|
||||
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'File changes reverted.',
|
||||
);
|
||||
});
|
||||
expect(mockSetInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Cancel correctly', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onRewind = component.props.onRewind;
|
||||
|
||||
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevertFileChanges).not.toHaveBeenCalled();
|
||||
expect(mockRewindTo).not.toHaveBeenCalled();
|
||||
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockSetInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle onExit correctly', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onExit = component.props.onExit;
|
||||
|
||||
onExit();
|
||||
|
||||
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle rewind error correctly', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onRewind = component.props.onRewind;
|
||||
|
||||
mockRewindTo.mockImplementation(() => {
|
||||
throw new Error('Rewind Failed');
|
||||
});
|
||||
|
||||
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Rewind Failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null conversation from rewindTo', async () => {
|
||||
const result = (await rewindCommand.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as OpenCustomDialogActionReturn;
|
||||
const component = result.component as ReactElement<RewindViewerProps>;
|
||||
const onRewind = component.props.onRewind;
|
||||
|
||||
mockRewindTo.mockReturnValue(null);
|
||||
|
||||
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Could not fetch conversation file',
|
||||
);
|
||||
expect(mockRemoveComponent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if config is missing', () => {
|
||||
const context = { services: {} } as CommandContext;
|
||||
|
||||
const result = rewindCommand.action!(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if client is not initialized', () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: { getGeminiClient: () => undefined },
|
||||
},
|
||||
}) as unknown as CommandContext;
|
||||
|
||||
const result = rewindCommand.action!(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Client not initialized',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if recording service is unavailable', () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
|
||||
},
|
||||
},
|
||||
}) as unknown as CommandContext;
|
||||
|
||||
const result = rewindCommand.action!(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Recording service unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info if no conversation found', () => {
|
||||
mockGetConversation.mockReturnValue(null);
|
||||
|
||||
const result = rewindCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info if no user interactions found', () => {
|
||||
mockGetConversation.mockReturnValue({
|
||||
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
const result = rewindCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Nothing to rewind to.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { RewindViewer } from '../components/RewindViewer.js';
|
||||
import { type HistoryItem } from '../types.js';
|
||||
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
|
||||
import { revertFileChanges } from '../utils/rewindFileOps.js';
|
||||
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type {
|
||||
ChatRecordingService,
|
||||
GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Helper function to handle the core logic of rewinding a conversation.
|
||||
* This function encapsulates the steps needed to rewind the conversation,
|
||||
* update the client and UI history, and clear the component.
|
||||
*
|
||||
* @param context The command context.
|
||||
* @param client Gemini client
|
||||
* @param recordingService The chat recording service.
|
||||
* @param messageId The ID of the message to rewind to.
|
||||
* @param newText The new text for the input field after rewinding.
|
||||
*/
|
||||
async function rewindConversation(
|
||||
context: CommandContext,
|
||||
client: GeminiClient,
|
||||
recordingService: ChatRecordingService,
|
||||
messageId: string,
|
||||
newText: string,
|
||||
) {
|
||||
try {
|
||||
const conversation = recordingService.rewindTo(messageId);
|
||||
if (!conversation) {
|
||||
const errorMsg = 'Could not fetch conversation file';
|
||||
debugLogger.error(errorMsg);
|
||||
context.ui.removeComponent();
|
||||
coreEvents.emitFeedback('error', errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to UI and Client formats
|
||||
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
);
|
||||
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
|
||||
// Reset context manager as we are rewinding history
|
||||
await context.services.config?.getContextManager()?.refresh();
|
||||
|
||||
// Update UI History
|
||||
// We generate IDs based on index for the rewind history
|
||||
const startId = 1;
|
||||
const historyWithIds = uiHistory.map(
|
||||
(item, idx) =>
|
||||
({
|
||||
...item,
|
||||
id: startId + idx,
|
||||
}) as HistoryItem,
|
||||
);
|
||||
|
||||
// 1. Remove component FIRST to avoid flicker and clear the stage
|
||||
context.ui.removeComponent();
|
||||
|
||||
// 2. Load the rewound history and set the input
|
||||
context.ui.loadHistory(historyWithIds, newText);
|
||||
} catch (error) {
|
||||
// If an error occurs, we still want to remove the component if possible
|
||||
context.ui.removeComponent();
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Unknown error during rewind',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const rewindCommand: SlashCommand = {
|
||||
name: 'rewind',
|
||||
description: 'Jump back to a specific message and restart the conversation',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not found',
|
||||
};
|
||||
|
||||
const client = config.getGeminiClient();
|
||||
if (!client)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Client not initialized',
|
||||
};
|
||||
|
||||
const recordingService = client.getChatRecordingService();
|
||||
if (!recordingService)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Recording service unavailable',
|
||||
};
|
||||
|
||||
const conversation = recordingService.getConversation();
|
||||
if (!conversation)
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found.',
|
||||
};
|
||||
|
||||
const hasUserInteractions = conversation.messages.some(
|
||||
(msg) => msg.type === 'user',
|
||||
);
|
||||
if (!hasUserInteractions) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Nothing to rewind to.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: (
|
||||
<RewindViewer
|
||||
conversation={conversation}
|
||||
onExit={() => {
|
||||
context.ui.removeComponent();
|
||||
}}
|
||||
onRewind={async (messageId, newText, outcome) => {
|
||||
switch (outcome) {
|
||||
case RewindOutcome.Cancel:
|
||||
context.ui.removeComponent();
|
||||
return;
|
||||
|
||||
case RewindOutcome.RevertOnly:
|
||||
if (conversation) {
|
||||
await revertFileChanges(conversation, messageId);
|
||||
}
|
||||
context.ui.removeComponent();
|
||||
coreEvents.emitFeedback('info', 'File changes reverted.');
|
||||
return;
|
||||
|
||||
case RewindOutcome.RewindAndRevert:
|
||||
if (conversation) {
|
||||
await revertFileChanges(conversation, messageId);
|
||||
}
|
||||
await rewindConversation(
|
||||
context,
|
||||
client,
|
||||
recordingService,
|
||||
messageId,
|
||||
newText,
|
||||
);
|
||||
return;
|
||||
|
||||
case RewindOutcome.RewindOnly:
|
||||
await rewindConversation(
|
||||
context,
|
||||
client,
|
||||
recordingService,
|
||||
messageId,
|
||||
newText,
|
||||
);
|
||||
return;
|
||||
|
||||
default:
|
||||
checkExhaustive(outcome);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
GitService,
|
||||
Logger,
|
||||
CommandActionReturn,
|
||||
AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
@@ -67,19 +66,13 @@ export interface CommandContext {
|
||||
* Loads a new set of history items, replacing the current history.
|
||||
*
|
||||
* @param history The array of history items to load.
|
||||
* @param postLoadInput Optional text to set in the input buffer after loading history.
|
||||
*/
|
||||
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
reloadCommands: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
definition: AgentDefinition,
|
||||
) => void;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
@@ -117,7 +110,6 @@ export interface OpenDialogActionReturn {
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'agentConfig'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,13 @@ describe('AboutBox', () => {
|
||||
expect(output).toContain('gemini-pro');
|
||||
expect(output).toContain('default');
|
||||
expect(output).toContain('macOS');
|
||||
expect(output).toContain('Logged in with Google');
|
||||
expect(output).toContain('OAuth');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['userEmail', 'test@example.com', 'User Email'],
|
||||
['gcpProject', 'my-project', 'GCP Project'],
|
||||
['ideClient', 'vscode', 'IDE Client'],
|
||||
['tier', 'Enterprise', 'Tier'],
|
||||
])('renders optional prop %s', (prop, value, label) => {
|
||||
const props = { ...defaultProps, [prop]: value };
|
||||
const { lastFrame } = render(<AboutBox {...props} />);
|
||||
@@ -48,13 +48,6 @@ describe('AboutBox', () => {
|
||||
expect(output).toContain(value);
|
||||
});
|
||||
|
||||
it('renders Auth Method with email when userEmail is provided', () => {
|
||||
const props = { ...defaultProps, userEmail: 'test@example.com' };
|
||||
const { lastFrame } = render(<AboutBox {...props} />);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google (test@example.com)');
|
||||
});
|
||||
|
||||
it('renders Auth Method correctly when not oauth', () => {
|
||||
const props = { ...defaultProps, selectedAuthType: 'api-key' };
|
||||
const { lastFrame } = render(<AboutBox {...props} />);
|
||||
|
||||
@@ -18,7 +18,6 @@ interface AboutBoxProps {
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
userEmail?: string;
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
@@ -30,7 +29,6 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
gcpProject,
|
||||
ideClient,
|
||||
userEmail,
|
||||
tier,
|
||||
}) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -105,23 +103,19 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{selectedAuthType.startsWith('oauth')
|
||||
? userEmail
|
||||
? `Logged in with Google (${userEmail})`
|
||||
: 'Logged in with Google'
|
||||
: selectedAuthType}
|
||||
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{tier && (
|
||||
{userEmail && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Tier
|
||||
User Email
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{tier}</Text>
|
||||
<Text color={theme.text.primary}>{userEmail}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import type { AgentDefinition } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
mainAreaWidth: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
enum TerminalKeys {
|
||||
ENTER = '\u000D',
|
||||
TAB = '\t',
|
||||
UP_ARROW = '\u001B[A',
|
||||
DOWN_ARROW = '\u001B[B',
|
||||
ESCAPE = '\u001B',
|
||||
}
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
workspaceSettings = {},
|
||||
): LoadedSettings => {
|
||||
const settings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {}, agents: {} },
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
agents: {},
|
||||
},
|
||||
path: '/system/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
path: '/system/system-defaults.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
agents: { overrides: {} },
|
||||
...userSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
agents: { overrides: {} },
|
||||
...userSettings,
|
||||
},
|
||||
path: '/user/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
agents: { overrides: {} },
|
||||
...workspaceSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
agents: { overrides: {} },
|
||||
...workspaceSettings,
|
||||
},
|
||||
path: '/workspace/settings.json',
|
||||
},
|
||||
true,
|
||||
[],
|
||||
);
|
||||
|
||||
// Mock setValue
|
||||
settings.setValue = vi.fn();
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
const createMockAgentDefinition = (
|
||||
overrides: Partial<AgentDefinition> = {},
|
||||
): AgentDefinition =>
|
||||
({
|
||||
name: 'test-agent',
|
||||
displayName: 'Test Agent',
|
||||
description: 'A test agent for testing',
|
||||
kind: 'local',
|
||||
modelConfig: {
|
||||
model: 'inherit',
|
||||
generateContentConfig: {
|
||||
temperature: 1.0,
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTimeMinutes: 5,
|
||||
maxTurns: 10,
|
||||
},
|
||||
experimental: false,
|
||||
...overrides,
|
||||
}) as AgentDefinition;
|
||||
|
||||
describe('AgentConfigDialog', () => {
|
||||
let mockOnClose: ReturnType<typeof vi.fn>;
|
||||
let mockOnSave: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnClose = vi.fn();
|
||||
mockOnSave = vi.fn();
|
||||
});
|
||||
|
||||
const renderDialog = (
|
||||
settings: LoadedSettings,
|
||||
definition: AgentDefinition = createMockAgentDefinition(),
|
||||
) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<AgentConfigDialog
|
||||
agentName="test-agent"
|
||||
displayName="Test Agent"
|
||||
definition={definition}
|
||||
settings={settings}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the dialog with title', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings);
|
||||
|
||||
expect(lastFrame()).toContain('Configure: Test Agent');
|
||||
});
|
||||
|
||||
it('should render all configuration fields', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings);
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Enabled');
|
||||
expect(frame).toContain('Model');
|
||||
expect(frame).toContain('Temperature');
|
||||
expect(frame).toContain('Top P');
|
||||
expect(frame).toContain('Top K');
|
||||
expect(frame).toContain('Max Output Tokens');
|
||||
expect(frame).toContain('Max Time (minutes)');
|
||||
expect(frame).toContain('Max Turns');
|
||||
});
|
||||
|
||||
it('should render scope selector', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings);
|
||||
|
||||
expect(lastFrame()).toContain('Apply To');
|
||||
expect(lastFrame()).toContain('User Settings');
|
||||
expect(lastFrame()).toContain('Workspace Settings');
|
||||
});
|
||||
|
||||
it('should render help text', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings);
|
||||
|
||||
expect(lastFrame()).toContain('Use Enter to select');
|
||||
expect(lastFrame()).toContain('Tab to change focus');
|
||||
expect(lastFrame()).toContain('Esc to close');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should close dialog on Escape', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { stdin } = renderDialog(settings);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ESCAPE);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate down with arrow key', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = renderDialog(settings);
|
||||
|
||||
// Initially first item (Enabled) should be active
|
||||
expect(lastFrame()).toContain('●');
|
||||
|
||||
// Press down arrow
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Model field should now be highlighted
|
||||
expect(lastFrame()).toContain('Model');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch focus with Tab', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = renderDialog(settings);
|
||||
|
||||
// Initially settings section is focused
|
||||
expect(lastFrame()).toContain('> Configure: Test Agent');
|
||||
|
||||
// Press Tab to switch to scope selector
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.TAB);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('> Apply To');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean toggle', () => {
|
||||
it('should toggle enabled field on Enter', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { stdin } = renderDialog(settings);
|
||||
|
||||
// Press Enter to toggle the first field (Enabled)
|
||||
await act(async () => {
|
||||
stdin.write(TerminalKeys.ENTER);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'agents.overrides.test-agent.enabled',
|
||||
false, // Toggles from true (default) to false
|
||||
);
|
||||
expect(mockOnSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should show values from agent definition as defaults', () => {
|
||||
const definition = createMockAgentDefinition({
|
||||
modelConfig: {
|
||||
model: 'gemini-2.0-flash',
|
||||
generateContentConfig: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTimeMinutes: 10,
|
||||
maxTurns: 20,
|
||||
},
|
||||
});
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings, definition);
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('gemini-2.0-flash');
|
||||
expect(frame).toContain('0.7');
|
||||
expect(frame).toContain('10');
|
||||
expect(frame).toContain('20');
|
||||
});
|
||||
|
||||
it('should show experimental agents as disabled by default', () => {
|
||||
const definition = createMockAgentDefinition({
|
||||
experimental: true,
|
||||
});
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = renderDialog(settings, definition);
|
||||
|
||||
// Experimental agents default to disabled
|
||||
expect(lastFrame()).toContain('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing overrides', () => {
|
||||
it('should show existing override values with * indicator', () => {
|
||||
const settings = createMockSettings({
|
||||
agents: {
|
||||
overrides: {
|
||||
'test-agent': {
|
||||
enabled: false,
|
||||
modelConfig: {
|
||||
model: 'custom-model',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { lastFrame } = renderDialog(settings);
|
||||
const frame = lastFrame();
|
||||
|
||||
// Should show the overridden values
|
||||
expect(frame).toContain('custom-model');
|
||||
expect(frame).toContain('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,435 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
LoadableSettingScope,
|
||||
LoadedSettings,
|
||||
} from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
|
||||
/**
|
||||
* Configuration field definition for agent settings
|
||||
*/
|
||||
interface AgentConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'boolean' | 'number' | 'string';
|
||||
path: string[]; // Path within AgentOverride, e.g., ['modelConfig', 'generateContentConfig', 'temperature']
|
||||
defaultValue: boolean | number | string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent configuration fields
|
||||
*/
|
||||
const AGENT_CONFIG_FIELDS: AgentConfigField[] = [
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
description: 'Enable or disable this agent',
|
||||
type: 'boolean',
|
||||
path: ['enabled'],
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: 'Model',
|
||||
description: "Model to use (e.g., 'gemini-2.0-flash' or 'inherit')",
|
||||
type: 'string',
|
||||
path: ['modelConfig', 'model'],
|
||||
defaultValue: 'inherit',
|
||||
},
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'Temperature',
|
||||
description: 'Sampling temperature (0.0 to 2.0)',
|
||||
type: 'number',
|
||||
path: ['modelConfig', 'generateContentConfig', 'temperature'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
{
|
||||
key: 'topP',
|
||||
label: 'Top P',
|
||||
description: 'Nucleus sampling parameter (0.0 to 1.0)',
|
||||
type: 'number',
|
||||
path: ['modelConfig', 'generateContentConfig', 'topP'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
{
|
||||
key: 'topK',
|
||||
label: 'Top K',
|
||||
description: 'Top-K sampling parameter',
|
||||
type: 'number',
|
||||
path: ['modelConfig', 'generateContentConfig', 'topK'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
{
|
||||
key: 'maxOutputTokens',
|
||||
label: 'Max Output Tokens',
|
||||
description: 'Maximum number of tokens to generate',
|
||||
type: 'number',
|
||||
path: ['modelConfig', 'generateContentConfig', 'maxOutputTokens'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
{
|
||||
key: 'maxTimeMinutes',
|
||||
label: 'Max Time (minutes)',
|
||||
description: 'Maximum execution time in minutes',
|
||||
type: 'number',
|
||||
path: ['runConfig', 'maxTimeMinutes'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
{
|
||||
key: 'maxTurns',
|
||||
label: 'Max Turns',
|
||||
description: 'Maximum number of conversational turns',
|
||||
type: 'number',
|
||||
path: ['runConfig', 'maxTurns'],
|
||||
defaultValue: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
interface AgentConfigDialogProps {
|
||||
agentName: string;
|
||||
displayName: string;
|
||||
definition: AgentDefinition;
|
||||
settings: LoadedSettings;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using a path array
|
||||
*/
|
||||
function getNestedValue(
|
||||
obj: Record<string, unknown> | undefined,
|
||||
path: string[],
|
||||
): unknown {
|
||||
if (!obj) return undefined;
|
||||
let current: unknown = obj;
|
||||
for (const key of path) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using a path array, creating intermediate objects as needed
|
||||
*/
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...obj };
|
||||
let current = result;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const finalKey = path[path.length - 1];
|
||||
if (value === undefined) {
|
||||
delete current[finalKey];
|
||||
} else {
|
||||
current[finalKey] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective default value for a field from the agent definition
|
||||
*/
|
||||
function getFieldDefaultFromDefinition(
|
||||
field: AgentConfigField,
|
||||
definition: AgentDefinition,
|
||||
): unknown {
|
||||
if (definition.kind !== 'local') return field.defaultValue;
|
||||
|
||||
if (field.key === 'enabled') {
|
||||
return !definition.experimental; // Experimental agents default to disabled
|
||||
}
|
||||
if (field.key === 'model') {
|
||||
return definition.modelConfig?.model ?? 'inherit';
|
||||
}
|
||||
if (field.key === 'temperature') {
|
||||
return definition.modelConfig?.generateContentConfig?.temperature;
|
||||
}
|
||||
if (field.key === 'topP') {
|
||||
return definition.modelConfig?.generateContentConfig?.topP;
|
||||
}
|
||||
if (field.key === 'topK') {
|
||||
return definition.modelConfig?.generateContentConfig?.topK;
|
||||
}
|
||||
if (field.key === 'maxOutputTokens') {
|
||||
return definition.modelConfig?.generateContentConfig?.maxOutputTokens;
|
||||
}
|
||||
if (field.key === 'maxTimeMinutes') {
|
||||
return definition.runConfig?.maxTimeMinutes;
|
||||
}
|
||||
if (field.key === 'maxTurns') {
|
||||
return definition.runConfig?.maxTurns;
|
||||
}
|
||||
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
export function AgentConfigDialog({
|
||||
agentName,
|
||||
displayName,
|
||||
definition,
|
||||
settings,
|
||||
onClose,
|
||||
onSave,
|
||||
}: AgentConfigDialogProps): React.JSX.Element {
|
||||
// Scope selector state (User by default)
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Pending override state for the selected scope
|
||||
const [pendingOverride, setPendingOverride] = useState<AgentOverride>(() => {
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const existingOverride = scopeSettings.agents?.overrides?.[agentName];
|
||||
return existingOverride ? structuredClone(existingOverride) : {};
|
||||
});
|
||||
|
||||
// Track which fields have been modified
|
||||
const [modifiedFields, setModifiedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// Update pending override when scope changes
|
||||
useEffect(() => {
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const existingOverride = scopeSettings.agents?.overrides?.[agentName];
|
||||
setPendingOverride(
|
||||
existingOverride ? structuredClone(existingOverride) : {},
|
||||
);
|
||||
setModifiedFields(new Set());
|
||||
}, [selectedScope, settings, agentName]);
|
||||
|
||||
/**
|
||||
* Save a specific field value to settings
|
||||
*/
|
||||
const saveFieldValue = useCallback(
|
||||
(fieldKey: string, path: string[], value: unknown) => {
|
||||
// Guard against prototype pollution
|
||||
if (['__proto__', 'constructor', 'prototype'].includes(agentName)) {
|
||||
return;
|
||||
}
|
||||
// Build the full settings path for agent override
|
||||
// e.g., agents.overrides.<agentName>.modelConfig.generateContentConfig.temperature
|
||||
const settingsPath = ['agents', 'overrides', agentName, ...path].join(
|
||||
'.',
|
||||
);
|
||||
settings.setValue(selectedScope, settingsPath, value);
|
||||
onSave?.();
|
||||
},
|
||||
[settings, selectedScope, agentName, onSave],
|
||||
);
|
||||
|
||||
// Calculate max label width
|
||||
const maxLabelWidth = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const field of AGENT_CONFIG_FIELDS) {
|
||||
const lWidth = getCachedStringWidth(field.label);
|
||||
const dWidth = getCachedStringWidth(field.description);
|
||||
max = Math.max(max, lWidth, dWidth);
|
||||
}
|
||||
return max;
|
||||
}, []);
|
||||
|
||||
// Generate items for BaseSettingsDialog
|
||||
const items: SettingsDialogItem[] = useMemo(
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
|
||||
let displayValue: string;
|
||||
if (field.type === 'boolean') {
|
||||
displayValue = effectiveValue ? 'true' : 'false';
|
||||
} else if (effectiveValue !== undefined && effectiveValue !== null) {
|
||||
displayValue = String(effectiveValue);
|
||||
} else {
|
||||
displayValue = '(default)';
|
||||
}
|
||||
|
||||
// Add * if modified
|
||||
const isModified =
|
||||
modifiedFields.has(field.key) || currentValue !== undefined;
|
||||
if (isModified && currentValue !== undefined) {
|
||||
displayValue += '*';
|
||||
}
|
||||
|
||||
// Get raw value for edit mode
|
||||
const rawValue =
|
||||
currentValue !== undefined ? currentValue : effectiveValue;
|
||||
|
||||
return {
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
type: field.type,
|
||||
displayValue,
|
||||
isGreyedOut: currentValue === undefined,
|
||||
scopeMessage: undefined,
|
||||
rawValue: rawValue as string | number | boolean | undefined,
|
||||
};
|
||||
}),
|
||||
[pendingOverride, definition, modifiedFields],
|
||||
);
|
||||
|
||||
const maxItemsToShow = 8;
|
||||
|
||||
// Handle scope changes
|
||||
const handleScopeChange = useCallback((scope: LoadableSettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
}, []);
|
||||
|
||||
// Handle toggle for boolean fields
|
||||
const handleItemToggle = useCallback(
|
||||
(key: string, _item: SettingsDialogItem) => {
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
const defaultValue = getFieldDefaultFromDefinition(field, definition);
|
||||
const effectiveValue =
|
||||
currentValue !== undefined ? currentValue : defaultValue;
|
||||
const newValue = !effectiveValue;
|
||||
|
||||
const newOverride = setNestedValue(
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
newValue,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => new Set(prev).add(key));
|
||||
|
||||
// Save the field value to settings
|
||||
saveFieldValue(field.key, field.path, newValue);
|
||||
},
|
||||
[pendingOverride, definition, saveFieldValue],
|
||||
);
|
||||
|
||||
// Handle edit commit for string/number fields
|
||||
const handleEditCommit = useCallback(
|
||||
(key: string, newValue: string, _item: SettingsDialogItem) => {
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field) return;
|
||||
|
||||
let parsed: string | number | undefined;
|
||||
if (field.type === 'number') {
|
||||
if (newValue.trim() === '') {
|
||||
// Empty means clear the override
|
||||
parsed = undefined;
|
||||
} else {
|
||||
const numParsed = Number(newValue.trim());
|
||||
if (Number.isNaN(numParsed)) {
|
||||
// Invalid number; don't save
|
||||
return;
|
||||
}
|
||||
parsed = numParsed;
|
||||
}
|
||||
} else {
|
||||
// For strings, empty means clear the override
|
||||
parsed = newValue.trim() === '' ? undefined : newValue;
|
||||
}
|
||||
|
||||
// Update pending override locally
|
||||
const newOverride = setNestedValue(
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
parsed,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => new Set(prev).add(key));
|
||||
|
||||
// Save the field value to settings
|
||||
saveFieldValue(field.key, field.path, parsed);
|
||||
},
|
||||
[pendingOverride, saveFieldValue],
|
||||
);
|
||||
|
||||
// Handle clear/reset - reset to default value (removes override)
|
||||
const handleItemClear = useCallback(
|
||||
(key: string, _item: SettingsDialogItem) => {
|
||||
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field) return;
|
||||
|
||||
// Remove the override (set to undefined)
|
||||
const newOverride = setNestedValue(
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
undefined,
|
||||
) as AgentOverride;
|
||||
|
||||
setPendingOverride(newOverride);
|
||||
setModifiedFields((prev) => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(key);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Save as undefined to remove the override
|
||||
saveFieldValue(field.key, field.path, undefined);
|
||||
},
|
||||
[pendingOverride, saveFieldValue],
|
||||
);
|
||||
|
||||
// Footer content
|
||||
const footerContent =
|
||||
modifiedFields.size > 0 ? (
|
||||
<Text color={theme.text.secondary}>Changes saved automatically.</Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<BaseSettingsDialog
|
||||
title={`Configure: ${displayName}`}
|
||||
searchEnabled={false}
|
||||
items={items}
|
||||
showScopeSelector={true}
|
||||
selectedScope={selectedScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
maxLabelWidth={maxLabelWidth}
|
||||
onItemToggle={handleItemToggle}
|
||||
onEditCommit={handleEditCommit}
|
||||
onItemClear={handleItemClear}
|
||||
onClose={onClose}
|
||||
footerContent={footerContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,30 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/AppContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/AppContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: () => ({
|
||||
version: '0.10.0',
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('../contexts/AppContext.js', () => ({
|
||||
useAppContext: () => ({
|
||||
version: '0.10.0',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -89,10 +82,22 @@ const mockPendingHistoryItems: HistoryItemWithoutId[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockConfig = {
|
||||
getScreenReader: () => false,
|
||||
getEnableInteractiveShell: () => false,
|
||||
getModel: () => 'gemini-pro',
|
||||
getTargetDir: () => '/tmp',
|
||||
getDebugMode: () => false,
|
||||
getIdeMode: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getExperiments: () => ({
|
||||
flags: {},
|
||||
experimentIds: [],
|
||||
}),
|
||||
getPreviewFeatures: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const baseUIState = {
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
@@ -107,7 +112,6 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
};
|
||||
|
||||
it('renders with active and pending tool messages', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -116,13 +120,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
|
||||
});
|
||||
|
||||
it('renders with empty history and no pending items', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -131,13 +135,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('empty');
|
||||
});
|
||||
|
||||
it('renders with history but no pending items', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -146,13 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
|
||||
});
|
||||
|
||||
it('renders with pending items but no history', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
@@ -161,52 +165,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
history: [],
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
|
||||
});
|
||||
|
||||
it('renders with a tool awaiting confirmation', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const pendingHistoryItems: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call4',
|
||||
name: 'confirming_tool',
|
||||
description: 'Confirming tool description',
|
||||
status: ToolCallStatus.Confirming,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: {
|
||||
type: 'info',
|
||||
title: 'Confirm Tool',
|
||||
prompt: 'Confirm this action?',
|
||||
onConfirm: async () => {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: [],
|
||||
pendingHistoryItems,
|
||||
},
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Action Required (was prompted):');
|
||||
expect(output).toContain('confirming_tool');
|
||||
expect(output).toContain('Confirming tool description');
|
||||
expect(output).toMatchSnapshot('with_confirming_tool');
|
||||
});
|
||||
|
||||
it('renders with user and gemini messages', () => {
|
||||
persistentStateMock.setData({ tipsShown: 0 });
|
||||
const history: HistoryItem[] = [
|
||||
{ id: 1, type: 'user', text: 'Hello Gemini' },
|
||||
{ id: 2, type: 'gemini', text: 'Hello User!' },
|
||||
@@ -219,6 +184,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
||||
history,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
|
||||
|
||||
@@ -4,26 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showPromptedTool =
|
||||
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
@@ -61,25 +52,6 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Action Required (was prompted):
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<ToolStatusIndicator
|
||||
status={confirmingTool.tool.status}
|
||||
name={confirmingTool.tool.name}
|
||||
/>
|
||||
<ToolInfo
|
||||
name={confirmingTool.tool.name}
|
||||
status={confirmingTool.tool.status}
|
||||
description={confirmingTool.tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<QuittingDisplay />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -4,20 +4,31 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const persistentStateMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.get.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should render the banner with default text', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
@@ -31,10 +42,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
@@ -55,10 +63,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('There are capacity issues');
|
||||
@@ -78,10 +83,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Banner');
|
||||
@@ -102,10 +104,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
@@ -125,10 +124,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
@@ -137,6 +133,7 @@ describe('<AppHeader />', () => {
|
||||
});
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
persistentStateMock.get.mockReturnValue(5);
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
@@ -146,21 +143,9 @@ describe('<AppHeader />', () => {
|
||||
},
|
||||
};
|
||||
|
||||
persistentStateMock.setData({
|
||||
defaultBannerShownCount: {
|
||||
[crypto
|
||||
.createHash('sha256')
|
||||
.update(uiState.bannerData.defaultText)
|
||||
.digest('hex')]: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
@@ -169,6 +154,7 @@ describe('<AppHeader />', () => {
|
||||
});
|
||||
|
||||
it('should increment the version count when default banner is displayed', () => {
|
||||
persistentStateMock.get.mockReturnValue({});
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
@@ -178,10 +164,6 @@ describe('<AppHeader />', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Set tipsShown to 10 or more to prevent Tips from incrementing its count
|
||||
// and interfering with the expected persistentState.set call.
|
||||
persistentStateMock.setData({ tipsShown: 10 });
|
||||
|
||||
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
@@ -212,87 +194,10 @@ describe('<AppHeader />', () => {
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render Tips when tipsShown is less than 10', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
persistentStateMock.setData({ tipsShown: 5 });
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Tips');
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT render Tips when tipsShown is 10 or more', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
|
||||
persistentStateMock.setData({ tipsShown: 10 });
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Tips');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show tips until they have been shown 10 times (persistence flow)', () => {
|
||||
persistentStateMock.setData({ tipsShown: 9 });
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
// First session
|
||||
const session1 = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
});
|
||||
|
||||
expect(session1.lastFrame()).toContain('Tips');
|
||||
expect(persistentStateMock.get('tipsShown')).toBe(10);
|
||||
session1.unmount();
|
||||
|
||||
// Second session - state is persisted in the fake
|
||||
const session2 = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
});
|
||||
|
||||
expect(session2.lastFrame()).not.toContain('Tips');
|
||||
session2.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { useBanner } from '../hooks/useBanner.js';
|
||||
import { useTips } from '../hooks/useTips.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -24,7 +23,6 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { showTips } = useTips();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -40,8 +38,9 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
// Helper to write to stdin with proper act() wrapping
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('AskUserDialog', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const authQuestion: Question[] = [
|
||||
{
|
||||
question: 'Which authentication method should we use?',
|
||||
header: 'Auth',
|
||||
options: [
|
||||
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
|
||||
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('renders question and options', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'Single Select',
|
||||
questions: authQuestion,
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\r');
|
||||
},
|
||||
expectedSubmit: { '0': 'OAuth 2.0' },
|
||||
},
|
||||
{
|
||||
name: 'Multi-select',
|
||||
questions: [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\r'); // Toggle TS
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\r'); // Toggle ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
},
|
||||
expectedSubmit: { '0': 'TypeScript, ESLint' },
|
||||
},
|
||||
{
|
||||
name: 'Text Input',
|
||||
questions: [
|
||||
{
|
||||
question: 'Name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
for (const char of 'test-app') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
writeKey(stdin, '\r');
|
||||
},
|
||||
expectedSubmit: { '0': 'test-app' },
|
||||
},
|
||||
])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => {
|
||||
it(`submits correct values for ${name}`, async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
actions(stdin);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedSubmit);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles custom option in single select with inline typing', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Move down to custom option
|
||||
writeKey(stdin, '\x1b[B');
|
||||
writeKey(stdin, '\x1b[B');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Enter a custom value');
|
||||
});
|
||||
|
||||
// Type directly (inline)
|
||||
for (const char of 'API Key') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('API Key');
|
||||
});
|
||||
|
||||
// Press Enter to submit the custom value
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' });
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Type a character without navigating down
|
||||
writeKey(stdin, 'A');
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show the custom input with 'A'
|
||||
// Placeholder is hidden when text is present
|
||||
expect(lastFrame()).toContain('A');
|
||||
expect(lastFrame()).toContain('3. A');
|
||||
});
|
||||
|
||||
// Continue typing
|
||||
writeKey(stdin, 'P');
|
||||
writeKey(stdin, 'I');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('API');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows progress header for multiple questions', () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which database should we use?',
|
||||
header: 'Database',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Relational database' },
|
||||
{ label: 'MongoDB', description: 'Document database' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which ORM do you prefer?',
|
||||
header: 'ORM',
|
||||
options: [
|
||||
{ label: 'Prisma', description: 'Type-safe ORM' },
|
||||
{ label: 'Drizzle', description: 'Lightweight ORM' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides progress header for single question', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows keyboard hints', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('navigates between questions with arrow keys', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which testing framework?',
|
||||
header: 'Testing',
|
||||
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which CI provider?',
|
||||
header: 'CI',
|
||||
options: [
|
||||
{ label: 'GitHub Actions', description: 'Built into GitHub' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which CI provider?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves answers when navigating back', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which package manager?',
|
||||
header: 'Package',
|
||||
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which bundler?',
|
||||
header: 'Bundler',
|
||||
options: [{ label: 'Vite', description: 'Next generation bundler' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer first question (should auto-advance)
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which bundler?');
|
||||
});
|
||||
|
||||
// Navigate back
|
||||
writeKey(stdin, '\x1b[D');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which package manager?');
|
||||
});
|
||||
|
||||
// Navigate forward
|
||||
writeKey(stdin, '\x1b[C');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which bundler?');
|
||||
});
|
||||
|
||||
// Answer second question
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
});
|
||||
|
||||
// Submit from Review
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Review tab in progress header for multiple questions', () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which framework?',
|
||||
header: 'Framework',
|
||||
options: [
|
||||
{ label: 'React', description: 'Component library' },
|
||||
{ label: 'Vue', description: 'Progressive framework' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which styling?',
|
||||
header: 'Styling',
|
||||
options: [
|
||||
{ label: 'Tailwind', description: 'Utility-first CSS' },
|
||||
{ label: 'CSS Modules', description: 'Scoped styles' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('allows navigating to Review tab and back', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Create tests?',
|
||||
header: 'Tests',
|
||||
options: [{ label: 'Yes', description: 'Generate test files' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Add documentation?',
|
||||
header: 'Docs',
|
||||
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add documentation?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow to Review
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow back
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add documentation?');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warning for unanswered questions on Review tab', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which license?',
|
||||
header: 'License',
|
||||
options: [{ label: 'MIT', description: 'Permissive license' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Include README?',
|
||||
header: 'README',
|
||||
options: [{ label: 'Yes', description: 'Generate README.md' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Navigate directly to Review tab without answering
|
||||
writeKey(stdin, '\x1b[C');
|
||||
writeKey(stdin, '\x1b[C');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits with unanswered questions when user confirms on Review', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Target Node version?',
|
||||
header: 'Node',
|
||||
options: [{ label: 'Node 20', description: 'LTS version' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Enable strict mode?',
|
||||
header: 'Strict',
|
||||
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer only first question
|
||||
writeKey(stdin, '\r');
|
||||
// Navigate to Review tab
|
||||
writeKey(stdin, '\x1b[C');
|
||||
// Submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Text type questions', () => {
|
||||
it('renders text input for type: "text"', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'What should we name this component?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'e.g., UserProfileCard',
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows default placeholder when none provided', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the database connection string:',
|
||||
header: 'Database',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('supports backspace in text mode', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the function name:',
|
||||
header: 'Function',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'abc') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('abc');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x7f'); // Backspace
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('ab');
|
||||
expect(lastFrame()).not.toContain('abc');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct keyboard hints for text type', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the variable name:',
|
||||
header: 'Variable',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('preserves text answer when navigating between questions', async () => {
|
||||
const mixedQuestions: Question[] = [
|
||||
{
|
||||
question: 'What should we name this hook?',
|
||||
header: 'Hook',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
{
|
||||
question: 'Should it be async?',
|
||||
header: 'Async',
|
||||
options: [
|
||||
{ label: 'Yes', description: 'Use async/await' },
|
||||
{ label: 'No', description: 'Synchronous hook' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'useAuth') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Should it be async?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
|
||||
// Wait, Async question is a CHOICE question, so Left arrow SHOULD work.
|
||||
// But ChoiceQuestionView also captures editing custom option state?
|
||||
// No, only if it is FOCUSING the custom option.
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('useAuth');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles mixed text and choice questions', async () => {
|
||||
const mixedQuestions: Question[] = [
|
||||
{
|
||||
question: 'What should we name this component?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'Enter component name',
|
||||
},
|
||||
{
|
||||
question: 'Which styling approach?',
|
||||
header: 'Style',
|
||||
options: [
|
||||
{ label: 'CSS Modules', description: 'Scoped CSS' },
|
||||
{ label: 'Tailwind', description: 'Utility classes' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'DataTable') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which styling approach?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
expect(lastFrame()).toContain('Name');
|
||||
expect(lastFrame()).toContain('DataTable');
|
||||
expect(lastFrame()).toContain('Style');
|
||||
expect(lastFrame()).toContain('CSS Modules');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
'0': 'DataTable',
|
||||
'1': 'CSS Modules',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit empty text', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
header: 'Class',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// onSubmit should not be called for empty text
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears text on Ctrl+C', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
header: 'Class',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const onCancel = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'SomeText') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('SomeText');
|
||||
});
|
||||
|
||||
// Send Ctrl+C
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
// Text should be cleared
|
||||
expect(lastFrame()).not.toContain('SomeText');
|
||||
expect(lastFrame()).toContain('>');
|
||||
});
|
||||
|
||||
// Should NOT call onCancel (dialog should stay open)
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows immediate arrow navigation after switching away from text input', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Choice Q?',
|
||||
header: 'Choice',
|
||||
options: [{ label: 'Option 1', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Text Q?',
|
||||
header: 'Text',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 1. Move to Text Q (Right arrow works for Choice Q)
|
||||
writeKey(stdin, '\x1b[C');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
|
||||
// 2. Type something in Text Q to make isEditingCustomOption true
|
||||
writeKey(stdin, 'a');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('a');
|
||||
});
|
||||
|
||||
// 3. Move back to Choice Q (Left arrow works because cursor is at left edge)
|
||||
// When typing 'a', cursor is at index 1.
|
||||
// We need to move cursor to index 0 first for Left arrow to work for navigation.
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Choice Q?');
|
||||
});
|
||||
|
||||
// 4. Immediately try Right arrow to go back to Text Q
|
||||
writeKey(stdin, '\x1b[C');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles rapid sequential answers correctly (stale closure protection)', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Question 1?',
|
||||
header: 'Q1',
|
||||
options: [{ label: 'A1', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Question 2?',
|
||||
header: 'Q2',
|
||||
options: [{ label: 'A2', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer Q1 and Q2 sequentialy
|
||||
act(() => {
|
||||
stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Question 2?');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r'); // Submit from Review
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
'0': 'A1',
|
||||
'1': 'A2',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -71,12 +71,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!uiState.slashCommands ||
|
||||
!uiState.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
<ConfigInitDisplay
|
||||
message={uiState.isResuming ? 'Resuming session...' : undefined}
|
||||
/>
|
||||
{(!uiState.slashCommands || !uiState.isConfigInitialized) && (
|
||||
<ConfigInitDisplay />
|
||||
)}
|
||||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
@@ -137,7 +133,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={isFocused}
|
||||
focus={true}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
|
||||
@@ -5,25 +5,12 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import {
|
||||
CoreEvent,
|
||||
MCPServerStatus,
|
||||
type McpClient,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AppEvent } from '../../utils/events.js';
|
||||
import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core';
|
||||
import { Text } from 'ink';
|
||||
|
||||
// Mock GeminiSpinner
|
||||
@@ -31,11 +18,30 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||
GeminiSpinner: () => <Text>Spinner</Text>,
|
||||
}));
|
||||
|
||||
describe('ConfigInitDisplay', () => {
|
||||
let onSpy: MockInstance<EventEmitter['on']>;
|
||||
// Mock appEvents
|
||||
const { mockOn, mockOff, mockEmit } = vi.hoisted(() => ({
|
||||
mockOn: vi.fn(),
|
||||
mockOff: vi.fn(),
|
||||
mockEmit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/events.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils/events.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appEvents: {
|
||||
on: mockOn,
|
||||
off: mockOff,
|
||||
emit: mockEmit,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ConfigInitDisplay', () => {
|
||||
beforeEach(() => {
|
||||
onSpy = vi.spyOn(coreEvents as EventEmitter, 'on');
|
||||
mockOn.mockClear();
|
||||
mockOff.mockClear();
|
||||
mockEmit.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -49,11 +55,10 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('updates message on McpClientUpdate event', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
@@ -87,11 +92,10 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('truncates list of waiting servers if too many', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
@@ -123,11 +127,10 @@ describe('ConfigInitDisplay', () => {
|
||||
|
||||
it('handles empty clients map', async () => {
|
||||
let listener: ((clients?: Map<string, McpClient>) => void) | undefined;
|
||||
onSpy.mockImplementation((event: unknown, fn: unknown) => {
|
||||
if (event === CoreEvent.McpClientUpdate) {
|
||||
listener = fn as (clients?: Map<string, McpClient>) => void;
|
||||
mockOn.mockImplementation((event, fn) => {
|
||||
if (event === AppEvent.McpClientUpdate) {
|
||||
listener = fn;
|
||||
}
|
||||
return coreEvents;
|
||||
});
|
||||
|
||||
const { lastFrame } = render(<ConfigInitDisplay />);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user