mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dde5ac077 | |||
| 7191d71711 | |||
| 0c4ac593eb | |||
| 8572e60f9c | |||
| daba5229ec | |||
| ec786aeaa8 | |||
| 76c97bfcc0 | |||
| 128bba380a | |||
| cd8bfce192 | |||
| 8f0edcd64f | |||
| 04e875c5c8 | |||
| a79da4f3a9 | |||
| 56809d7069 | |||
| 5dfbb739e5 | |||
| f87072f4e3 | |||
| 6a3175e973 | |||
| 4d1ca92a19 | |||
| b6fc583b0c | |||
| 0d6bd29752 | |||
| 78877942ec | |||
| 493b555646 | |||
| 75a8de83fc | |||
| a7beb890d0 | |||
| 60a6a47d56 | |||
| d313cd7dde | |||
| 77f4be1f3d | |||
| 0da1a2026a | |||
| 37edd1d4df | |||
| 165efa8a38 | |||
| 790f2cf815 | |||
| 704be5a418 | |||
| 9de8c8aadb | |||
| 0657d315fb | |||
| 88bdadc9c6 | |||
| 30c324dec7 | |||
| 40aa7397b6 | |||
| ab48aad213 | |||
| 4fa2c95c59 | |||
| 4e175527a2 | |||
| 1b021bddab | |||
| 40b384de2c | |||
| de8fdcfa16 | |||
| 39de9586a0 | |||
| 393d72ac52 | |||
| 408afd3c5a | |||
| c18ae0c382 | |||
| a93d2a1d1c | |||
| 381aae25b2 | |||
| dc5b3114c0 | |||
| 9380e13f6d | |||
| 363854172f | |||
| 7dea5b47a1 | |||
| 997f461cad | |||
| b14a29efa2 | |||
| f496354884 | |||
| 76d1a73606 | |||
| b266912e61 | |||
| 8fb1b5aa01 | |||
| 9cb48020e1 | |||
| 8943640a71 | |||
| 7213822e84 | |||
| d9f273e440 | |||
| c6121d5113 | |||
| b3e6c28933 | |||
| 4e81f48646 | |||
| 2e3090b6d9 | |||
| c427bd442f | |||
| caa0466416 | |||
| f497240f7e | |||
| 892c8a720d | |||
| 80e3bb9689 | |||
| d494195602 | |||
| 2f0c7518ad | |||
| a03ec92436 | |||
| e67631132d | |||
| 778da08ee1 | |||
| 7125d2cd65 | |||
| 84616626f5 | |||
| ef040eb392 | |||
| 8c1e255ac0 | |||
| 0af13141b2 | |||
| 90895efb29 | |||
| 84875ce911 | |||
| c94edcd862 | |||
| 0f1077076e | |||
| 9a98b0e56c | |||
| 071e2923bb | |||
| 487fb219cc | |||
| d743c6fae6 | |||
| a15568e013 |
@@ -0,0 +1,17 @@
|
||||
# Git history - not needed in build context
|
||||
.git
|
||||
|
||||
# Root node_modules - reinstalled inside container via npm ci
|
||||
node_modules
|
||||
|
||||
# Package-level node_modules - reinstalled inside container
|
||||
packages/*/node_modules
|
||||
|
||||
# Development and IDE files
|
||||
.github
|
||||
.vscode
|
||||
npm-debug.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.tmp
|
||||
@@ -53,11 +53,27 @@ Gemini CLI project.
|
||||
overriding values. Refer to `text-buffer.ts` for a canonical example.
|
||||
- **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in
|
||||
the code.
|
||||
- **State & Effects**: Ensure state initialization is explicit (e.g., use
|
||||
`undefined` rather than `true` as a default if the state is truly unknown).
|
||||
Carefully manage `useEffect` dependencies. Prefer a reducer whenever
|
||||
practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to
|
||||
correctly declare dependencies instead.
|
||||
- **State**: Ensure state initialization is explicit (e.g., use `undefined`
|
||||
rather than `true` as a default if the state is truly unknown). Prefer a
|
||||
reducer whenever practical. NEVER disable `react-hooks/exhaustive-deps`; fix
|
||||
the code to correctly declare dependencies instead. Evaluate all the React
|
||||
states in a component and ensure that the `useState` calls are necessary and
|
||||
not cases where values could be derived on render. Ensure there are no stale
|
||||
closures that are relying on a value from a previous render. React Components
|
||||
that modify Settings should effectively use the `useSettingsStore` pattern.
|
||||
Components that configure application Settings (e.g settings.json) are the
|
||||
only reasonable case for unsaved changes to drive UX; in these cases, the
|
||||
Settings store should only be written to on save. If the user experience does
|
||||
not utilize unsaved changes because there is no option to exit without saving
|
||||
or reverting the unsaved changes, then the component should directly read from
|
||||
and write to the Settings store without holding pending changes in component
|
||||
level UI state.
|
||||
- **Effect**: `useEffect` should not be used to synchronize React states, it
|
||||
should only be used for genuine side effects that occur outside of React.
|
||||
Contributors should be able to strongly justify the need for an effect.
|
||||
Consider whether the effect should instead be inside an event handler, or
|
||||
whether it is better off being computed on render. Carefully manage
|
||||
`useEffect` dependencies.
|
||||
- **Context & Props**: Avoid excessive property drilling. Leverage existing
|
||||
providers, extend them, or propose a new one if necessary. Only use providers
|
||||
for properties that are consistent across the entire application.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gemini Scheduled Lifecycle Manager Script
|
||||
* @param {object} param0
|
||||
* @param {import('@octokit/rest').Octokit} param0.github
|
||||
* @param {import('@actions/github/lib/context').Context} param0.context
|
||||
* @param {import('@actions/core')} param0.core
|
||||
*/
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const STALE_LABEL = 'stale';
|
||||
const NEED_INFO_LABEL = 'status/need-information';
|
||||
const EXEMPT_LABELS = [
|
||||
'pinned',
|
||||
'security',
|
||||
'🔒 maintainer only',
|
||||
'help wanted',
|
||||
'🗓️ Public Roadmap',
|
||||
];
|
||||
|
||||
const STALE_DAYS = 30;
|
||||
const CLOSE_DAYS = 7;
|
||||
const NO_RESPONSE_DAYS = 7;
|
||||
|
||||
const now = new Date();
|
||||
const staleThreshold = new Date(
|
||||
now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const closeThreshold = new Date(
|
||||
now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const noResponseThreshold = new Date(
|
||||
now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
async function processItems(query, callback) {
|
||||
core.info(`Searching: ${query}`);
|
||||
try {
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
order: 'asc',
|
||||
});
|
||||
const items = response.data.items;
|
||||
core.info(`Found ${items.length} items (batch limited).`);
|
||||
for (const item of items) {
|
||||
try {
|
||||
await callback(item);
|
||||
} catch (err) {
|
||||
core.error(`Error processing #${item.number}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
core.error(`Search failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Handle No-Response (status/need-information)
|
||||
// Removal: Check issues updated in the last 48h that have the label
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`,
|
||||
async (item) => {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
// Check if the last comment is from a non-maintainer
|
||||
const lastComment = comments[0];
|
||||
if (
|
||||
lastComment &&
|
||||
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
lastComment.author_association,
|
||||
) &&
|
||||
lastComment.user?.type !== 'Bot'
|
||||
) {
|
||||
core.info(
|
||||
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues
|
||||
.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
name: NEED_INFO_LABEL,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Closure: Check issues with the label that haven't been updated in 14 days
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(
|
||||
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Handle Stale Mark (60 days inactivity, no stale label)
|
||||
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Marking #${item.number} as stale.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [STALE_LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Handle Stale Close (14 days with stale label)
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Closing stale item #${item.number}.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
|
||||
const PR_NUDGE_DAYS = 7;
|
||||
const PR_CLOSE_DAYS = 14;
|
||||
const nudgeThreshold = new Date(
|
||||
now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const prCloseThreshold = new Date(
|
||||
now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: ['status/pr-nudge-sent'],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Close
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
|
||||
core.info(
|
||||
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
name: 'Build Unsigned Mac Binaries'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
build-mac:
|
||||
name: 'Build Unsigned (${{ matrix.arch }})'
|
||||
runs-on: 'macos-latest'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ['x64', 'arm64']
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
architecture: '${{ matrix.arch }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Binary'
|
||||
env:
|
||||
SKIP_SIGNING: 'true'
|
||||
run: 'npm run build:binary'
|
||||
|
||||
- name: 'Verify Output Exists'
|
||||
run: |
|
||||
if [ -f "dist/darwin-${{ matrix.arch }}/gemini" ]; then
|
||||
echo "Binary found at dist/darwin-${{ matrix.arch }}/gemini"
|
||||
else
|
||||
echo "Error: Binary not found in dist/darwin-${{ matrix.arch }}/"
|
||||
ls -R dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Upload Artifact'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'gemini-darwin-${{ matrix.arch }}-unsigned'
|
||||
path: 'dist/darwin-${{ matrix.arch }}/'
|
||||
retention-days: 5
|
||||
@@ -147,10 +147,7 @@ jobs:
|
||||
pull-requests: 'write'
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ${{ fromJSON(github.event_name == 'pull_request' && '["20.x"]' || '["20.x", "22.x", "24.x"]') }}
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
@@ -242,10 +239,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ["20.x"]
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
|
||||
@@ -53,9 +53,25 @@ jobs:
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -86,8 +102,15 @@ jobs:
|
||||
if [ -n "$LAST_RUN_ID" ]; then
|
||||
echo "Found previous successful run: $LAST_RUN_ID"
|
||||
|
||||
# Download brain memory (all state in one artifact)
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D . || echo "brain-data not found"
|
||||
# Download brain memory to a temp dir so we can selectively restore only persistent state
|
||||
mkdir -p .temp_brain_data
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found"
|
||||
|
||||
# Restore only persistent memory files
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
|
||||
mkdir -p tools/gemini-cli-bot/history/
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true
|
||||
rm -rf .temp_brain_data
|
||||
else
|
||||
echo "No previous successful run found."
|
||||
fi
|
||||
@@ -97,7 +120,7 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
|
||||
|
||||
- name: 'Run Brain Phases'
|
||||
- name: 'Run Brain and Critique Loop'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
@@ -129,31 +152,76 @@ jobs:
|
||||
echo "</untrusted_context>" >> trigger_context.md
|
||||
fi
|
||||
|
||||
cat trigger_context.md "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md > combined_prompt.md
|
||||
MAX_ITERATIONS=4
|
||||
ITERATION=1
|
||||
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
- name: 'Run Critique Phase'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
run: |
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes staged. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
while [ $ITERATION -le $MAX_ITERATIONS ]; do
|
||||
echo "========================================"
|
||||
echo "Starting Iteration $ITERATION"
|
||||
echo "========================================"
|
||||
|
||||
# --- BRAIN PHASE ---
|
||||
cat trigger_context.md > combined_prompt.md
|
||||
if [ -f "critique_feedback.md" ]; then
|
||||
cat critique_feedback.md >> combined_prompt.md
|
||||
fi
|
||||
cat "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md >> combined_prompt.md
|
||||
|
||||
echo "Running Brain Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "Agent failed to respond. Generating fallback error message."
|
||||
echo "⚠️ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md"
|
||||
echo "" >> "issue-comment.md"
|
||||
echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md"
|
||||
fi
|
||||
|
||||
# --- CRITIQUE PHASE ---
|
||||
if [ "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}" != "true" ]; then
|
||||
echo "PRs disabled, skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
fi
|
||||
|
||||
if git diff --staged --quiet && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "No changes staged and no comments generated. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
echo "Critique failed, rejected, or did not explicitly approve changes. Skipping PR creation."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Running Critique Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
echo "Critique Approved."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
else
|
||||
echo "Critique Rejected."
|
||||
if [ $ITERATION -lt $MAX_ITERATIONS ]; then
|
||||
echo "Preparing feedback for next iteration..."
|
||||
echo "<critique_feedback>" > critique_feedback.md
|
||||
echo "# Critique Feedback (Iteration $ITERATION)" >> critique_feedback.md
|
||||
echo "Your previous changes were rejected by the Critique agent. You MUST fix the following issues:" >> critique_feedback.md
|
||||
cat critique_output.log >> critique_feedback.md
|
||||
echo "</critique_feedback>" >> critique_feedback.md
|
||||
|
||||
# Discard rejected changes
|
||||
git reset
|
||||
git checkout .
|
||||
rm -f pr-description.md branch-name.txt pr-comment.md pr-number.txt issue-comment.md bot-changes.patch rejected-changes.patch
|
||||
else
|
||||
echo "Max iterations reached. Failing."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
# We still want to upload artifacts for debugging even if it failed.
|
||||
git diff --staged > rejected-changes.patch || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
ITERATION=$((ITERATION+1))
|
||||
done
|
||||
- name: 'Generate Patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
run: |
|
||||
@@ -173,6 +241,7 @@ jobs:
|
||||
tools/gemini-cli-bot/lessons-learned.md
|
||||
tools/gemini-cli-bot/history/*.csv
|
||||
bot-changes.patch
|
||||
rejected-changes.patch
|
||||
pr-description.md
|
||||
branch-name.txt
|
||||
pr-comment.md
|
||||
@@ -204,10 +273,25 @@ jobs:
|
||||
permission-pull-requests: 'write'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: 'main'
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -238,7 +322,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git checkout -B "$BRANCH_NAME"
|
||||
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git add .
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: '🔄 Gemini Scheduled Lifecycle Manager'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *' # Once a day
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
manage-lifecycle:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Lifecycle Management'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const script = require('./.github/scripts/gemini-lifecycle-manager.cjs');
|
||||
await script({github, context, core});
|
||||
@@ -63,15 +63,15 @@ jobs:
|
||||
|
||||
echo '🔍 Finding issues missing area labels...'
|
||||
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔍 Finding issues missing kind labels...'
|
||||
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🏷️ Finding issues missing priority labels...'
|
||||
NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
name: '🔒 Gemini Scheduled Stale Issue Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
close-stale-issues:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Process Stale Issues'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet: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';
|
||||
if (dryRun) {
|
||||
core.info('DRY RUN MODE ENABLED: No changes will be applied.');
|
||||
}
|
||||
const batchLabel = 'Stale';
|
||||
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const tenDaysAgo = new Date();
|
||||
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
||||
|
||||
core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`);
|
||||
core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`);
|
||||
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`;
|
||||
core.info(`Searching with query: ${query}`);
|
||||
|
||||
const itemsToCheck = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
sort: 'created',
|
||||
order: 'asc',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
core.info(`Found ${itemsToCheck.length} open issues to check.`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
for (const issue of itemsToCheck) {
|
||||
const createdAt = new Date(issue.created_at);
|
||||
const updatedAt = new Date(issue.updated_at);
|
||||
const reactionCount = issue.reactions.total_count;
|
||||
|
||||
// Basic thresholds
|
||||
if (reactionCount >= 5) {
|
||||
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')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isStale = updatedAt < tenDaysAgo;
|
||||
|
||||
// If apparently active, check if it's only bot activity
|
||||
if (!isStale) {
|
||||
try {
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const lastHumanComment = comments.data.find(comment => comment.user.type !== 'Bot');
|
||||
if (lastHumanComment) {
|
||||
isStale = new Date(lastHumanComment.created_at) < tenDaysAgo;
|
||||
} else {
|
||||
// No human comments. Check if creator is human.
|
||||
if (issue.user.type !== 'Bot') {
|
||||
isStale = createdAt < tenDaysAgo;
|
||||
} else {
|
||||
isStale = true; // Bot created, only bot comments
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to fetch comments for issue #${issue.number}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
processedCount++;
|
||||
const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`;
|
||||
core.info(message);
|
||||
|
||||
if (!dryRun) {
|
||||
// Add label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [batchLabel]
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!'
|
||||
});
|
||||
|
||||
// Close issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`\nTotal issues processed: ${processedCount}`);
|
||||
@@ -1,254 +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'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Process Stale PRs'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: team_slug
|
||||
});
|
||||
for (const m of members) maintainerLogins.add(m.login.toLowerCase());
|
||||
core.info(`Successfully fetched ${members.length} team members from ${team_slug}`);
|
||||
} catch (e) {
|
||||
// Silently skip if permissions are insufficient; we will rely on author_association
|
||||
core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMaintainer = async (login, assoc) => {
|
||||
// Reliably identify maintainers using authorAssociation (provided by GitHub)
|
||||
// and organization membership (if available).
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
if (isTeamMember || isRepoMaintainer) return true;
|
||||
|
||||
// Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission)
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({ org: org, username: login });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Gracefully ignore failures here
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. Fetch all open PRs
|
||||
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 = await isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
if (maintainerPr || isBot) continue;
|
||||
|
||||
// Helper: Fetch labels and linked issues via GraphQL
|
||||
const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let linkedIssues = [];
|
||||
try {
|
||||
const res = await github.graphql(prDetailsQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// Check for mentions in body as fallback (regex)
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const matches = body.match(mentionRegex);
|
||||
if (matches && linkedIssues.length === 0) {
|
||||
const issueNumber = parseInt(matches[1]);
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }];
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 3. Enforcement Logic
|
||||
const prLabels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const hasHelpWanted = prLabels.includes('help wanted') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted'));
|
||||
|
||||
const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only'));
|
||||
|
||||
const hasLinkedIssue = linkedIssues.length > 0;
|
||||
|
||||
// Closure Policy: No help-wanted label = Close after 14 days
|
||||
if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) {
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
|
||||
// We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM
|
||||
if (prCreatedAt > fourteenDaysAgo) {
|
||||
core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check for linked issue even if it has help wanted (redundant but safe)
|
||||
if (pr.state === 'open' && !hasLinkedIssue) {
|
||||
// Already covered by hasHelpWanted check above, but good for future-proofing
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Staleness Check (Scheduled only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) continue;
|
||||
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (await 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 (await isMaintainer(c.user.login, c.author_association)) {
|
||||
const d = new Date(c.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only');
|
||||
if (isProtected) {
|
||||
core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
name: 'No Response'
|
||||
|
||||
# Run as a daily cron at 1:45 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '45 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-no-response'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
days-before-stale: -1
|
||||
days-before-close: 14
|
||||
stale-issue-label: 'status/need-information'
|
||||
close-issue-message: >-
|
||||
This issue was marked as needing more information and has not received a response in 14 days.
|
||||
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
|
||||
stale-pr-label: 'status/need-information'
|
||||
close-pr-message: >-
|
||||
This pull request was marked as needing more information and has had no updates in 14 days.
|
||||
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!
|
||||
@@ -1,133 +0,0 @@
|
||||
name: '🏷️ PR Contribution Guidelines Notifier'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- 'opened'
|
||||
|
||||
jobs:
|
||||
notify-process-change:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Check membership and post comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |-
|
||||
const org = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const username = context.payload.pull_request.user.login;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
|
||||
// 1. Check if the PR author is a maintainer
|
||||
// Check team membership (most reliable for private org members)
|
||||
let isTeamMember = false;
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: org,
|
||||
team_slug: team_slug
|
||||
});
|
||||
if (members.some(m => m.login.toLowerCase() === username.toLowerCase())) {
|
||||
isTeamMember = true;
|
||||
core.info(`${username} is a member of ${team_slug}. No notification needed.`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isTeamMember) return;
|
||||
|
||||
// Check author_association from webhook payload
|
||||
const authorAssociation = context.payload.pull_request.author_association;
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation);
|
||||
|
||||
if (isRepoMaintainer) {
|
||||
core.info(`${username} is a maintainer (author_association: ${authorAssociation}). No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if author is a Googler
|
||||
const isGoogler = async (login) => {
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: org,
|
||||
username: login
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to check org membership for ${login}: ${e.message}`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await isGoogler(username)) {
|
||||
core.info(`${username} is a Googler. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if the PR is already associated with an issue
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = { owner: org, repo: repo, number: pr_number };
|
||||
const result = await github.graphql(query, variables);
|
||||
const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||
|
||||
if (issueCount > 0) {
|
||||
core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Post the notification comment
|
||||
core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`);
|
||||
|
||||
const comment = `
|
||||
Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.
|
||||
|
||||
We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706).
|
||||
|
||||
Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.
|
||||
|
||||
Thank you for your understanding and for being a part of our community!
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: org,
|
||||
repo: repo,
|
||||
issue_number: pr_number,
|
||||
body: comment
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 'Mark stale issues and pull requests'
|
||||
|
||||
# Run as a daily cron at 1:30 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner:
|
||||
- 'ubuntu-latest' # GitHub-hosted
|
||||
runs-on: '${{ matrix.runner }}'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-stale'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
stale-issue-message: >-
|
||||
This issue has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
stale-pr-message: >-
|
||||
This pull request has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
close-issue-message: >-
|
||||
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
|
||||
close-pr-message: >-
|
||||
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
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'
|
||||
+42
-1
@@ -1,3 +1,44 @@
|
||||
# ---- Stage 1: Builder ----
|
||||
FROM docker.io/library/node:20-slim AS builder
|
||||
|
||||
# Install git (needed by generate-git-commit-info.js script)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy only package.json files first for better layer caching
|
||||
# Dependencies only re-install when package files change, not source files
|
||||
COPY package*.json ./
|
||||
COPY packages/cli/package*.json ./packages/cli/
|
||||
COPY packages/core/package*.json ./packages/core/
|
||||
COPY packages/vscode-ide-companion/package*.json ./packages/vscode-ide-companion/
|
||||
COPY packages/vscode-ide-companion/scripts/ ./packages/vscode-ide-companion/scripts/
|
||||
COPY packages/devtools/package*.json ./packages/devtools/
|
||||
COPY packages/sdk/package*.json ./packages/sdk/
|
||||
COPY packages/test-utils/package*.json ./packages/test-utils/
|
||||
COPY packages/a2a-server/package*.json ./packages/a2a-server/
|
||||
|
||||
# Use npm ci for consistent, reliable builds (respects package-lock.json)
|
||||
RUN HUSKY=0 npm ci --ignore-scripts
|
||||
|
||||
# Now copy the rest of the source (after install for better caching)
|
||||
COPY packages/ ./packages/
|
||||
COPY tsconfig*.json ./
|
||||
COPY eslint.config.js ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY esbuild.config.js ./
|
||||
|
||||
# Pass git commit hash as build arg instead of copying entire .git directory
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# Build and pack artifacts
|
||||
RUN HUSKY=0 npm run build && \
|
||||
npm pack -w packages/core --pack-destination packages/core/dist/ && \
|
||||
npm pack -w packages/cli --pack-destination packages/cli/dist/
|
||||
|
||||
# ---- Stage 2: Runtime ----
|
||||
FROM docker.io/library/node:20-slim
|
||||
|
||||
ARG SANDBOX_NAME="gemini-cli-sandbox"
|
||||
@@ -50,4 +91,4 @@ RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
# default entrypoint when none specified
|
||||
CMD ["gemini"]
|
||||
ENTRYPOINT ["/usr/local/share/npm-global/bin/gemini"]
|
||||
@@ -395,6 +395,16 @@ for removal instructions.
|
||||
[Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy)
|
||||
- **Security**: [Security Policy](SECURITY.md)
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.star-history.com/google-gemini/gemini-cli">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=google-gemini/gemini-cli&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=google-gemini/gemini-cli" />
|
||||
<img alt="Star History Rank" src="https://api.star-history.com/badge?repo=google-gemini/gemini-cli" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -18,6 +18,24 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.40.0 - 2026-04-28
|
||||
|
||||
- **Offline Search and Themes:** Bundled ripgrep for offline search support and
|
||||
added GitHub-style colorblind themes
|
||||
([#25342](https://github.com/google-gemini/gemini-cli/pull/25342) by
|
||||
@scidomino, [#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
by @Z1xus).
|
||||
- **Advanced Resource and Memory Management:** Introduced MCP resource tools and
|
||||
transitioned to a prompt-driven, four-tier memory management system
|
||||
([#25395](https://github.com/google-gemini/gemini-cli/pull/25395) by
|
||||
@ruomengz, [#25716](https://github.com/google-gemini/gemini-cli/pull/25716) by
|
||||
@SandyTao520).
|
||||
- **UX and Local Models:** Enabled topic update narrations by default and
|
||||
streamlined Gemma local model setup with `gemini gemma`
|
||||
([#25586](https://github.com/google-gemini/gemini-cli/pull/25586) by
|
||||
@gundermanc, [#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
by @Samee24).
|
||||
|
||||
## Announcements: v0.39.0 - 2026-04-23
|
||||
|
||||
- **Skill Management:** Added a new `/memory` inbox command for reviewing and
|
||||
|
||||
+167
-242
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.39.0
|
||||
# Latest stable release: v0.40.0
|
||||
|
||||
Released: April 23, 2026
|
||||
Released: April 28, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,252 +11,177 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
|
||||
and patch skills extracted during agent sessions, streamlining the continuous
|
||||
learning workflow.
|
||||
- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
|
||||
requiring user confirmation for skill activation and allowing users to view
|
||||
the full content of generated plans.
|
||||
- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
|
||||
enabling agents to provide richer, more structured visual feedback during
|
||||
execution.
|
||||
- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
|
||||
`Sidecar` architecture to improve state management and session resilience.
|
||||
- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
|
||||
text in responses, ensuring full visibility into the agent's reasoning
|
||||
process.
|
||||
- **Offline Search Support:** Bundled ripgrep binaries into the Single
|
||||
Executable Application (SEA) to enable powerful codebase searching even in
|
||||
environments without internet access.
|
||||
- **Enhanced Theme Customization:** Introduced GitHub-style colorblind-friendly
|
||||
themes to improve accessibility and provide more personalized visual options.
|
||||
- **MCP Resource Management:** Added new tools for listing and reading Model
|
||||
Context Protocol (MCP) resources, enhancing the agent's ability to discover
|
||||
and utilize external data.
|
||||
- **Improved Narrative Flow:** Enabled topic update narrations by default to
|
||||
provide better session structure and a clearer understanding of the agent's
|
||||
current focus.
|
||||
- **Streamlined Local Model Setup:** Introduced a simplified `gemini gemma`
|
||||
command for quickly setting up and running Gemma models locally.
|
||||
- **Prompt-Driven Memory Management:** Replaced the legacy `MemoryManagerAgent`
|
||||
with a more efficient prompt-driven memory editing system across four tiers of
|
||||
context.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
@SandyTao520 in
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
|
||||
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
|
||||
by @rcleveng in
|
||||
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
|
||||
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
|
||||
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
|
||||
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
|
||||
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
|
||||
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
|
||||
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
|
||||
- feat(test): add large conversation scenario for performance test by
|
||||
@cynthialong0-0 in
|
||||
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
|
||||
- improve(core): require recurrence evidence before extracting skills by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
|
||||
- test(evals): add subagent delegation evaluation tests by @anj-s in
|
||||
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
|
||||
- feat: add github colorblind themes by @Z1xus in
|
||||
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
|
||||
@chrisjcthomas in
|
||||
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
|
||||
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
|
||||
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
|
||||
- Changelog for v0.38.0 by @gemini-cli-robot in
|
||||
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
|
||||
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
|
||||
memory by @SandyTao520 in
|
||||
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
|
||||
- Changelog for v0.38.1 by @gemini-cli-robot in
|
||||
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
|
||||
- feat(core): integrate skill-creator into skill extraction agent by
|
||||
@SandyTao520 in
|
||||
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
|
||||
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
|
||||
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
|
||||
- feat(core): add tools to list and read MCP resources by @ruomengz in
|
||||
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
|
||||
- fix(evals): add typecheck coverage for evals, integration-tests, and
|
||||
memory-tests by @SandyTao520 in
|
||||
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
|
||||
- Use OSC 777 for terminal notifications by @jackyliuxx in
|
||||
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
|
||||
- fix(extensions): fix bundling for examples by @abhipatel12 in
|
||||
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
|
||||
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
|
||||
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
|
||||
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
|
||||
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
|
||||
- docs(policy): mention that workspace policies are broken by @6112 in
|
||||
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
|
||||
- fix(core): allow explicit write permissions to override governance file
|
||||
protections in sandboxes by @galz10 in
|
||||
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
|
||||
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
|
||||
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
|
||||
- Reduce blank lines. by @gundermanc in
|
||||
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
|
||||
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
|
||||
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
|
||||
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
|
||||
backoff by @mahimashanware in
|
||||
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
|
||||
- feat(core): Disable topic updates for subagents by @gundermanc in
|
||||
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
|
||||
- feat(core): enable topic update narration by default and promote to general by
|
||||
@gundermanc in
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
|
||||
version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
|
||||
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
|
||||
- docs: migrate installation and authentication to mdx with tabbed layouts by
|
||||
@g-samroberts in
|
||||
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
|
||||
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
|
||||
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
|
||||
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
|
||||
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
|
||||
- fix(cli): round slow render latency to avoid opentelemetry float warning by
|
||||
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
|
||||
- docs(tracker): introduce experimental task tracker feature by @anj-s in
|
||||
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
|
||||
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
|
||||
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
|
||||
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
|
||||
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
- Changelog for v0.38.2 by @gemini-cli-robot in
|
||||
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
|
||||
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
|
||||
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
|
||||
- feat(test): refactor the memory usage test to use metrics from CLI process
|
||||
instead of test runner by @cynthialong0-0 in
|
||||
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
|
||||
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
|
||||
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
|
||||
- Fix/allow for session persistence by @ahsanfarooq210 in
|
||||
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
|
||||
- Allow dots on GEMINI_API_KEY by @DKbyo in
|
||||
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
|
||||
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
|
||||
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
|
||||
- fix(core): resolve nested plan directory duplication and relative path
|
||||
policies by @mahimashanware in
|
||||
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
|
||||
- feat: detect new files in @ recommendations with watcher based updates by
|
||||
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
|
||||
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
|
||||
@cocosheng-g in
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
|
||||
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
|
||||
processes by @euxaristia in
|
||||
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
|
||||
- feat: add /new as alias for /clear and refine command description by @ved015
|
||||
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
|
||||
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
|
||||
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
|
||||
- fix(core): remove duplicate initialize call on agents refreshed by
|
||||
@adamfweidman in
|
||||
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
|
||||
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
|
||||
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
|
||||
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
|
||||
across four tiers by @SandyTao520 in
|
||||
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
|
||||
- fix(cli): fix "/clear (new)" command by @mini2s in
|
||||
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
|
||||
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
|
||||
by @thekishandev in
|
||||
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
|
||||
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
|
||||
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
|
||||
- Fix/command injection shell by @Famous077 in
|
||||
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
|
||||
- fix(ui): removed background color for input by @devr0306 in
|
||||
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
|
||||
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
|
||||
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
|
||||
- fix(core): support jsonl session logs in memory and summary services by
|
||||
@SandyTao520 in
|
||||
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
|
||||
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
|
||||
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
|
||||
- fix(patch): cherry-pick 048bf6e to release/v0.40.0-preview.3-pr-25941 to patch
|
||||
version v0.40.0-preview.3 and create version 0.40.0-preview.4 by
|
||||
@gemini-cli-robot in
|
||||
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
|
||||
- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
|
||||
version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
|
||||
[#25942](https://github.com/google-gemini/gemini-cli/pull/25942)
|
||||
- fix(patch): cherry-pick 54b7586 to release/v0.40.0-preview.4-pr-26066
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#26124](https://github.com/google-gemini/gemini-cli/pull/26124)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.2...v0.39.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0
|
||||
|
||||
+99
-385
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.40.0-preview.3
|
||||
# Preview release: v0.41.0-preview.0
|
||||
|
||||
Released: April 24, 2026
|
||||
Released: April 28, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,395 +13,109 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Ripgrep Binary Bundling:** Ripgrep binaries are now bundled into the Single
|
||||
Executable Application (SEA), enabling grep functionality in offline
|
||||
environments.
|
||||
- **MCP Resource Tools:** New core tools added to list and read MCP (Model
|
||||
Context Protocol) resources, expanding the agent's ability to interact with
|
||||
MCP servers.
|
||||
- **Local Model Setup:** Introduced a streamlined `gemini gemma` command for
|
||||
easier local model setup and integration.
|
||||
- **Prompt-Driven Memory Management:** Refactored memory management into a
|
||||
prompt-driven, four-tier system and integrated `skill-creator` for robust
|
||||
skill extraction.
|
||||
- **Enhanced UI and Accessibility:** Added support for OSC 777 terminal
|
||||
notifications and GitHub colorblind themes for better user feedback and
|
||||
accessibility.
|
||||
- **Real-Time Voice Mode:** Implemented a new real-time voice mode supporting
|
||||
both cloud and local backends for a more interactive experience.
|
||||
- **Enhanced Security & Trust:** Enforced workspace trust in headless mode and
|
||||
secured `.env` file loading to improve system integrity.
|
||||
- **Expanded Model Support:** Added experimental support for Gemma 4 models in
|
||||
both core and CLI packages.
|
||||
- **Improved Core Infrastructure:** Wired up new `ContextManager` and
|
||||
`AgentChatHistory` for better state management, and optimized boot performance
|
||||
by fetching experiments and quota asynchronously.
|
||||
- **New Developer Tools & UX:** Added support for output redirection for CLI
|
||||
commands, manual session UUIDs via command-line arguments, and persistent
|
||||
auto-memory scratchpad for skill extraction.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
|
||||
@gemini-cli-robot in
|
||||
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
|
||||
- fix(core): only show `list` suggestion if the partial input is empty by
|
||||
@cynthialong0-0 in
|
||||
[#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
|
||||
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
|
||||
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
|
||||
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
|
||||
@gemini-cli-robot in
|
||||
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
|
||||
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
|
||||
by @rcleveng in
|
||||
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
|
||||
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
|
||||
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
|
||||
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
|
||||
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
|
||||
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
|
||||
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
|
||||
- feat(test): add large conversation scenario for performance test by
|
||||
@cynthialong0-0 in
|
||||
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
|
||||
- improve(core): require recurrence evidence before extracting skills by
|
||||
- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
|
||||
[#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
|
||||
- update package-lock.json by @ehedlund in
|
||||
[#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- fix(ui): corrected background color check in user message components by
|
||||
@devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
|
||||
- perf(core): fix slow boot by fetching experiments and quota asynchronously by
|
||||
@spencer426 in
|
||||
[#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
|
||||
- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
|
||||
in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- docs: add Gemini CLI course link to README by @JayadityaGit in
|
||||
[#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
|
||||
- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
|
||||
[#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
|
||||
- fix(cli): allow output redirection for cli commands by @spencer426 in
|
||||
[#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
|
||||
- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
|
||||
rules by @ehedlund in
|
||||
[#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
|
||||
- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
|
||||
in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
|
||||
- feat(voice): implement real-time voice mode with cloud and local backends by
|
||||
@Abhijit-2592 in
|
||||
[#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
|
||||
- Changelog for v0.39.0 by @gemini-cli-robot in
|
||||
[#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
|
||||
- feat(memory): persist auto-memory scratchpad for skill extraction by
|
||||
@SandyTao520 in
|
||||
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
|
||||
- test(evals): add subagent delegation evaluation tests by @anj-s in
|
||||
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
|
||||
- feat: add github colorblind themes by @Z1xus in
|
||||
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
|
||||
@chrisjcthomas in
|
||||
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
|
||||
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
|
||||
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
|
||||
- Changelog for v0.38.0 by @gemini-cli-robot in
|
||||
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
|
||||
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
|
||||
memory by @SandyTao520 in
|
||||
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
|
||||
- Changelog for v0.38.1 by @gemini-cli-robot in
|
||||
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
|
||||
- feat(core): integrate skill-creator into skill extraction agent by
|
||||
@SandyTao520 in
|
||||
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
|
||||
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
|
||||
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
|
||||
- feat(core): add tools to list and read MCP resources by @ruomengz in
|
||||
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
|
||||
- fix(evals): add typecheck coverage for evals, integration-tests, and
|
||||
memory-tests by @SandyTao520 in
|
||||
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
|
||||
- Use OSC 777 for terminal notifications by @jackyliuxx in
|
||||
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
|
||||
- fix(extensions): fix bundling for examples by @abhipatel12 in
|
||||
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
|
||||
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
|
||||
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
|
||||
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
|
||||
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
|
||||
- docs(policy): mention that workspace policies are broken by @6112 in
|
||||
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
|
||||
- fix(core): allow explicit write permissions to override governance file
|
||||
protections in sandboxes by @galz10 in
|
||||
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
|
||||
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
|
||||
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
|
||||
- Reduce blank lines. by @gundermanc in
|
||||
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
|
||||
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
|
||||
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
|
||||
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
|
||||
backoff by @mahimashanware in
|
||||
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
|
||||
- feat(core): Disable topic updates for subagents by @gundermanc in
|
||||
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
|
||||
- feat(core): enable topic update narration by default and promote to general by
|
||||
@gundermanc in
|
||||
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
|
||||
- docs: migrate installation and authentication to mdx with tabbed layouts by
|
||||
@g-samroberts in
|
||||
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
|
||||
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
|
||||
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
|
||||
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
|
||||
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
|
||||
- fix(cli): round slow render latency to avoid opentelemetry float warning by
|
||||
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
|
||||
- docs(tracker): introduce experimental task tracker feature by @anj-s in
|
||||
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
|
||||
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
|
||||
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
|
||||
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
|
||||
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
- Changelog for v0.38.2 by @gemini-cli-robot in
|
||||
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
|
||||
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
|
||||
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
|
||||
- feat(test): refactor the memory usage test to use metrics from CLI process
|
||||
instead of test runner by @cynthialong0-0 in
|
||||
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
|
||||
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
|
||||
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
|
||||
- Fix/allow for session persistence by @ahsanfarooq210 in
|
||||
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
|
||||
- Allow dots on GEMINI_API_KEY by @DKbyo in
|
||||
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
|
||||
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
|
||||
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
|
||||
- fix(core): resolve nested plan directory duplication and relative path
|
||||
policies by @mahimashanware in
|
||||
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
|
||||
- feat: detect new files in @ recommendations with watcher based updates by
|
||||
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
|
||||
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
|
||||
[#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
|
||||
- fix(cli): add missing response key to custom theme text schema by @gaurav0107
|
||||
in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
|
||||
- fix(cli): provide manual update command when automatic update fails by
|
||||
@cocosheng-g in
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
|
||||
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
|
||||
processes by @euxaristia in
|
||||
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
|
||||
- feat: add /new as alias for /clear and refine command description by @ved015
|
||||
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
|
||||
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
|
||||
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
|
||||
- fix(core): remove duplicate initialize call on agents refreshed by
|
||||
@adamfweidman in
|
||||
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
|
||||
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
|
||||
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
|
||||
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
|
||||
across four tiers by @SandyTao520 in
|
||||
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
|
||||
- fix(cli): fix "/clear (new)" command by @mini2s in
|
||||
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
|
||||
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
|
||||
by @thekishandev in
|
||||
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
|
||||
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
|
||||
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
|
||||
- Fix/command injection shell by @Famous077 in
|
||||
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
|
||||
- fix(ui): removed background color for input by @devr0306 in
|
||||
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
|
||||
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
|
||||
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
|
||||
- fix(core): support jsonl session logs in memory and summary services by
|
||||
@SandyTao520 in
|
||||
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
|
||||
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
|
||||
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
@SandyTao520 in
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
@gundermanc in
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
[#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
|
||||
- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
|
||||
[#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
|
||||
- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
|
||||
[#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
|
||||
- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
|
||||
in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
|
||||
- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
|
||||
in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
|
||||
- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
|
||||
[#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
|
||||
- fix(cli): make MCP ping optional in list command and use configured timeout by
|
||||
@cocosheng-g in
|
||||
[#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
|
||||
- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
|
||||
[#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
|
||||
- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
|
||||
[#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
|
||||
- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
|
||||
[#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
|
||||
- (docs) update sandboxing documentation by @g-samroberts in
|
||||
[#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
|
||||
- fix(core): enforce parallel task tracker updates by @anj-s in
|
||||
[#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
|
||||
- Update policy so transient errors are not marked terminal by @DavidAPierce in
|
||||
[#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
|
||||
- Implement bot that performs time-series metric analysis and suggests repo
|
||||
management improvements by @gundermanc in
|
||||
[#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
|
||||
- fix(core): handle non-string model flags in resolution by @Adib234 in
|
||||
[#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
|
||||
- fix(ux): added error message for ENOTDIR by @devr0306 in
|
||||
[#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
|
||||
- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
|
||||
[#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
|
||||
- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
|
||||
in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
|
||||
- feat(cli): support boolean and number casting for env vars in settings.json by
|
||||
@cocosheng-g in
|
||||
[#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
|
||||
- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
|
||||
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.40.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.40.0-preview.5...v0.41.0-preview.0
|
||||
|
||||
@@ -9,7 +9,7 @@ and parameters.
|
||||
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | Start interactive REPL | `gemini` |
|
||||
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
|
||||
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
|
||||
| gemini "query" | Query and continue interactively | gemini "explain this project" |
|
||||
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
|
||||
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
|
||||
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
|
||||
@@ -33,6 +33,7 @@ These commands are available within the interactive REPL.
|
||||
| -------------------- | ----------------------------------------------- |
|
||||
| `/skills reload` | Reload discovered skills from disk |
|
||||
| `/agents reload` | Reload the agent registry |
|
||||
| `/commands list` | List available custom slash commands |
|
||||
| `/commands reload` | Reload custom slash commands |
|
||||
| `/memory reload` | Reload context files (for example, `GEMINI.md`) |
|
||||
| `/mcp reload` | Restart and reload MCP servers |
|
||||
|
||||
+169
-42
@@ -1,18 +1,21 @@
|
||||
# Creating Agent Skills
|
||||
|
||||
This guide provides an overview of how to create your own Agent Skills to extend
|
||||
the capabilities of Gemini CLI.
|
||||
Agent Skills let you extend Gemini CLI with specialized expertise, procedural
|
||||
workflows, and task-specific resources. This guide walks you through both
|
||||
automated and manual methods for creating and organizing your skills.
|
||||
|
||||
## Getting started: The `skill-creator` skill
|
||||
## Quickstart: Create a skill with a prompt
|
||||
|
||||
The recommended way to create a new skill is to use the built-in `skill-creator`
|
||||
skill. To use it, ask Gemini CLI to create a new skill for you.
|
||||
The fastest way to create a new skill is to use the built-in `skill-creator`.
|
||||
This meta-skill guides you through designing, scaffolding, and validating your
|
||||
expertise.
|
||||
|
||||
**Example prompt:**
|
||||
Simply ask Gemini CLI to create a skill for you:
|
||||
|
||||
> "create a new skill called 'code-reviewer'"
|
||||
> "Create a new skill called 'code-reviewer' that analyzes local files for
|
||||
> common errors and style violations."
|
||||
|
||||
Gemini CLI will then use the `skill-creator` to generate the skill:
|
||||
Gemini will then:
|
||||
|
||||
1. Generate a new directory for your skill (for example, `my-new-skill/`).
|
||||
2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and
|
||||
@@ -20,24 +23,116 @@ Gemini CLI will then use the `skill-creator` to generate the skill:
|
||||
3. Create the standard resource directories: `scripts/`, `references/`, and
|
||||
`assets/`.
|
||||
|
||||
## Manual skill creation
|
||||
Once created, you can find your new skill in `.gemini/skills/code-reviewer/`.
|
||||
|
||||
If you prefer to create skills manually:
|
||||
## Manual creation
|
||||
|
||||
1. **Create a directory** for your skill (for example, `my-new-skill/`).
|
||||
2. **Create a `SKILL.md` file** inside the new directory.
|
||||
|
||||
To add additional resources that support the skill, refer to the skill
|
||||
structure.
|
||||
### 1. Create the directory structure
|
||||
|
||||
## Skill structure
|
||||
The first step is to create the necessary folders for your skill and its
|
||||
scripts.
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file at its root.
|
||||
**macOS/Linux**
|
||||
|
||||
### Folder structure
|
||||
```bash
|
||||
mkdir -p .gemini/skills/code-reviewer/scripts
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\code-reviewer\scripts"
|
||||
```
|
||||
|
||||
### 2. Define the skill (`SKILL.md`)
|
||||
|
||||
The `SKILL.md` file defines the skill's purpose and instructions for the agent.
|
||||
Create a file at `.gemini/skills/code-reviewer/SKILL.md`.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Expertise in reviewing code changes for correctness, security, and style. Use
|
||||
when the user asks to "review" their code or a PR.
|
||||
---
|
||||
|
||||
# Code Reviewer Instructions
|
||||
|
||||
You act as a senior software engineer specialized in code quality. When this
|
||||
skill is active, you MUST:
|
||||
|
||||
1. **Analyze**: Review the provided code for logical errors, security
|
||||
vulnerabilities, and style violations.
|
||||
2. **Review**: Use the bundled `scripts/review.js` utility to perform an
|
||||
automated check.
|
||||
3. **Feedback**: Provide constructive feedback, clearly distinguishing between
|
||||
critical issues and minor improvements.
|
||||
```
|
||||
|
||||
### 3. Add the tool logic
|
||||
|
||||
Skills can bundle resources like scripts to perform deterministic tasks. Create
|
||||
a file at `.gemini/skills/code-reviewer/scripts/review.js`.
|
||||
|
||||
```javascript
|
||||
// .gemini/skills/code-reviewer/scripts/review.js
|
||||
const file = process.argv[2];
|
||||
|
||||
if (!file) {
|
||||
console.error('Usage: node review.js <file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Reviewing ${file}...`);
|
||||
// Simple mock review logic
|
||||
setTimeout(() => {
|
||||
console.log(`Result: Success (No major issues found in ${file})`);
|
||||
}, 500);
|
||||
```
|
||||
|
||||
### 4. Test the skill
|
||||
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory.
|
||||
|
||||
1. Start a new session and ask a question that triggers the skill's
|
||||
description: "Can you review index.js"
|
||||
2. Gemini identifies the request matches the `code-reviewer` description and
|
||||
asks for permission to activate it.
|
||||
3. Once you approve, Gemini executes the bundled script:
|
||||
`node .gemini/skills/code-reviewer/scripts/review.js index.js`
|
||||
|
||||
To determine whether your skill has been correctly loaded, run the command:
|
||||
|
||||
```bash
|
||||
/skills
|
||||
```
|
||||
|
||||
### 5. Optional: Share your skill
|
||||
|
||||
You can share your skills in several ways depending on your target audience.
|
||||
|
||||
- **Workspace skills**: Commit your skill to a `.gemini/skills/` directory in
|
||||
your project repository.
|
||||
- **Extensions**: Bundle your skill within a
|
||||
[Gemini CLI extension](../extensions/writing-extensions.md).
|
||||
- **Git repositories**: Share the skill directory as a standalone Git repo and
|
||||
install it using `gemini skills install <url>`.
|
||||
|
||||
---
|
||||
|
||||
## Core concepts
|
||||
|
||||
Now that you've built your first skill, let's explore the core components and
|
||||
workflows for developing more complex expertise.
|
||||
|
||||
### Skill structure
|
||||
|
||||
While a `SKILL.md` file is the only required component, we recommend the
|
||||
following structure for organizing your skill's resources:
|
||||
following structure for organizing your skill's resources.
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
@@ -47,34 +142,66 @@ my-skill/
|
||||
└── assets/ (Optional) Templates and other resources
|
||||
```
|
||||
|
||||
### `SKILL.md` file
|
||||
When a skill is activated, the model is granted access to this entire directory.
|
||||
You can instruct the model to use the tools and files found within these
|
||||
folders.
|
||||
|
||||
The `SKILL.md` file is the core of your skill. This file uses YAML frontmatter
|
||||
for metadata and Markdown for instructions. For example:
|
||||
### Metadata and triggers
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Use this skill to review code. It supports both local changes and remote Pull
|
||||
Requests.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
This skill guides the agent in conducting thorough code reviews.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine Review Target
|
||||
|
||||
- **Remote PR**: If the user gives a PR number or URL, target that remote PR.
|
||||
- **Local Changes**: If changes are local... ...
|
||||
```
|
||||
The `SKILL.md` file uses YAML frontmatter for metadata.
|
||||
|
||||
- **`name`**: A unique identifier for the skill. This should match the directory
|
||||
name.
|
||||
- **`description`**: A description of what the skill does and when Gemini should
|
||||
use it.
|
||||
- **Body**: The Markdown body of the file contains the instructions that guide
|
||||
the agent's behavior when the skill is active.
|
||||
- **`description`**: **CRITICAL.** This is how Gemini decides when to use the
|
||||
skill. Be specific about the tasks it handles and the keywords that should
|
||||
trigger it.
|
||||
|
||||
### Discovery tiers
|
||||
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
1. **Built-in Skills**: Included with Gemini CLI (pre-approved).
|
||||
2. **Extension Skills**: Bundled within [extensions](../extensions/).
|
||||
3. **User Skills**: `~/.gemini/skills/` or the `~/.agents/skills/` alias.
|
||||
4. **Workspace Skills**: `.gemini/skills/` or the `.agents/skills/` alias.
|
||||
|
||||
### Discovery aliases
|
||||
|
||||
You can use `.agents/skills` as an alternative to `.gemini/skills`. This alias
|
||||
is compatible with other AI agent tools following the
|
||||
[Agent Skills](https://agentskills.io) standard.
|
||||
|
||||
## Advanced development
|
||||
|
||||
Once you've built a basic skill, you can use specialized scripts and workflows
|
||||
to streamline your development process.
|
||||
|
||||
### Creation scripts
|
||||
|
||||
If you are developing a skill and want to use the same scripts the built-in
|
||||
tools use, you can find them in the core package. These scripts help automate
|
||||
the initialization, validation, and packaging of skills.
|
||||
|
||||
- **Initialize**: `node scripts/init_skill.cjs <name> --path <dir>`
|
||||
- **Validate**: `node scripts/validate_skill.cjs <path/to/skill>`
|
||||
- **Package**: `node scripts/package_skill.cjs <path/to/skill>` (Creates a
|
||||
`.skill` zip file)
|
||||
|
||||
### Linking for local development
|
||||
|
||||
If you are developing a skill in a separate directory, you can link it to your
|
||||
user skills directory for testing:
|
||||
|
||||
```bash
|
||||
gemini skills link .
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Skill best practices](./skills-best-practices.md): Learn strategies for
|
||||
building reliable and effective skills.
|
||||
- [Agent Skills overview](./skills.md): Deep dive into discovery tiers and the
|
||||
skill lifecycle.
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
quick walkthrough of triggering and using skills.
|
||||
|
||||
@@ -34,6 +34,7 @@ separator (`/` or `\`) being converted to a colon (`:`).
|
||||
> [!TIP]
|
||||
> After creating or modifying `.toml` command files, run
|
||||
> `/commands reload` to pick up your changes without restarting the CLI.
|
||||
> To see all available command files, run `/commands list`.
|
||||
|
||||
## TOML file format (v1)
|
||||
|
||||
|
||||
@@ -158,15 +158,16 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` |
|
||||
| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
|
||||
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
|
||||
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
|
||||
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
|
||||
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
@@ -177,7 +178,7 @@ they appear in the UI.
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Agent Skill best practices
|
||||
|
||||
Create high-quality, reliable Agent Skills by following these established design
|
||||
principles and patterns.
|
||||
|
||||
## Design for discovery
|
||||
|
||||
The most important part of a skill is its `description`. This is the only
|
||||
information the model has before activation.
|
||||
|
||||
- **Be specific**: Use keywords that are likely to appear in user prompts (for
|
||||
example, "audit," "security," "refactor," "migration").
|
||||
- **Define the trigger**: Clearly state _when_ the skill should be used (for
|
||||
example, "Use this skill when the user asks to review a PR for performance
|
||||
regressions").
|
||||
- **Avoid overlap**: Ensure your skill descriptions are distinct from one
|
||||
another and from the general capabilities of the model.
|
||||
|
||||
## Progressive disclosure
|
||||
|
||||
The "context window" is a shared resource. Use a three-level loading system to
|
||||
manage context efficiently.
|
||||
|
||||
1. **Metadata (name + description)**: Always in context (~100 words).
|
||||
2. **`SKILL.md` body**: Loaded only after the skill triggers (<5k words).
|
||||
3. **Bundled resources**: Loaded only as needed by the model.
|
||||
|
||||
**Best practice**: Keep the `SKILL.md` body focused on core procedural
|
||||
instructions. Move detailed reference material, schemas, and examples into
|
||||
separate files in a `references/` directory.
|
||||
|
||||
## Degrees of freedom
|
||||
|
||||
Match the level of instruction specificity to the task's fragility.
|
||||
|
||||
- **High freedom (text-based instructions)**: Use when multiple approaches are
|
||||
valid or decisions depend heavily on context.
|
||||
- **Medium freedom (pseudocode or scripts with parameters)**: Use when a
|
||||
preferred pattern exists but some variation is acceptable.
|
||||
- **Low freedom (specific scripts, few parameters)**: Use when operations are
|
||||
fragile and error-prone, or a specific sequence MUST be followed.
|
||||
|
||||
## Bundle resources effectively
|
||||
|
||||
Leverage the skill's ability to include scripts and assets to extend the agent's
|
||||
capabilities.
|
||||
|
||||
- **Use scripts for deterministic tasks**: If a task can be automated with a
|
||||
script (for example, running a linter, fetching data from an API), bundle it
|
||||
in the `scripts/` folder.
|
||||
- **Agentic ergonomics**: Ensure scripts output LLM-friendly stdout. Suppress
|
||||
verbose tracebacks and provide clear, concise success/failure messages.
|
||||
- **Provide templates**: Include common file headers or boilerplate code in the
|
||||
`assets/` folder to ensure the agent produces consistent output.
|
||||
|
||||
## Anatomy of a great skill
|
||||
|
||||
A well-structured skill directory organizes its resources into specialized
|
||||
sub-folders.
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (Required) Core instructions and metadata
|
||||
├── scripts/ (Optional) Executable logic (Node.js, Python, etc.)
|
||||
├── references/ (Optional) Documentation to be loaded as needed
|
||||
└── assets/ (Optional) Templates and non-executable resources
|
||||
```
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Design your skills with security in mind to protect your workspace and data.
|
||||
|
||||
- **Avoid hardcoded secrets**: Never include API keys or passwords in your
|
||||
skill's scripts or documentation.
|
||||
- **Review third-party skills**: Inspect the `SKILL.md` and scripts of any skill
|
||||
before installing it from an untrusted source.
|
||||
- **Limit scope**: Design skills to be as focused as possible to minimize the
|
||||
potential impact of errors.
|
||||
+113
-108
@@ -1,112 +1,21 @@
|
||||
# Agent Skills
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise,
|
||||
procedural workflows, and task-specific resources. Based on the
|
||||
Agent Skills let you extend Gemini CLI with specialized expertise, procedural
|
||||
workflows, and task-specific resources. Based on the
|
||||
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
|
||||
self-contained directory that packages instructions and assets into a
|
||||
discoverable capability.
|
||||
|
||||
## Overview
|
||||
|
||||
Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide
|
||||
Unlike general context files ([GEMINI.md](./gemini-md.md)), which provide
|
||||
persistent workspace-wide background, Skills represent **on-demand expertise**.
|
||||
This allows Gemini to maintain a vast library of specialized capabilities—such
|
||||
as security auditing, cloud deployments, or codebase migrations—without
|
||||
cluttering the model's immediate context window.
|
||||
This lets Gemini CLI maintain a vast library of specialized capabilities—such as
|
||||
security auditing, cloud deployments, or codebase migrations—without cluttering
|
||||
the model's immediate context window.
|
||||
|
||||
Gemini autonomously decides when to employ a skill based on your request and the
|
||||
skill's description. When a relevant skill is identified, the model "pulls in"
|
||||
the full instructions and resources required to complete the task using the
|
||||
`activate_skill` tool.
|
||||
## How it works
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Shared Expertise:** Package complex workflows (like a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
- **Resource Bundling:** Include scripts, templates, or example data alongside
|
||||
instructions so the agent has everything it needs.
|
||||
- **Progressive Disclosure:** Only skill metadata (name and description) is
|
||||
loaded initially. Detailed instructions and resources are only disclosed when
|
||||
the model explicitly activates the skill, saving context tokens.
|
||||
|
||||
## Skill Discovery Tiers
|
||||
|
||||
Gemini CLI discovers skills from three primary locations:
|
||||
|
||||
1. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias. Workspace skills are typically committed to version control and
|
||||
shared with the team.
|
||||
2. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias. These are personal skills available across all your workspaces.
|
||||
3. **Extension Skills**: Skills bundled within installed
|
||||
[extensions](../extensions/index.md).
|
||||
|
||||
**Precedence:** If multiple skills share the same name, higher-precedence
|
||||
locations override lower ones: **Workspace > User > Extension**.
|
||||
|
||||
Within the same tier (user or workspace), the `.agents/skills/` alias takes
|
||||
precedence over the `.gemini/skills/` directory. This generic alias provides an
|
||||
intuitive path for managing agent-specific expertise that remains compatible
|
||||
across different AI agent tools.
|
||||
|
||||
## Managing Skills
|
||||
|
||||
### In an Interactive Session
|
||||
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> `/skills disable` and `/skills enable` default to the `user` scope. Use
|
||||
> `--scope workspace` to manage workspace-specific settings.
|
||||
|
||||
### From the Terminal
|
||||
|
||||
The `gemini skills` command provides management utilities:
|
||||
|
||||
```bash
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Link agent skills from a local directory via symlink
|
||||
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills
|
||||
# (or ~/.agents/skills)
|
||||
gemini skills link /path/to/my-skills-repo
|
||||
|
||||
# Link to the workspace scope (.gemini/skills or .agents/skills)
|
||||
gemini skills link /path/to/my-skills-repo --scope workspace
|
||||
|
||||
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||
# Uses the user scope by default (~/.gemini/skills or ~/.agents/skills)
|
||||
gemini skills install https://github.com/user/repo.git
|
||||
gemini skills install /path/to/local/skill
|
||||
gemini skills install /path/to/local/my-expertise.skill
|
||||
|
||||
# Install a specific skill from a monorepo or subdirectory using --path
|
||||
gemini skills install https://github.com/my-org/my-skills.git --path skills/frontend-design
|
||||
|
||||
# Install to the workspace scope (.gemini/skills or .agents/skills)
|
||||
gemini skills install /path/to/skill --scope workspace
|
||||
|
||||
# Uninstall a skill by name
|
||||
gemini skills uninstall my-expertise --scope workspace
|
||||
|
||||
# Enable a skill (globally)
|
||||
gemini skills enable my-expertise
|
||||
|
||||
# Disable a skill. Can use --scope to specify workspace or user (defaults to workspace)
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## How it Works
|
||||
The lifecycle of an Agent Skill involves discovery, activation, and conditional
|
||||
resource access.
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
tiers and injects the name and description of all enabled skills into the
|
||||
@@ -123,14 +32,110 @@ gemini skills disable my-expertise --scope workspace
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
|
||||
### Skill activation
|
||||
## Discovery tiers
|
||||
|
||||
Once a skill is activated (typically by Gemini identifying a task that matches
|
||||
the skill's description and your approval), its specialized instructions and
|
||||
resources are loaded into the agent's context. A skill remains active and its
|
||||
guidance is prioritized for the duration of the session.
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
## Creating your own skills
|
||||
1. **Built-in skills**: Standard skills included with Gemini CLI that provide
|
||||
foundational capabilities.
|
||||
2. **Extension skills**: Skills bundled within installed
|
||||
[extensions](../extensions/index.md).
|
||||
3. **User skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias.
|
||||
4. **Workspace skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias. Workspace skills are shared with your team via version control.
|
||||
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
guide.
|
||||
### Precedence and aliases
|
||||
|
||||
If multiple skills share the same name, the version from the higher-precedence
|
||||
location is used. Within the same tier (user or workspace), the
|
||||
`.agents/skills/` alias takes precedence over the `.gemini/skills/` directory.
|
||||
|
||||
The `.agents/skills/` alias provides an interoperable path for managing
|
||||
agent-specific expertise that remains compatible across different AI tools.
|
||||
|
||||
## Key benefits
|
||||
|
||||
Agent Skills provide several advantages for managing specialized knowledge and
|
||||
complex workflows.
|
||||
|
||||
- **Shared expertise**: Package complex workflows (like a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable workflows**: Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
- **Resource bundling**: Include scripts, templates, or example data alongside
|
||||
instructions so the agent has everything it needs.
|
||||
- **Progressive disclosure**: Only skill metadata (name and description) is
|
||||
loaded initially. Detailed instructions and resources are only disclosed when
|
||||
the model explicitly activates the skill, saving context tokens.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> `/skills disable` and `/skills enable` default to the `user` scope. Use
|
||||
> `--scope workspace` to manage workspace-specific settings.
|
||||
|
||||
To see all available skills in your current session, use the `/skills list`
|
||||
command.
|
||||
|
||||
## Managing skills
|
||||
|
||||
You can manage Agent Skills through interactive session commands or directly
|
||||
from your terminal.
|
||||
|
||||
### In an interactive session
|
||||
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list [all] [nodesc]`: Shows discovered skills. Use `all` to include
|
||||
built-in skills and `nodesc` to hide descriptions.
|
||||
- `/skills link <path> [--scope user|workspace]`: Links skills from a local
|
||||
directory.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload` (or `/skills refresh`): Refreshes the list of discovered
|
||||
skills from all tiers.
|
||||
|
||||
### From the terminal
|
||||
|
||||
The `gemini skills` command provides management utilities:
|
||||
|
||||
```bash
|
||||
# List all discovered skills. Use --all to include built-in skills.
|
||||
gemini skills list --all
|
||||
|
||||
# Install a skill from a Git repository or local directory.
|
||||
# Use --consent to skip the security confirmation prompt.
|
||||
gemini skills install https://github.com/user/repo.git --consent
|
||||
|
||||
# Uninstall a skill.
|
||||
gemini skills uninstall my-skill --scope workspace
|
||||
```
|
||||
|
||||
#### Command options
|
||||
|
||||
The skill management commands support several global and command-specific
|
||||
options.
|
||||
|
||||
- `--scope`: Either `user` (global, default) or `workspace` (local to the
|
||||
project).
|
||||
- `--path`: The sub-directory within a Git repository containing the skill.
|
||||
- `--consent`: Acknowledge security risks and skip the interactive confirmation
|
||||
during installation.
|
||||
|
||||
For more details on CLI commands, see the
|
||||
[CLI reference](./cli-reference.md#skills-management).
|
||||
|
||||
## Next steps
|
||||
|
||||
Explore these resources to refine your skills and understand the framework
|
||||
better.
|
||||
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
quick walkthrough of triggering and using skills.
|
||||
- [Creating Agent Skills](./creating-skills.md): Create your first skill and
|
||||
bundle custom logic.
|
||||
- [Using Agent Skills](./using-agent-skills.md): Learn how to leverage built-in
|
||||
and custom skills.
|
||||
- [Best practices](./skills-best-practices.md): Learn strategies for building
|
||||
effective skills.
|
||||
|
||||
@@ -1,110 +1,158 @@
|
||||
# Get started with Agent Skills
|
||||
|
||||
Agent Skills extend Gemini CLI with specialized expertise. In this guide, you'll
|
||||
learn how to create your first skill, bundle custom scripts, and activate them
|
||||
during a session.
|
||||
Agent Skills extend Gemini CLI with specialized expertise. In this tutorial,
|
||||
you'll learn how to create your first skill, bundle custom logic, and activate
|
||||
it during a session.
|
||||
|
||||
## How to create a skill
|
||||
## Create your first skill
|
||||
|
||||
A skill is defined by a directory containing a `SKILL.md` file. Let's create an
|
||||
**API Auditor** skill that helps you verify if local or remote endpoints are
|
||||
responding correctly.
|
||||
A skill is defined by a directory containing a `SKILL.md` file and
|
||||
subdirectories containing reference materials or scripts used by the skill.
|
||||
Let's create an **API Auditor** skill that runs a script to help you verify if
|
||||
local or remote endpoints are responding correctly.
|
||||
|
||||
### Create the directory structure
|
||||
### 1. Create the directory structure
|
||||
|
||||
1. Run the following command to create the folders:
|
||||
The first step is to create the necessary folders for your skill and its
|
||||
scripts.
|
||||
|
||||
**macOS/Linux**
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini/skills/api-auditor/scripts
|
||||
```
|
||||
```bash
|
||||
mkdir -p .gemini/skills/api-auditor/scripts
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts"
|
||||
```
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts"
|
||||
```
|
||||
|
||||
### Create the definition
|
||||
### 2. Create the definition (`SKILL.md`)
|
||||
|
||||
1. Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent
|
||||
_when_ to use the skill and _how_ to behave.
|
||||
The `SKILL.md` file defines the skill's purpose and instructions for the agent.
|
||||
Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent
|
||||
_when_ to use the skill and _how_ to behave.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: api-auditor
|
||||
description:
|
||||
Expertise in auditing and testing API endpoints. Use when the user asks to
|
||||
"check", "test", or "audit" a URL or API.
|
||||
---
|
||||
```markdown
|
||||
---
|
||||
name: api-auditor
|
||||
description:
|
||||
Expertise in auditing and testing API endpoints. Use when the user asks to
|
||||
"check", "test", or "audit" a URL or API.
|
||||
---
|
||||
|
||||
# API Auditor Instructions
|
||||
# API Auditor Instructions
|
||||
|
||||
You act as a QA engineer specialized in API reliability. When this skill is
|
||||
active, you MUST:
|
||||
You act as a QA engineer specialized in API reliability. When this skill is
|
||||
active, you MUST:
|
||||
|
||||
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the
|
||||
status of the provided URL.
|
||||
2. **Report**: Analyze the output (status codes, latency) and explain any
|
||||
failures in plain English.
|
||||
3. **Secure**: Remind the user if they are testing a sensitive endpoint
|
||||
without an `https://` protocol.
|
||||
```
|
||||
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the status of
|
||||
the provided URL.
|
||||
2. **Report**: Analyze the output (status codes, latency) and explain any
|
||||
failures in plain English.
|
||||
3. **Secure**: Remind the user if they are testing a sensitive endpoint without
|
||||
an `https://` protocol.
|
||||
```
|
||||
|
||||
### Add the tool logic
|
||||
### 3. Add the tool logic
|
||||
|
||||
Skills can bundle resources like scripts.
|
||||
Skills can bundle resources like scripts to perform deterministic tasks. Create
|
||||
a file at `.gemini/skills/api-auditor/scripts/audit.js`. This is the code the
|
||||
agent will run.
|
||||
|
||||
1. Create a file at `.gemini/skills/api-auditor/scripts/audit.js`. This is the
|
||||
code the agent will run.
|
||||
```javascript
|
||||
// .gemini/skills/api-auditor/scripts/audit.js
|
||||
const url = process.argv[2];
|
||||
|
||||
```javascript
|
||||
// .gemini/skills/api-auditor/scripts/audit.js
|
||||
const url = process.argv[2];
|
||||
if (!url) {
|
||||
console.error('Usage: node audit.js <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: node audit.js <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Auditing ${url}...`);
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => console.log(`Result: Success (Status ${r.status})`))
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
|
||||
console.log(`Auditing ${url}...`);
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => console.log(`Result: Success (Status ${r.status})`))
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
## Verify discovery
|
||||
|
||||
## How to verify discovery
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory (as
|
||||
well as the `.agents/skills` alias).
|
||||
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory. You
|
||||
can also use `.agents/skills` as a more generic alternative. Check that it found
|
||||
your new skill.
|
||||
To check if Gemini CLI found your new skill, use the `/skills list` command
|
||||
within an interactive session:
|
||||
|
||||
**Command:** `/skills list`
|
||||
```bash
|
||||
/skills list
|
||||
```
|
||||
|
||||
You should see `api-auditor` in the list of available skills.
|
||||
You should see `api-auditor` in the list of available skills. If you just added
|
||||
the files, you can run `/skills reload` to refresh the list without restarting
|
||||
the session.
|
||||
|
||||
### If your skill doesn't appear
|
||||
|
||||
If `/skills list` doesn't show your skill, check the following:
|
||||
|
||||
1. **The folder must be trusted (workspace skills only).** Skills under
|
||||
`<workspace>/.gemini/skills/` are only loaded when the workspace folder is
|
||||
marked as trusted. Run `/trust` and restart the session if needed. Skills
|
||||
under `~/.gemini/skills/` (user scope) are not affected by trust.
|
||||
2. **Check the path layout.** `SKILL.md` is discovered either at the root of
|
||||
the skills directory (`.gemini/skills/SKILL.md`) or one directory deep
|
||||
(`.gemini/skills/<skill-name>/SKILL.md`). The recommended layout uses a
|
||||
subdirectory per skill so you can bundle scripts and other resources
|
||||
alongside it. Files nested more than one directory deep are not discovered.
|
||||
3. **The filename must be exactly `SKILL.md`.** Capitalization matters on
|
||||
case-sensitive filesystems (Linux, and macOS when configured as such):
|
||||
`skill.md` or `Skill.md` will be ignored.
|
||||
4. **Frontmatter must include both `name:` and `description:`, and must be the
|
||||
first thing in the file.** A `SKILL.md` is silently skipped if either field
|
||||
is missing, if the delimiters (`---` on their own lines) are absent, or if
|
||||
any text (an H1 title, a comment, even a blank line) appears before the
|
||||
opening `---`.
|
||||
5. **The skill name comes from the `name:` field, not the directory name.** If
|
||||
your frontmatter says `name: foo`, the skill appears as `foo` in
|
||||
`/skills list` regardless of what its parent directory is called. The
|
||||
characters `: \ / < > * ? " |` in the name are replaced with `-`.
|
||||
|
||||
## How to use the skill
|
||||
|
||||
Now, try it out. Start a new session and ask a question that triggers the
|
||||
skill's description.
|
||||
Now that the skill is discovered, you can trigger its activation by asking a
|
||||
relevant question.
|
||||
|
||||
**User:** "Can you audit http://geminicli.com"
|
||||
1. **Trigger**: Start a new session and ask: "Can you audit https://google.com"
|
||||
2. **Activation**: Gemini identifies that the request matches the `api-auditor`
|
||||
description and calls the `activate_skill` tool.
|
||||
3. **Consent**: You will see a confirmation prompt. Type **y** to approve.
|
||||
4. **Execution**: Once activated, Gemini uses the `run_shell_command` tool to
|
||||
execute your bundled script:
|
||||
`node .gemini/skills/api-auditor/scripts/audit.js https://google.com`
|
||||
|
||||
Gemini recognizes the request matches the `api-auditor` description and asks for
|
||||
permission to activate it.
|
||||
## Pro tip: Use the skill-creator
|
||||
|
||||
**Model:** (After calling `activate_skill`) "I've activated the **api-auditor**
|
||||
skill. I'll run the audit script now..."
|
||||
If you don't want to create the files manually, you can use the built-in
|
||||
`skill-creator` skill. Simply ask Gemini:
|
||||
|
||||
Gemini then uses the `run_shell_command` tool to execute your bundled Node
|
||||
script:
|
||||
> "Create a new skill called 'api-auditor' that tests if URLs are responding."
|
||||
|
||||
`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com`
|
||||
The `skill-creator` will handle the directory structure and boilerplate for you.
|
||||
|
||||
## Manage skills
|
||||
|
||||
You can also manage skills using the `gemini skills` command from your terminal:
|
||||
|
||||
- **Install**: `gemini skills install <url-or-path>`
|
||||
- **Link**: `gemini skills link <path>` (useful for local development)
|
||||
- **Uninstall**: `gemini skills uninstall <name>`
|
||||
|
||||
## Next steps
|
||||
|
||||
- Explore the
|
||||
[Agent Skills Authoring Guide](../../cli/skills.md#creating-a-skill) to learn
|
||||
about more advanced features.
|
||||
- Learn how to share skills via [Extensions](../../extensions/index.md).
|
||||
- [Creating Agent Skills](../creating-skills.md): Detailed guide on advanced
|
||||
skill features and metadata.
|
||||
- [Using Agent Skills](../using-agent-skills.md): More ways to discover and
|
||||
manage your skill library.
|
||||
- [Skill best practices](../skills-best-practices.md): Learn how to design
|
||||
reliable and effective expertise.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Managing Agent Skills
|
||||
|
||||
Agent Skills provide Gemini CLI with specialized expertise on demand. This guide
|
||||
covers advanced management techniques, including using slash commands, terminal
|
||||
utilities, and understanding discovery tiers.
|
||||
|
||||
## Discovery tiers
|
||||
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
1. **Built-in Skills**: Included with Gemini CLI and always available.
|
||||
2. **Extension Skills**: Bundled within [extensions](../extensions/index.md).
|
||||
3. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias. These are available across all your projects.
|
||||
4. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias within your current directory. These are project-specific.
|
||||
|
||||
> **Tip:** If multiple skills share the same name, the version from the
|
||||
> higher-precedence location is used.
|
||||
|
||||
## In-session management
|
||||
|
||||
Use the `/skills` slash command during an interactive session to manage your
|
||||
available expertise.
|
||||
|
||||
- **List skills**: `/skills list` (shows discovered skills).
|
||||
- Use `/skills list all` to include internal built-in skills.
|
||||
- Use `/skills list nodesc` to hide descriptions.
|
||||
- **Reload skills**: `/skills reload` (or `/skills refresh`) to scan for new or
|
||||
modified skills without restarting the CLI.
|
||||
- **Toggle status**:
|
||||
- `/skills disable <name>`: Prevents a skill from being triggered.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- **Link local skills**: `/skills link <path> [--scope user|workspace]` to
|
||||
immediately use a skill you are developing.
|
||||
|
||||
## Terminal utilities
|
||||
|
||||
The `gemini skills` command provides management utilities directly from your
|
||||
system shell.
|
||||
|
||||
### Install a skill
|
||||
|
||||
To install a skill from a remote repository or a local `.skill` package:
|
||||
|
||||
```bash
|
||||
gemini skills install https://github.com/user/my-awesome-skill
|
||||
```
|
||||
|
||||
By default, this installs to your **user profile**. Use `--scope workspace` to
|
||||
install it only for the current project.
|
||||
|
||||
### Link for development
|
||||
|
||||
If you are developing a skill, use the `link` command to create a reference to
|
||||
your local directory:
|
||||
|
||||
```bash
|
||||
gemini skills link ./path/to/my-skill
|
||||
```
|
||||
|
||||
### Uninstall a skill
|
||||
|
||||
To completely remove an installed or linked skill:
|
||||
|
||||
```bash
|
||||
gemini skills uninstall <name>
|
||||
```
|
||||
|
||||
## Security and consent
|
||||
|
||||
Agent Skills can execute scripts and access your files. To protect your
|
||||
environment:
|
||||
|
||||
1. **Installation consent**: When installing from a remote URL, you will be
|
||||
asked to confirm the source.
|
||||
2. **Activation consent**: Every time a skill is triggered during a session,
|
||||
the agent must ask for permission to activate it and gain access to its
|
||||
resources.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
walkthrough for creating your first skill.
|
||||
- [Creating Agent Skills](./creating-skills.md): Detailed guide on bundling
|
||||
scripts and assets.
|
||||
- [Skill best practices](./skills-best-practices.md): Strategies for building
|
||||
reliable expertise.
|
||||
@@ -111,6 +111,11 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **Description:** Manage custom slash commands loaded from `.toml` files.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List available custom command `.toml` files from all
|
||||
sources (user-level `~/.gemini/commands/`, project-level
|
||||
`<project>/.gemini/commands/`, and active extensions).
|
||||
- **Usage:** `/commands list`
|
||||
- **`reload`**:
|
||||
- **Description:** Reload custom command definitions from all sources
|
||||
(user-level `~/.gemini/commands/`, project-level
|
||||
@@ -504,12 +509,12 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
|
||||
in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
|
||||
last undone action in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z**
|
||||
(Linux/WSL) to redo the last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
|
||||
@@ -1752,6 +1752,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
["DEBUG", "DEBUG_MODE"]
|
||||
```
|
||||
|
||||
- **`advanced.ignoreLocalEnv`** (boolean):
|
||||
- **Description:** Whether to ignore generic .env files in the project
|
||||
directory.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`advanced.bugCommand`** (object):
|
||||
- **Description:** Configuration for the bug report command.
|
||||
- **Default:** `undefined`
|
||||
@@ -1759,8 +1765,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.gemma`** (boolean):
|
||||
- **Description:** Enable access to Gemma 4 models (experimental).
|
||||
- **Default:** `false`
|
||||
- **Description:** Enable access to Gemma 4 models via Gemini API.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.voiceMode`** (boolean):
|
||||
@@ -1774,7 +1780,9 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **Description:** The backend to use for voice transcription. Note: When
|
||||
using the Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
@@ -1925,8 +1933,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
- **Description:** Automatically extract reusable skills from past sessions in
|
||||
the background. Review results with /memory inbox.
|
||||
- **Description:** Automatically extract memory patches and skills from past
|
||||
sessions in the background. Every change is written as a unified diff
|
||||
`.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review
|
||||
in /memory inbox; nothing is applied until you approve it.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -2580,7 +2590,6 @@ for that specific session.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
|
||||
@@ -39,7 +39,7 @@ available combinations.
|
||||
| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`<br />`Alt+Delete`<br />`Alt+D` |
|
||||
| `edit.deleteLeft` | Delete the character to the left. | `Backspace`<br />`Ctrl+H` |
|
||||
| `edit.deleteRight` | Delete the character to the right. | `Delete`<br />`Ctrl+D` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`<br />`Alt+Z` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`<br />`Alt+Z`<br />`Cmd/Win+Z` |
|
||||
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`<br />`Shift+Cmd/Win+Z`<br />`Alt+Shift+Z` |
|
||||
|
||||
#### Scrolling
|
||||
|
||||
@@ -71,7 +71,9 @@ primary conditions are the tool's name and its arguments.
|
||||
|
||||
#### Tool Name
|
||||
|
||||
The `toolName` in the rule must match the name of the tool being called.
|
||||
The `toolName` in the rule must match the name of the tool being called. For a
|
||||
complete list of built-in tool names, see the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
- **Wildcards**: You can use wildcards to match multiple tools.
|
||||
- `*`: Matches **any tool** (built-in or MCP).
|
||||
@@ -87,7 +89,9 @@ The `toolName` in the rule must match the name of the tool being called.
|
||||
|
||||
If `argsPattern` is specified, the tool's arguments are converted to a stable
|
||||
JSON string, which is then tested against the provided regular expression. If
|
||||
the arguments don't match the pattern, the rule does not apply.
|
||||
the arguments don't match the pattern, the rule does not apply. For a list of
|
||||
argument keys available for each tool, see the **Parameters** in the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
#### Execution environment
|
||||
|
||||
@@ -279,7 +283,11 @@ directory are **ignored**.
|
||||
|
||||
### TOML rule schema
|
||||
|
||||
Here is a breakdown of the fields available in a TOML policy rule:
|
||||
This section describes the fields available in a TOML policy rule.
|
||||
|
||||
For valid built-in `toolName` values and their argument structures (used by
|
||||
`argsPattern`), see the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
@@ -365,6 +373,9 @@ priority = 10
|
||||
|
||||
To simplify writing policies for `run_shell_command`, you can use
|
||||
`commandPrefix` or `commandRegex` instead of the more complex `argsPattern`.
|
||||
These are policy-rule shorthands, not arguments of the `run_shell_command` tool
|
||||
itself. For the tool's invocation arguments, see [Shell tool](/docs/tools/shell)
|
||||
and [Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
- `commandPrefix`: Matches if the `command` argument starts with the given
|
||||
string.
|
||||
|
||||
@@ -154,6 +154,55 @@ each tool.
|
||||
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
|
||||
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (for example, localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. |
|
||||
|
||||
### Tool argument keys
|
||||
|
||||
When writing [`argsPattern`](./policy-engine.md#arguments-pattern) rules for the
|
||||
[policy engine](./policy-engine.md), you need to know the JSON argument keys for
|
||||
each tool. The following table lists the keys that appear in the JSON
|
||||
representation of each tool's arguments.
|
||||
|
||||
| Tool | JSON argument keys |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `run_shell_command` | `command`, `description`, `dir_path`, `is_background` |
|
||||
| `glob` | `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
|
||||
| `grep_search` | `pattern`, `dir_path`, `include_pattern`, `exclude_pattern`, `names_only`, `case_sensitive`, `fixed_strings`, `context`, `after`, `before`, `no_ignore`, `max_matches_per_file`, `total_max_matches` |
|
||||
| `list_directory` | `dir_path`, `ignore`, `file_filtering_options` |
|
||||
| `read_file` | `file_path`, `start_line`, `end_line` |
|
||||
| `read_many_files` | `include`, `exclude`, `recursive`, `useDefaultExcludes` |
|
||||
| `write_file` | `file_path`, `content` |
|
||||
| `replace` | `file_path`, `old_string`, `new_string`, `instruction`, `allow_multiple` |
|
||||
| `ask_user` | `questions` (array of `question`, `header`, `type`, `options`) |
|
||||
| `write_todos` | `todos` (array of `description`, `status`) |
|
||||
| `save_memory` | `fact` |
|
||||
| `activate_skill` | `name` |
|
||||
| `get_internal_docs` | `path` |
|
||||
| `enter_plan_mode` | `reason` |
|
||||
| `exit_plan_mode` | `plan_path` |
|
||||
| `tracker_create_task` | `title`, `description`, `type` |
|
||||
| `tracker_update_task` | `id`, `title`, `description`, `status`, `dependencies` |
|
||||
| `tracker_get_task` | `id` |
|
||||
| `tracker_list_tasks` | `status`, `type`, `parentId` |
|
||||
| `tracker_add_dependency` | `taskId`, `dependencyId` |
|
||||
| `tracker_visualize` | _(none)_ |
|
||||
| `update_topic` | `title`, `summary`, `strategic_intent` |
|
||||
| `google_web_search` | `query` |
|
||||
| `web_fetch` | `prompt` |
|
||||
|
||||
For example, to write a policy rule that blocks any `write_file` call targeting
|
||||
a `.env` file, you would match against the `file_path` key:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
argsPattern = '"file_path":".*\.env"'
|
||||
decision = "deny"
|
||||
priority = 100
|
||||
denyMessage = "Writing to .env files is not allowed."
|
||||
```
|
||||
|
||||
For full argument descriptions and types, see the individual tool pages linked
|
||||
in the [tables above](#available-tools).
|
||||
|
||||
## Under the hood
|
||||
|
||||
For developers, the tool system is designed to be extensible and robust. The
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
Our release flows support both `dev` and `prod` environments.
|
||||
|
||||
The `dev` environment pushes to a private Github-hosted NPM repository, with the
|
||||
The `dev` environment pushes to a private GitHub-hosted NPM repository, with the
|
||||
package names beginning with `@google-gemini/**` instead of `@google/**`.
|
||||
|
||||
The `prod` environment pushes to the public global NPM registry via Wombat
|
||||
@@ -20,7 +20,7 @@ More information can be found about these systems in the
|
||||
|
||||
### Package scopes
|
||||
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (Github Private NPM Repo) |
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (GitHub Private NPM Repo) |
|
||||
| ---------- | ----------------------------- | ----------------------------------------- |
|
||||
| CLI | @google/gemini-cli | @google-gemini/gemini-cli |
|
||||
| Core | @google/gemini-cli-core | @google-gemini/gemini-cli-core A2A Server |
|
||||
|
||||
+24
-2
@@ -27,7 +27,7 @@
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
},
|
||||
{
|
||||
"label": "Get started with Agent skills",
|
||||
"label": "Get started with Agent Skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
@@ -95,7 +95,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Agent Skills",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Get started with Agent Skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
"label": "Creating Agent Skills",
|
||||
"slug": "docs/cli/creating-skills"
|
||||
},
|
||||
{
|
||||
"label": "Using Agent Skills",
|
||||
"slug": "docs/cli/using-agent-skills"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/cli/skills-best-practices"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Auto Memory",
|
||||
"badge": "🔬",
|
||||
|
||||
@@ -40,4 +40,4 @@ The agent uses this tool to provide professional-grade assistance:
|
||||
## Next steps
|
||||
|
||||
- Learn how to [Use Agent Skills](../cli/skills.md).
|
||||
- See the [Creating Agent Skills](../cli/creating-skills.md) guide.
|
||||
- See the [Build agent skills](../cli/creating-skills.md) guide.
|
||||
|
||||
@@ -19,6 +19,23 @@ platforms, they execute with `bash -c`.
|
||||
- `is_background` (boolean, optional): Whether to move the process to the
|
||||
background immediately after starting.
|
||||
|
||||
### Policy engine shorthands
|
||||
|
||||
The [policy engine](../reference/policy-engine.md) provides two convenience
|
||||
fields for writing rules that target shell commands:
|
||||
|
||||
- `commandPrefix`: Matches if the `command` argument starts with a given string.
|
||||
- `commandRegex`: Matches if the `command` argument matches a given regular
|
||||
expression.
|
||||
|
||||
These are syntactic sugar for combining `toolName = "run_shell_command"` with an
|
||||
`argsPattern` in a policy TOML file. They are **not** arguments of
|
||||
`run_shell_command` itself.
|
||||
|
||||
For details on writing shell-specific policy rules, see
|
||||
[Special syntax for `run_shell_command`](../reference/policy-engine.md#special-syntax-for-run_shell_command)
|
||||
in the policy engine reference.
|
||||
|
||||
### Return values
|
||||
|
||||
The tool returns a JSON object containing:
|
||||
|
||||
@@ -54,6 +54,7 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-LLM evals that pin down the auto-memory inbox contract:
|
||||
* 1. Canonical filename — agent uses `.inbox/<kind>/extraction.patch`.
|
||||
* 2. Incremental merge — agent rewrites an existing extraction.patch
|
||||
* instead of creating new patch files alongside.
|
||||
* 3. Absolute-path pointers — when the agent creates a sibling .md, the
|
||||
* paired MEMORY.md hunk references it by absolute path.
|
||||
* 4. Project-root protection — agent never writes to
|
||||
* `<projectRoot>/GEMINI.md` even when content is team-shared.
|
||||
*
|
||||
* Each test seeds session transcripts with strong, consistent signal so the
|
||||
* extraction agent will reasonably produce SOME output (or, in the human-only
|
||||
* test, refrain from producing output that targets forbidden paths). Tests
|
||||
* are USUALLY_PASSES policy because LLM behavior is stochastic; the harness
|
||||
* already retries up to 3 times.
|
||||
*/
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
/** Minutes ago the session ended (must be ≥ 180 to clear the idle gate). */
|
||||
timestampOffsetMinutes: number;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'auto-memory-contract-eval',
|
||||
private: true,
|
||||
scripts: { build: 'echo build', test: 'echo test' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'README.md': '# Auto Memory Contract Eval\n\nFixture workspace.\n',
|
||||
};
|
||||
|
||||
const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
experimentalAutoMemory: true,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
|
||||
return userTurns.flatMap((text, index) => [
|
||||
{
|
||||
id: `u${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'user',
|
||||
content: [{ text }],
|
||||
},
|
||||
{
|
||||
id: `a${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Acknowledged.' }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
): Promise<void> {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionTimestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
|
||||
const conversation = {
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(chatsDir, filename),
|
||||
JSON.stringify(conversation, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InboxSnapshot {
|
||||
privateFiles: string[];
|
||||
globalFiles: string[];
|
||||
privateContents: Map<string, string>;
|
||||
}
|
||||
|
||||
async function snapshotInbox(config: Config): Promise<InboxSnapshot> {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inbox: InboxSnapshot = {
|
||||
privateFiles: [],
|
||||
globalFiles: [],
|
||||
privateContents: new Map(),
|
||||
};
|
||||
for (const kind of ['private', 'global'] as const) {
|
||||
const dir = path.join(memoryDir, '.inbox', kind);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fsp.readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const patchFiles = entries.filter((f) => f.endsWith('.patch')).sort();
|
||||
if (kind === 'private') {
|
||||
inbox.privateFiles = patchFiles;
|
||||
for (const fileName of patchFiles) {
|
||||
try {
|
||||
inbox.privateContents.set(
|
||||
fileName,
|
||||
await fsp.readFile(path.join(dir, fileName), 'utf-8'),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inbox.globalFiles = patchFiles;
|
||||
}
|
||||
}
|
||||
return inbox;
|
||||
}
|
||||
|
||||
describe('Auto Memory Contract', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses canonical extraction.patch filename when writing private memory',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-1',
|
||||
summary:
|
||||
'Confirm that this project verifies memory edits with `npm run verify:memory`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'For this project, every memory-system change is verified with `npm run verify:memory` before we hand the change back.',
|
||||
'That command is the gate. Without it the change is not considered done.',
|
||||
'It runs typechecks, the related unit tests, and a snapshot diff.',
|
||||
'Future agents working on memory should always run it after editing memoryService or commands/memory.ts.',
|
||||
'This is a durable rule for this project, not a one-off.',
|
||||
'The check is fast, under a minute, and failure means revert.',
|
||||
'Treat it as part of the memory subsystem contract.',
|
||||
'I want this remembered for next time.',
|
||||
'It applies to anything in packages/core/src/services/memoryService.ts and packages/core/src/commands/memory.ts.',
|
||||
'Make sure agents do not skip the verify step.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-2',
|
||||
summary: 'Same memory-verify command in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I had to remind the previous agent to run `npm run verify:memory` again.',
|
||||
'It is the durable verification command for memory edits in this repo.',
|
||||
'The agent forgot, even though we agreed last time.',
|
||||
'Please remember it for future memory-related work.',
|
||||
'It is the official verification step for memory changes.',
|
||||
'Run it whenever you touch memoryService.ts or commands/memory.ts.',
|
||||
'No exceptions. The command must finish green.',
|
||||
'This is a recurring rule across multiple sessions now.',
|
||||
'Make this part of your standard workflow for memory work.',
|
||||
'Verified again that the command catches regressions in MEMORY.md handling.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Either the agent extracted nothing (acceptable no-op) OR it extracted
|
||||
// exactly one canonical file per kind. Multiple files per kind violates
|
||||
// the contract.
|
||||
expect(inbox.privateFiles.length).toBeLessThanOrEqual(1);
|
||||
expect(inbox.globalFiles.length).toBeLessThanOrEqual(1);
|
||||
|
||||
// Strong assertion: when the agent DID write a private patch, it must
|
||||
// be the canonical filename.
|
||||
if (inbox.privateFiles.length === 1) {
|
||||
expect(inbox.privateFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
if (inbox.globalFiles.length === 1) {
|
||||
expect(inbox.globalFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'merges new findings into existing extraction.patch instead of creating new files',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
|
||||
await fsp.mkdir(inboxPrivate, { recursive: true });
|
||||
|
||||
// Pre-existing canonical patch left over from a prior session.
|
||||
const existingMemoryMd = path.join(memoryDir, 'MEMORY.md');
|
||||
const preExistingPatch = [
|
||||
`--- /dev/null`,
|
||||
`+++ ${existingMemoryMd}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory`,
|
||||
`+`,
|
||||
`+- This project lints with \`npm run lint\` (recurring rule from session 1).`,
|
||||
``,
|
||||
].join('\n');
|
||||
await fsp.writeFile(
|
||||
path.join(inboxPrivate, 'extraction.patch'),
|
||||
preExistingPatch,
|
||||
);
|
||||
|
||||
// New session that surfaces a different durable fact.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'incremental-typecheck-cmd',
|
||||
summary:
|
||||
'Confirm that typecheck for memory edits uses `npm run typecheck`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Always run `npm run typecheck` after editing any *.ts file in this repo.',
|
||||
'It is the standard typecheck command for the whole monorepo.',
|
||||
'Future agents should follow this without being reminded.',
|
||||
'It catches type errors before tests, much faster.',
|
||||
'Run it on every TypeScript edit, no exceptions.',
|
||||
'This is durable across the whole project.',
|
||||
'It is the project-wide convention for TS work.',
|
||||
'Make sure to run it after edits to memoryService.ts especially.',
|
||||
'It is fast and catches regressions early.',
|
||||
'Treat it as standard workflow.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Contract: still ONLY ONE file in private inbox, and its name is the
|
||||
// canonical extraction.patch.
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
// The single canonical patch must STILL contain the old hunk (the
|
||||
// agent must merge with existing rather than replace blindly), AND
|
||||
// ideally also contain the new typecheck fact.
|
||||
const merged = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
expect(merged).toMatch(/npm run lint/);
|
||||
// Soft assertion: the agent SHOULD have added the new fact too. We
|
||||
// don't fail the test if it didn't (the agent may legitimately decide
|
||||
// the new fact isn't durable enough), but the file must be intact.
|
||||
// The hard assertion (no proliferation + old content preserved) is
|
||||
// what we lock down.
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses absolute paths in MEMORY.md sibling pointer lines',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions whose extracted memory has substantial detail — encourages
|
||||
// the agent to spawn a sibling .md file (per prompt guidance).
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-1',
|
||||
summary: 'Detailed release workflow that runs across multiple steps',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Our release workflow has several distinct phases that future agents need to follow exactly.',
|
||||
'Phase 1 (preflight): run `npm run lint`, `npm run typecheck`, and `npm test` in that order.',
|
||||
'Phase 2 (build): run `npm run build` and verify dist/ outputs against a checksum file.',
|
||||
'Phase 3 (publish): run `npm run publish:dry-run` first, then `npm run publish` if no errors.',
|
||||
'Phase 4 (post): tag the commit with `git tag v$(jq -r .version package.json)` and push.',
|
||||
'There are pitfalls: phase 2 will silently succeed if dist/ is stale, so always check the checksum.',
|
||||
'Phase 3 must NEVER be skipped for hotfixes; the dry-run catches credential issues.',
|
||||
'The checklist is durable across all releases for this repo.',
|
||||
'Future agents should reproduce these phases in order without omitting any.',
|
||||
'This is the canonical release procedure for this project.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-2',
|
||||
summary: 'Reusing the same multi-phase release workflow',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I just ran the release workflow again and it caught an issue in phase 2 because the checksum mismatched.',
|
||||
'Confirms the durable rule: always check the dist/ checksum after building.',
|
||||
'The 4-phase release procedure (preflight, build, publish, post) is the recurring workflow.',
|
||||
'I want this captured as durable memory because we use it every release.',
|
||||
'Each phase has multiple sub-steps and pitfalls, so it deserves substantial detail.',
|
||||
'Please remember the phases for future agents.',
|
||||
'The procedure has been the same for the last 6 releases.',
|
||||
'It includes the verify-checksum step that just saved us from a bad publish.',
|
||||
'This is a recurring multi-step workflow, not a one-off.',
|
||||
'Make sure future sessions know about all 4 phases and their pitfalls.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
|
||||
// The agent might choose to add brief facts directly to MEMORY.md
|
||||
// without spawning a sibling. That's a valid outcome; we only enforce
|
||||
// the absolute-path rule WHEN a sibling is created.
|
||||
if (inbox.privateFiles.length === 0) {
|
||||
return; // No-op extraction: nothing to assert.
|
||||
}
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
const patch = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
|
||||
// Find any /dev/null sibling-creation hunk that targets <memoryDir>/<x>.md
|
||||
// (where x != MEMORY).
|
||||
const siblingPattern = new RegExp(
|
||||
`\\+\\+\\+ ${memoryDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}/([^\\s/]+)\\.md`,
|
||||
'g',
|
||||
);
|
||||
const siblingTargets: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = siblingPattern.exec(patch)) !== null) {
|
||||
const name = match[1];
|
||||
// Skip MEMORY.md updates (those aren't siblings).
|
||||
if (name.toLowerCase() !== 'memory') {
|
||||
siblingTargets.push(`${name}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
if (siblingTargets.length === 0) {
|
||||
return; // No sibling creations; nothing more to check.
|
||||
}
|
||||
|
||||
// For each created sibling, the patch must contain a MEMORY.md
|
||||
// pointer line that uses the ABSOLUTE path. Bare basename references
|
||||
// are the bug we're guarding against.
|
||||
for (const sibling of siblingTargets) {
|
||||
const absolutePath = path.join(memoryDir, sibling);
|
||||
// Look for an added line referencing the sibling.
|
||||
const addedLines = patch
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('+'));
|
||||
const referencingLines = addedLines.filter((line) =>
|
||||
line.includes(sibling),
|
||||
);
|
||||
expect(
|
||||
referencingLines.length,
|
||||
`Expected a MEMORY.md pointer for ${sibling} (auto-bundle would also add one).`,
|
||||
).toBeGreaterThan(0);
|
||||
const allAbsolute = referencingLines.every((line) =>
|
||||
line.includes(absolutePath),
|
||||
);
|
||||
expect(
|
||||
allAbsolute,
|
||||
`Pointer for ${sibling} must use absolute path. Saw: ${referencingLines.join(' | ')}`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'never writes to <projectRoot>/GEMINI.md even for team-shared facts',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions that talk about TEAM CONVENTIONS — the kind of content that
|
||||
// would be a perfect fit for <projectRoot>/GEMINI.md, but the prompt
|
||||
// forbids the extraction agent from touching it.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-1',
|
||||
summary: 'Team convention: always use pnpm not npm for installs',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Important team-wide convention for this repo: always use pnpm for installs, never npm.',
|
||||
'This is a shared rule across all engineers on the project.',
|
||||
'It applies to every package install, every clean, every dependency add.',
|
||||
'The rationale is workspace hoisting; npm would break the monorepo layout.',
|
||||
'This is a durable team rule, committed to the repo conventions.',
|
||||
'Future agents working in this repo should ALWAYS use pnpm.',
|
||||
'It is the standard team practice, no exceptions.',
|
||||
'Document it as part of the project conventions.',
|
||||
'Treat it as a hard rule for the team.',
|
||||
'I want this captured for future sessions.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-2',
|
||||
summary: 'Reaffirming the pnpm-only team rule in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'Reminder again: this team uses pnpm exclusively, never npm.',
|
||||
'Another agent tried npm install and broke the lockfile.',
|
||||
'The team rule is clear: pnpm only for any install operation.',
|
||||
'It is part of our shared conventions for this codebase.',
|
||||
'Make sure future agents follow this team-wide rule.',
|
||||
'It applies to all engineers, all CI runs, all dev environments.',
|
||||
'The convention is durable and well-established for this repo.',
|
||||
'Agents should read this rule from project conventions before installing.',
|
||||
'No future agent should ever invoke `npm install` in this repo.',
|
||||
'Always pnpm. Always.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
// No private patch should target <projectRoot>/GEMINI.md or any
|
||||
// subdirectory GEMINI.md.
|
||||
const projectRootRegex = new RegExp(
|
||||
`\\+\\+\\+ ${projectRoot.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}.*GEMINI\\.md`,
|
||||
);
|
||||
for (const [name, content] of inbox.privateContents) {
|
||||
expect(
|
||||
projectRootRegex.test(content),
|
||||
`Private patch "${name}" must not target a GEMINI.md under <projectRoot>. Content:\n${content}`,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Verify on disk: <projectRoot>/GEMINI.md was not created or modified
|
||||
// by the extraction agent (snapshot rollback should also enforce this,
|
||||
// but we double-check from the post-run state).
|
||||
const projectGemini = path.join(projectRoot, 'GEMINI.md');
|
||||
const exists = await fsp
|
||||
.access(projectGemini)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
// The seeded workspace's WORKSPACE_FILES doesn't include GEMINI.md, so
|
||||
// it must NOT exist after the run.
|
||||
expect(
|
||||
exists,
|
||||
`<projectRoot>/GEMINI.md (${projectGemini}) must not be created by the extraction agent.`,
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest';
|
||||
import { runEval } from './test-helper.js';
|
||||
import { SESSION_FILE_PREFIX } from '../packages/core/src/services/chatRecordingService.js';
|
||||
|
||||
const evalState = vi.hoisted(() => ({
|
||||
sessionFilePath: '',
|
||||
debugLines: [] as string[],
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
localAgentCreate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.js', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.ts', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.js', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.ts', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.ts', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockMemoryConfig {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => string;
|
||||
getProjectMemoryTempDir: () => string;
|
||||
getProjectSkillsMemoryDir: () => string;
|
||||
getProjectTempDir: () => string;
|
||||
getProjectRoot: () => string;
|
||||
};
|
||||
getTargetDir: () => string;
|
||||
getToolRegistry: () => unknown;
|
||||
getGeminiClient: () => unknown;
|
||||
getSkillManager: () => { getSkills: () => unknown[] };
|
||||
isAutoMemoryEnabled: () => boolean;
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
sandboxManager: undefined;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
rootDir: string;
|
||||
homeDir: string;
|
||||
targetDir: string;
|
||||
projectTempDir: string;
|
||||
memoryDir: string;
|
||||
skillsDir: string;
|
||||
config: MockMemoryConfig;
|
||||
}
|
||||
|
||||
interface AutoMemoryRunSnapshot {
|
||||
sessionIds?: string[];
|
||||
memoryCandidatesCreated?: string[];
|
||||
memoryFilesUpdated?: string[];
|
||||
skillsCreated?: string[];
|
||||
}
|
||||
|
||||
const fixtures: Fixture[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
evalState.debugLines = [];
|
||||
evalState.sessionFilePath = '';
|
||||
mocks.localAgentCreate.mockReset();
|
||||
mocks.localAgentCreate.mockImplementation(
|
||||
async (_agent, context, onActivity) => ({
|
||||
run: vi.fn().mockImplementation(async () => {
|
||||
if (evalState.sessionFilePath) {
|
||||
const callId = `read-inbox-routing`;
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
callId,
|
||||
args: { file_path: evalState.sessionFilePath },
|
||||
},
|
||||
});
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: { id: callId, data: { isError: false } },
|
||||
});
|
||||
}
|
||||
|
||||
const config = context.config as MockMemoryConfig;
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxDir = path.join(memoryDir, '.inbox');
|
||||
|
||||
const homeDir = process.env['GEMINI_CLI_HOME'] ?? os.homedir();
|
||||
const globalGeminiDir = path.join(homeDir, '.gemini');
|
||||
|
||||
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
|
||||
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
|
||||
|
||||
const privateTarget = path.join(memoryDir, 'verify-memory.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'private', 'verify-memory.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${privateTarget}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory Candidate`,
|
||||
`+`,
|
||||
`+Future agents should remember that this project verifies memory changes with \`npm run verify:memory\`.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const globalTarget = path.join(globalGeminiDir, 'GEMINI.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'global', 'reply-style.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${globalTarget}`,
|
||||
`@@ -0,0 +1,1 @@`,
|
||||
`+User prefers concise Chinese architecture plans.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return {
|
||||
turn_count: 3,
|
||||
duration_ms: 25,
|
||||
terminate_reason: 'GOAL',
|
||||
};
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (fixtures.length > 0) {
|
||||
const fixture = fixtures.pop();
|
||||
if (fixture) {
|
||||
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function autoMemoryEval(name: string, fn: () => Promise<void>): void {
|
||||
runEval(
|
||||
'USUALLY_PASSES',
|
||||
{
|
||||
suiteName: 'auto-memory-modes',
|
||||
suiteType: 'component-level',
|
||||
name,
|
||||
timeout: 30000,
|
||||
},
|
||||
fn,
|
||||
40000,
|
||||
);
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const rootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-auto-memory-eval-'),
|
||||
);
|
||||
const homeDir = path.join(rootDir, 'home');
|
||||
const targetDir = path.join(rootDir, 'workspace');
|
||||
const projectTempDir = path.join(rootDir, 'project-temp');
|
||||
const memoryDir = path.join(projectTempDir, 'memory');
|
||||
const skillsDir = path.join(memoryDir, 'skills');
|
||||
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.mkdir(path.join(projectTempDir, 'chats'), { recursive: true });
|
||||
vi.stubEnv('GEMINI_CLI_HOME', homeDir);
|
||||
|
||||
const config: MockMemoryConfig = {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => memoryDir,
|
||||
getProjectMemoryTempDir: () => memoryDir,
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectTempDir: () => projectTempDir,
|
||||
getProjectRoot: () => targetDir,
|
||||
},
|
||||
getTargetDir: () => targetDir,
|
||||
getToolRegistry: () => ({}),
|
||||
getGeminiClient: () => ({}),
|
||||
getSkillManager: () => ({ getSkills: () => [] }),
|
||||
isAutoMemoryEnabled: () => true,
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: vi.fn(),
|
||||
},
|
||||
sandboxManager: undefined,
|
||||
};
|
||||
|
||||
const fixture = {
|
||||
rootDir,
|
||||
homeDir,
|
||||
targetDir,
|
||||
projectTempDir,
|
||||
memoryDir,
|
||||
skillsDir,
|
||||
config,
|
||||
};
|
||||
fixtures.push(fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function seedSession(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<string> {
|
||||
const sessionFilePath = path.join(
|
||||
fixture.projectTempDir,
|
||||
'chats',
|
||||
`${SESSION_FILE_PREFIX}2026-04-20T10-00-${sessionId}.json`,
|
||||
);
|
||||
const oldTimestamp = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
|
||||
const messages = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `m${index + 1}`,
|
||||
timestamp: oldTimestamp,
|
||||
type: index % 2 === 0 ? 'user' : 'gemini',
|
||||
content: [
|
||||
{
|
||||
text:
|
||||
index % 2 === 0
|
||||
? 'For this project, durable memory changes are verified with `npm run verify:memory`.'
|
||||
: 'Acknowledged.',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFilePath,
|
||||
[
|
||||
{
|
||||
sessionId,
|
||||
projectHash: 'auto-memory-eval',
|
||||
summary: 'Capture durable auto memory routing behavior',
|
||||
startTime: oldTimestamp,
|
||||
lastUpdated: oldTimestamp,
|
||||
kind: 'main',
|
||||
},
|
||||
...messages,
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
return sessionFilePath;
|
||||
}
|
||||
|
||||
async function expectSeedSessionEligible(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const { buildSessionIndex } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const { newSessionIds } = await buildSessionIndex(
|
||||
path.join(fixture.projectTempDir, 'chats'),
|
||||
{ runs: [] },
|
||||
);
|
||||
expect(newSessionIds).toContain(sessionId);
|
||||
}
|
||||
|
||||
async function readRun(fixture: Fixture): Promise<AutoMemoryRunSnapshot> {
|
||||
const statePath = path.join(fixture.memoryDir, '.extraction-state.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(statePath, 'utf-8');
|
||||
} catch (error) {
|
||||
let memoryEntries = '(memory dir missing)';
|
||||
try {
|
||||
memoryEntries = (await fs.readdir(fixture.memoryDir, { recursive: true }))
|
||||
.map(String)
|
||||
.join('\n');
|
||||
} catch {
|
||||
// Leave default diagnostic.
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`Expected extraction state at ${statePath}.`,
|
||||
`LocalAgentExecutor.create calls: ${mocks.localAgentCreate.mock.calls.length}`,
|
||||
`Memory dir entries:\n${memoryEntries}`,
|
||||
`Debug log:\n${evalState.debugLines.join('\n')}`,
|
||||
].join('\n'),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: AutoMemoryRunSnapshot[];
|
||||
};
|
||||
const run = state.runs?.at(-1);
|
||||
if (!run) {
|
||||
throw new Error('Expected an auto memory extraction run to be recorded');
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auto Memory inbox routing', () => {
|
||||
autoMemoryEval(
|
||||
'every memory patch lands in .inbox/<kind>/ for review and active files stay untouched',
|
||||
async () => {
|
||||
const { startMemoryService } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const fixture = await createFixture();
|
||||
evalState.sessionFilePath = await seedSession(
|
||||
fixture,
|
||||
'inbox-routing-session',
|
||||
);
|
||||
await expectSeedSessionEligible(fixture, 'inbox-routing-session');
|
||||
|
||||
await startMemoryService(fixture.config as never);
|
||||
|
||||
const privatePatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'private',
|
||||
'verify-memory.patch',
|
||||
);
|
||||
const globalPatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'global',
|
||||
'reply-style.patch',
|
||||
);
|
||||
|
||||
const activePrivateMemoryPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'verify-memory.md',
|
||||
);
|
||||
const activeGlobalMemoryPath = path.join(
|
||||
fixture.homeDir,
|
||||
'.gemini',
|
||||
'GEMINI.md',
|
||||
);
|
||||
const run = await readRun(fixture);
|
||||
|
||||
// Both patches were written to the inbox.
|
||||
await expect(fs.readFile(privatePatchPath, 'utf-8')).resolves.toContain(
|
||||
'npm run verify:memory',
|
||||
);
|
||||
await expect(fs.readFile(globalPatchPath, 'utf-8')).resolves.toContain(
|
||||
'concise Chinese architecture plans',
|
||||
);
|
||||
|
||||
// No active file was touched — every patch must be reviewed manually.
|
||||
expect(await fileExists(activePrivateMemoryPath)).toBe(false);
|
||||
expect(await fileExists(activeGlobalMemoryPath)).toBe(false);
|
||||
|
||||
// Run state records both patches as candidates and zero applied files.
|
||||
expect(run.memoryFilesUpdated ?? []).toEqual([]);
|
||||
expect(run.memoryCandidatesCreated ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.relative(fixture.memoryDir, privatePatchPath),
|
||||
path.relative(fixture.memoryDir, globalPatchPath),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('file_creation_behavior', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should create a new file in the correct directory when asked',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
'src/index.ts': 'console.log("hello");',
|
||||
},
|
||||
prompt:
|
||||
'Please create a new file called src/logger.ts containing a simple logging class. Do not modify any existing files.',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify write_file tool was called
|
||||
const logs = rig.readToolLogs();
|
||||
const writeFileCalls = logs.filter(
|
||||
(log) => log.toolRequest?.name === 'write_file',
|
||||
);
|
||||
expect(
|
||||
writeFileCalls.length,
|
||||
'Expected a write_file call to create the new file',
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 2) Verify existing files were not modified
|
||||
const indexContent = rig.readFile('src/index.ts');
|
||||
expect(indexContent).toBe('console.log("hello");');
|
||||
|
||||
const pkgContent = rig.readFile('package.json');
|
||||
expect(JSON.parse(pkgContent).name).toBe('test-project');
|
||||
|
||||
// 3) Verify new file is created
|
||||
const loggerContent = rig.readFile('src/logger.ts');
|
||||
expect(loggerContent.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not overwrite existing file when creating new file with same name',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
'config.json': JSON.stringify({ port: 3000, env: 'production' }),
|
||||
},
|
||||
prompt:
|
||||
"Please create a new configuration file called config.json in the workspace. Ensure the port is set to 8080. Since there's already a config file there, make sure to check it first before making changes.",
|
||||
assert: async (rig) => {
|
||||
// Verify that read_file was called on config.json before write_file
|
||||
const logs = rig.readToolLogs();
|
||||
const targetReadFileIndex = logs.findIndex((log) => {
|
||||
if (log.toolRequest?.name !== 'read_file') return false;
|
||||
try {
|
||||
const args =
|
||||
typeof log.toolRequest.args === 'string'
|
||||
? JSON.parse(log.toolRequest.args)
|
||||
: log.toolRequest.args;
|
||||
return args.file_path === 'config.json';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const targetWriteFileIndex = logs.findIndex((log) => {
|
||||
if (log.toolRequest?.name !== 'write_file') return false;
|
||||
try {
|
||||
const args =
|
||||
typeof log.toolRequest.args === 'string'
|
||||
? JSON.parse(log.toolRequest.args)
|
||||
: log.toolRequest.args;
|
||||
return args.file_path === 'config.json';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
targetReadFileIndex,
|
||||
'Expected read_file to be called to inspect config.json before overwriting it',
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
if (targetWriteFileIndex !== -1) {
|
||||
expect(
|
||||
targetReadFileIndex,
|
||||
'Expected read_file to be invoked before write_file for safety',
|
||||
).toBeLessThan(targetWriteFileIndex);
|
||||
}
|
||||
|
||||
// Also check the resulting config.json content
|
||||
const configContent = rig.readFile('config.json');
|
||||
expect(configContent).toContain('8080');
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should scaffold multiple related files in correct locations',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
},
|
||||
prompt:
|
||||
'Please scaffold auth validation and types by creating two new files: src/auth/validator.ts and src/auth/types.ts with relevant exports. Do not modify existing files.',
|
||||
assert: async (rig) => {
|
||||
// Verify files are created in right place
|
||||
const validatorContent = rig.readFile('src/auth/validator.ts');
|
||||
const typesContent = rig.readFile('src/auth/types.ts');
|
||||
|
||||
expect(validatorContent.length).toBeGreaterThan(0);
|
||||
expect(typesContent.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -78,4 +78,37 @@ describe('git repo eval', () => {
|
||||
expect(commitCalls.length).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the agent is prompted to commit its changes, it does not
|
||||
* use `git add .` or `git add -A`.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not stage changes via git add . when prompted to commit',
|
||||
prompt:
|
||||
'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, stage and commit your changes.',
|
||||
files: FILES,
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const gitAddAllCalls = toolLogs.filter((log) => {
|
||||
if (log.toolRequest.name !== 'run_shell_command') return false;
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
if (!args.command) return false;
|
||||
const cmd = args.command.toLowerCase();
|
||||
return (
|
||||
cmd.includes('git add .') ||
|
||||
cmd.includes('git add -a') ||
|
||||
cmd.includes('git add --all')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(gitAddAllCalls.length).toBe(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@ export class GeminiAgent {
|
||||
this.sessionManager = new AcpSessionManager(settings, argv, connection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.sessionManager.dispose();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
|
||||
@@ -564,4 +564,26 @@ describe('Session', () => {
|
||||
|
||||
expect(result.stopReason).toBe('max_turn_requests');
|
||||
});
|
||||
|
||||
it('should send sessionUpdate when approval mode changes', async () => {
|
||||
const { coreEvents, CoreEvent, ApprovalMode } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
|
||||
coreEvents.emit(CoreEvent.ApprovalModeChanged, {
|
||||
sessionId: 'session-1',
|
||||
mode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[MODE_UPDATE] ${ApprovalMode.PLAN}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type ApprovalMode,
|
||||
type ConversationRecord,
|
||||
CoreToolCallStatus,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
type ApprovalModeChangedPayload,
|
||||
logToolCall,
|
||||
convertToFunctionResponse,
|
||||
ToolConfirmationOutcome,
|
||||
@@ -69,7 +72,31 @@ export class Session {
|
||||
private readonly context: AgentLoopContext,
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {}
|
||||
) {
|
||||
coreEvents.on(
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
|
||||
if (payload.sessionId === this.id) {
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[MODE_UPDATE] ${payload.mode}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
coreEvents.off(
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
if (!this.pendingPrompt) {
|
||||
|
||||
@@ -48,6 +48,13 @@ export class AcpSessionManager {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
session.dispose();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
async newSession(
|
||||
{ cwd, mcpServers }: acp.NewSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
@@ -183,6 +190,12 @@ export class AcpSessionManager {
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const existingSession = this.sessions.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.dispose();
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { CommandContext } from './types.js';
|
||||
import {
|
||||
DisableExtensionCommand,
|
||||
UninstallExtensionCommand,
|
||||
} from './extensions.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
const mockGetErrorMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: mockGetErrorMessage,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => {
|
||||
class MockExtensionManager {
|
||||
disableExtension = vi.fn(async () => undefined);
|
||||
uninstallExtension = vi.fn(async () => undefined);
|
||||
getExtensions = vi.fn(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
ExtensionManager: MockExtensionManager,
|
||||
inferInstallMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type TestExtensionManager = InstanceType<typeof ExtensionManager> & {
|
||||
disableExtension: ReturnType<typeof vi.fn>;
|
||||
uninstallExtension: ReturnType<typeof vi.fn>;
|
||||
getExtensions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createExtensionManager(): TestExtensionManager {
|
||||
return new ExtensionManager({} as never) as TestExtensionManager;
|
||||
}
|
||||
|
||||
function createContext(extensionLoader: unknown): CommandContext {
|
||||
return {
|
||||
agentContext: {
|
||||
config: {
|
||||
getExtensionLoader: vi.fn().mockReturnValue(extensionLoader),
|
||||
},
|
||||
} as unknown as CommandContext['agentContext'],
|
||||
settings: {} as CommandContext['settings'],
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ACP extensions error paths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetErrorMessage.mockImplementation((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when disabling fails', async () => {
|
||||
const command = new DisableExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.disableExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['missing-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions disable',
|
||||
data: 'Failed to disable "missing-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when uninstalling a non-existent extension', async () => {
|
||||
const command = new UninstallExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.uninstallExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['non-existent-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions uninstall',
|
||||
data: 'Failed to uninstall extension "non-existent-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxMemoryPatches,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listMemoryFiles,
|
||||
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
|
||||
export class InboxMemoryCommand implements Command {
|
||||
readonly name = 'memory inbox';
|
||||
readonly description =
|
||||
'Lists skills extracted from past sessions that are pending review.';
|
||||
'Lists memory items extracted from past sessions that are pending review.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
|
||||
};
|
||||
}
|
||||
|
||||
const [skills, patches] = await Promise.all([
|
||||
const [skills, patches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(context.agentContext.config),
|
||||
listInboxPatches(context.agentContext.config),
|
||||
listInboxMemoryPatches(context.agentContext.config),
|
||||
]);
|
||||
|
||||
if (skills.length === 0 && patches.length === 0) {
|
||||
if (
|
||||
skills.length === 0 &&
|
||||
patches.length === 0 &&
|
||||
memoryPatches.length === 0
|
||||
) {
|
||||
return { name: this.name, data: 'No items in inbox.' };
|
||||
}
|
||||
|
||||
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
|
||||
: '';
|
||||
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
|
||||
}
|
||||
for (const memoryPatch of memoryPatches) {
|
||||
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = memoryPatch.extractedAt
|
||||
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
const sourceCount = memoryPatch.sourceFiles.length;
|
||||
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
|
||||
lines.push(
|
||||
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = skills.length + patches.length;
|
||||
const total = skills.length + patches.length + memoryPatches.length;
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
|
||||
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(tempWorkspaceDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -1174,6 +1174,20 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
});
|
||||
|
||||
const argv = await parseArguments(settings);
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
SimpleExtensionLoader,
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
applyRequiredServers,
|
||||
@@ -558,6 +559,8 @@ export interface LoadCliConfigOptions {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
skipMemoryLoad?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -566,7 +569,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
skipMemoryLoad = false,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -641,21 +649,24 @@ export async function loadCliConfig(
|
||||
includeDirectories.push(...ideFolders);
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
let extensionManager: ExtensionManager | undefined;
|
||||
if (!skipExtensions) {
|
||||
extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
}
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
?.getExtensions()
|
||||
?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
|
||||
@@ -673,7 +684,10 @@ export async function loadCliConfig(
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
if (!experimentalJitContext) {
|
||||
const finalExtensionLoader =
|
||||
extensionManager ?? new SimpleExtensionLoader([]);
|
||||
|
||||
if (!experimentalJitContext && !skipMemoryLoad) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
@@ -681,7 +695,7 @@ export async function loadCliConfig(
|
||||
? includeDirectories
|
||||
: [],
|
||||
fileService,
|
||||
extensionManager,
|
||||
finalExtensionLoader,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
@@ -1037,7 +1051,7 @@ export async function loadCliConfig(
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionLoader: finalExtensionLoader,
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up files if needed, or rely on temp dir cleanup
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(currentTempHome);
|
||||
await cleanupTmpDir(tempWorkspace);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -149,6 +149,35 @@ describe('consent', () => {
|
||||
expect(consent).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should clear the active confirmation request before resolving', async () => {
|
||||
const clearConfirmationRequest = vi.fn();
|
||||
const steps: string[] = [];
|
||||
const addExtensionUpdateConfirmationRequest = vi
|
||||
.fn()
|
||||
.mockImplementation((request: ConfirmationRequest) => {
|
||||
steps.push('prompted');
|
||||
request.onConfirm(true);
|
||||
steps.push('confirmed');
|
||||
});
|
||||
|
||||
const consentPromise = requestConsentInteractive(
|
||||
'Test consent',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
() => {
|
||||
steps.push('cleared');
|
||||
clearConfirmationRequest();
|
||||
},
|
||||
).then((consent) => {
|
||||
steps.push('resolved');
|
||||
return consent;
|
||||
});
|
||||
|
||||
expect(clearConfirmationRequest).toHaveBeenCalledTimes(1);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed']);
|
||||
await expect(consentPromise).resolves.toBe(true);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeRequestConsentOrFail', () => {
|
||||
|
||||
@@ -78,10 +78,12 @@ export async function requestConsentNonInteractive(
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
clearConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,12 +131,14 @@ export async function promptForConsentNonInteractive(
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
clearConfirmationRequest?.();
|
||||
setImmediate(() => resolve(resolvedConfirmed));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type * as osActual from 'node:os';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { loadEnvironment, type Settings } from './settings.js';
|
||||
import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Environment Isolation', () => {
|
||||
const mockHome = path.resolve('/mock/home');
|
||||
const mockWorkspace = path.resolve('/mock/workspace');
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
||||
vi.mocked(coreHomedir).mockReturnValue(mockHome);
|
||||
// Default to no files existing
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
process.argv = ['node', 'gemini'];
|
||||
|
||||
// Clear env vars that might leak from the host environment
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['OTHER_VAR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should load local .env by default', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should ignore local .env when ignoreLocalEnv is true', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === workspaceEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
// Should skip local and find home
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
|
||||
const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceGeminiEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect --ignore-env flag', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow home .env even with ignoreLocalEnv true', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from home dir
|
||||
loadEnvironment(settings, mockHome, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
|
||||
const deepProject = path.join(mockWorkspace, 'deep', 'dir');
|
||||
const deepEnv = path.join(deepProject, '.env');
|
||||
const parentEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === deepEnv || ps === parentEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
|
||||
if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, deepProject, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect trust whitelist even when loading from home .env', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
// Include one whitelisted and one non-whitelisted variable
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GEMINI_API_KEY=home\nOTHER_VAR=secret',
|
||||
);
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from an UNTRUSTED workspace
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
expect(process.env['OTHER_VAR']).toBeUndefined();
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should prioritize --ignore-env flag even if setting is false', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect both -s and --ignore-env flags simultaneously', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '-s', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
AuthType,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -202,6 +203,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('loadSettings', () => {
|
||||
@@ -3036,6 +3038,7 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env['CLOUD_SHELL'];
|
||||
delete process.env['MALICIOUS_VAR'];
|
||||
delete process.env['FOO'];
|
||||
delete process.env['_GEMINI_USER_GCP_PROJECT'];
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
@@ -3268,6 +3271,134 @@ MALICIOUS_VAR=allowed-because-trusted
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
});
|
||||
|
||||
it('should not override GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// No .env file
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-vertex-project');
|
||||
});
|
||||
|
||||
it('should respect .env override for GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// Mock .env file to override the shell project
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GOOGLE_CLOUD_PROJECT=env-vertex-project',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('env-vertex-project');
|
||||
});
|
||||
|
||||
it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// First call: normal Cloud Shell auth sets cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
|
||||
// Second call: user switched to Vertex AI, should remove cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should restore original project when switching to Vertex AI after Cloud Shell override', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'my-real-project';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// First call: saves original to _GEMINI_USER_GCP_PROJECT, sets cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
expect(process.env['_GEMINI_USER_GCP_PROJECT']).toBe('my-real-project');
|
||||
|
||||
// Second call: switching to Vertex AI should restore the saved value
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
|
||||
});
|
||||
|
||||
it('should restore project after restart when child inherits cloudshell-gca', () => {
|
||||
// Simulate child process after restart: inherits cloudshell-gca and
|
||||
// the saved original from the parent process.
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
process.env['_GEMINI_USER_GCP_PROJECT'] = 'my-real-project';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
|
||||
});
|
||||
|
||||
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
AuthType,
|
||||
type AdminControlsSettings,
|
||||
createCache,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -499,7 +500,11 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(
|
||||
startDir: string,
|
||||
isTrusted: boolean,
|
||||
ignoreLocalEnv: boolean,
|
||||
): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
@@ -511,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
if (!ignoreLocalEnv || currentDir === homedir()) {
|
||||
return envPath;
|
||||
}
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
@@ -532,17 +539,41 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal env var used to preserve the user's original GOOGLE_CLOUD_PROJECT
|
||||
// across process restarts in Cloud Shell. This survives relaunch because child
|
||||
// processes inherit the parent's environment.
|
||||
const USER_GCP_PROJECT = '_GEMINI_USER_GCP_PROJECT';
|
||||
|
||||
export function setUpCloudShellEnvironment(
|
||||
envFilePath: string | null,
|
||||
isTrusted: boolean,
|
||||
isSandboxed: boolean,
|
||||
selectedAuthType?: string,
|
||||
): void {
|
||||
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
||||
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
||||
// set by the user using "gcloud config set project" we do not want to
|
||||
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
||||
// one of the .env files, we set the Cloud Shell-specific default here.
|
||||
let value = 'cloudshell-gca';
|
||||
//
|
||||
// However, if the user has explicitly selected Vertex AI auth, they intend
|
||||
// to use their own GCP project, so we restore the original value and skip
|
||||
// the Cloud Shell override to respect their .env settings.
|
||||
|
||||
// Save the user's original value before overwriting, so it can be restored
|
||||
// if the user later switches to Vertex AI (even after a process restart).
|
||||
if (!process.env[USER_GCP_PROJECT]) {
|
||||
const current = process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
if (current && current !== 'cloudshell-gca') {
|
||||
process.env[USER_GCP_PROJECT] = current;
|
||||
}
|
||||
}
|
||||
|
||||
let value: string | undefined = 'cloudshell-gca';
|
||||
|
||||
if (selectedAuthType === AuthType.USE_VERTEX_AI) {
|
||||
value = process.env[USER_GCP_PROJECT];
|
||||
}
|
||||
|
||||
if (envFilePath && fs.existsSync(envFilePath)) {
|
||||
const envFileContent = fs.readFileSync(envFilePath);
|
||||
@@ -555,7 +586,12 @@ export function setUpCloudShellEnvironment(
|
||||
}
|
||||
}
|
||||
}
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
|
||||
if (value !== undefined) {
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
} else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') {
|
||||
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(
|
||||
@@ -565,7 +601,6 @@ export function loadEnvironment(
|
||||
): void {
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
@@ -582,9 +617,21 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
const shouldIgnoreEnv =
|
||||
!!settings.advanced?.ignoreLocalEnv ||
|
||||
relevantArgs.includes('--ignore-env');
|
||||
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
const selectedAuthType = settings.security?.auth?.selectedType;
|
||||
setUpCloudShellEnvironment(
|
||||
envFilePath,
|
||||
isTrusted,
|
||||
isSandboxed,
|
||||
selectedAuthType,
|
||||
);
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
|
||||
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ignoreLocalEnv: {
|
||||
type: 'boolean',
|
||||
label: 'Ignore Local .env',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ignore generic .env files in the project directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
bugCommand: {
|
||||
type: 'object',
|
||||
label: 'Bug Command',
|
||||
@@ -2057,8 +2067,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Gemma Models',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
default: true,
|
||||
description: 'Enable access to Gemma 4 models via Gemini API.',
|
||||
showInDialog: true,
|
||||
},
|
||||
voiceMode: {
|
||||
@@ -2099,7 +2109,11 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
description: oneLine`
|
||||
The backend to use for voice transcription. Note: When using the
|
||||
Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
@@ -2406,7 +2420,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
|
||||
'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generalistProfile: {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loadCliConfig, type CliArgs } from './config.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
|
||||
vi.mock('./extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
loadExtensions: vi.fn().mockResolvedValue([]),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('loadCliConfig skipExtensions', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = {
|
||||
query: undefined,
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
} as unknown as CliArgs;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load extensions by default', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv);
|
||||
expect(ExtensionManager).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip extensions when skipExtensions is true', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv, { skipExtensions: true });
|
||||
expect(ExtensionManager).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+44
-21
@@ -13,6 +13,7 @@ import {
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
type CoreEvents,
|
||||
createSessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
@@ -261,6 +262,7 @@ export async function startInteractiveUI(
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
let config: Config | undefined;
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
@@ -273,7 +275,7 @@ export async function main() {
|
||||
const cleanupStdio = patchStdio();
|
||||
registerSyncCleanup(() => {
|
||||
// This is needed to ensure we don't lose any buffered output.
|
||||
initializeOutputListenersAndFlush();
|
||||
initializeOutputListenersAndFlush(config);
|
||||
cleanupStdio();
|
||||
});
|
||||
|
||||
@@ -409,6 +411,8 @@ export async function main() {
|
||||
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -534,7 +538,7 @@ export async function main() {
|
||||
// may have side effects.
|
||||
{
|
||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
worktreeSettings: worktreeInfo,
|
||||
});
|
||||
@@ -780,7 +784,7 @@ export async function main() {
|
||||
debugLogger.log('Session ID: %s', sessionId);
|
||||
}
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
initializeOutputListenersAndFlush(config);
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
@@ -795,7 +799,7 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeOutputListenersAndFlush() {
|
||||
export function initializeOutputListenersAndFlush(config?: Config) {
|
||||
// If there are no listeners for output, make sure we flush so output is not
|
||||
// lost.
|
||||
if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
|
||||
@@ -807,24 +811,43 @@ export function initializeOutputListenersAndFlush() {
|
||||
writeToStdout(payload.chunk, payload.encoding);
|
||||
}
|
||||
});
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
});
|
||||
}
|
||||
}
|
||||
coreEvents.drainBacklogs();
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
const outputFormat = config?.getOutputFormat();
|
||||
const forceToStderr = outputFormat === 'json';
|
||||
|
||||
coreEvents.drainBacklogs(
|
||||
<K extends keyof CoreEvents>(event: K, args: CoreEvents[K]) => {
|
||||
if (forceToStderr && event === (CoreEvent.Output as string)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const payload = args[0] as OutputPayload;
|
||||
if (!payload.isStderr) {
|
||||
return {
|
||||
event,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
args: [{ ...payload, isStderr: true }] as unknown as CoreEvents[K],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { event, args };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
|
||||
@@ -179,7 +179,12 @@ export async function startInteractiveUI(
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
handleAutoUpdate(
|
||||
info,
|
||||
settings,
|
||||
config.getProjectRoot(),
|
||||
config.getSandboxEnabled(),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FatalInputError,
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
@@ -262,7 +263,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
@@ -381,7 +381,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
@@ -541,7 +540,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
@@ -683,7 +681,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
@@ -719,7 +716,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
@@ -852,7 +848,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
@@ -989,7 +984,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
@@ -1035,7 +1029,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
@@ -1213,7 +1206,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
@@ -1726,6 +1718,53 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'loop detected',
|
||||
events: [
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Loop detected, stopping execution',
|
||||
},
|
||||
{
|
||||
name: 'max session turns',
|
||||
events: [
|
||||
{ type: GeminiEventType.MaxSessionTurns },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Maximum session turns exceeded',
|
||||
},
|
||||
])(
|
||||
'should include warning in JSON mode for: $name',
|
||||
async ({ events, expectedWarning }) => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
...events,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(expectedWarning);
|
||||
},
|
||||
);
|
||||
|
||||
it('should log error when tool recording fails', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
@@ -2038,6 +2077,154 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
|
||||
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
// Setup stream-json format
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const calls = processStdoutSpy.mock.calls.map((call) =>
|
||||
JSON.parse(call[0] as string),
|
||||
);
|
||||
const errorEvent = calls.find(
|
||||
(c) => c.type === JsonStreamEventType.ERROR,
|
||||
);
|
||||
|
||||
expect(errorEvent).toBeDefined();
|
||||
expect(errorEvent.message).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
expect(errorEvent.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toHaveLength(2);
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
|
||||
});
|
||||
|
||||
it('should not include warnings field in JSON output if no blocks occur', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Clean answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
|
||||
@@ -56,6 +56,13 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the non-interactive CLI loop.
|
||||
*
|
||||
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
|
||||
* by stripping ANSI escape sequences from messages to ensure clean,
|
||||
* parseable output for downstream consumers.
|
||||
*/
|
||||
export async function runNonInteractive(
|
||||
params: RunNonInteractiveParams,
|
||||
): Promise<void> {
|
||||
@@ -296,6 +303,7 @@ export async function runNonInteractive(
|
||||
|
||||
let turnCount = 0;
|
||||
let invalidStreamError: string | undefined;
|
||||
const warnings: string[] = [];
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -311,7 +319,6 @@ export async function runNonInteractive(
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
@@ -352,23 +359,27 @@ export async function runNonInteractive(
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
const message = 'Loop detected, stopping execution';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
message,
|
||||
});
|
||||
}
|
||||
warnings.push(message);
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
const message = 'Maximum session turns exceeded';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
message,
|
||||
});
|
||||
}
|
||||
warnings.push(message);
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
@@ -395,7 +406,15 @@ export async function runNonInteractive(
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: stripAnsi(blockMessage),
|
||||
});
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
} else if (event.type === GeminiEventType.InvalidStream) {
|
||||
invalidStreamError =
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
@@ -507,7 +526,13 @@ export async function runNonInteractive(
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
formatter.format(
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
@@ -538,6 +563,7 @@ export async function runNonInteractive(
|
||||
invalidStreamError
|
||||
? { type: 'INVALID_STREAM', message: invalidStreamError }
|
||||
: undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FatalInputError,
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
|
||||
@@ -268,7 +269,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
@@ -435,7 +435,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
@@ -595,7 +594,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
@@ -737,7 +735,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
@@ -757,7 +754,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -773,7 +770,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
@@ -847,7 +843,7 @@ describe('runNonInteractive', () => {
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -927,7 +923,7 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -963,7 +959,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -979,7 +975,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
@@ -1116,7 +1111,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
@@ -1162,7 +1156,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
@@ -1382,7 +1375,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
@@ -1699,7 +1691,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1861,7 +1853,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1895,6 +1887,50 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'loop detected',
|
||||
events: [
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Loop detected, stopping execution',
|
||||
},
|
||||
])(
|
||||
'should include warning in JSON mode for: $name',
|
||||
async ({ events, expectedWarning }) => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
...events,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
try {
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
} catch {
|
||||
// Expected exit for max turns
|
||||
}
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings[0]).toContain(expectedWarning);
|
||||
},
|
||||
);
|
||||
|
||||
it('should log error when tool recording fails', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
@@ -2034,7 +2070,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2098,7 +2134,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2203,6 +2239,154 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
// Setup stream-json format
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const calls = processStdoutSpy.mock.calls.map((call) =>
|
||||
JSON.parse(call[0] as string),
|
||||
);
|
||||
const errorEvent = calls.find(
|
||||
(c) => c.type === JsonStreamEventType.ERROR,
|
||||
);
|
||||
|
||||
expect(errorEvent).toBeDefined();
|
||||
expect(errorEvent.message).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
expect(errorEvent.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toHaveLength(2);
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
|
||||
});
|
||||
|
||||
it('should not include warnings field in JSON output if no blocks occur', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Clean answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
@@ -2346,7 +2530,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2418,7 +2602,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the non-interactive CLI using the LegacyAgentSession.
|
||||
*
|
||||
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
|
||||
* by stripping ANSI escape sequences from messages to ensure clean,
|
||||
* parseable output for downstream consumers.
|
||||
*/
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
@@ -339,7 +346,13 @@ export async function runNonInteractive({
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
formatter.format(
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
@@ -420,6 +433,7 @@ export async function runNonInteractive({
|
||||
let responseText = '';
|
||||
let preToolResponseText: string | undefined;
|
||||
let streamEnded = false;
|
||||
const warnings: string[] = [];
|
||||
for await (const event of session.stream({ streamId })) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
@@ -538,9 +552,18 @@ export async function runNonInteractive({
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
const blockMessage = `Agent execution blocked: ${event.message.trim()}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: stripAnsi(blockMessage),
|
||||
});
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -554,9 +577,10 @@ export async function runNonInteractive({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity,
|
||||
message: event.message,
|
||||
message: stripAnsi(event.message),
|
||||
});
|
||||
}
|
||||
warnings.push(event.message);
|
||||
break;
|
||||
}
|
||||
case 'agent_end': {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { initializeOutputListenersAndFlush } from './gemini.js';
|
||||
import { coreEvents, CoreEvent, type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock core dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
writeToStdout: vi.fn(),
|
||||
writeToStderr: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { writeToStdout, writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Output Redirection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear listeners to simulate a clean state for each test
|
||||
coreEvents.removeAllListeners();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coreEvents.removeAllListeners();
|
||||
});
|
||||
|
||||
it('should redirect buffered stdout to stderr when output format is json', () => {
|
||||
const mockConfig = {
|
||||
getOutputFormat: () => 'json',
|
||||
} as unknown as Config;
|
||||
|
||||
// Simulate buffered output
|
||||
coreEvents.emitOutput(false, 'informational message');
|
||||
coreEvents.emitOutput(true, 'error message');
|
||||
|
||||
// Initialize listeners and flush
|
||||
initializeOutputListenersAndFlush(mockConfig);
|
||||
|
||||
// Verify informational message was forced to stderr
|
||||
expect(writeToStderr).toHaveBeenCalledWith(
|
||||
'informational message',
|
||||
undefined,
|
||||
);
|
||||
expect(writeToStderr).toHaveBeenCalledWith('error message', undefined);
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT redirect buffered stdout to stderr when output format is NOT json', () => {
|
||||
const mockConfig = {
|
||||
getOutputFormat: () => 'text',
|
||||
} as unknown as Config;
|
||||
|
||||
// Simulate buffered output
|
||||
coreEvents.emitOutput(false, 'regular message');
|
||||
|
||||
// Initialize listeners and flush
|
||||
initializeOutputListenersAndFlush(mockConfig);
|
||||
|
||||
// Verify regular message went to stdout
|
||||
expect(writeToStdout).toHaveBeenCalledWith('regular message', undefined);
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT force stdout to stderr when config is undefined (early init/version)', () => {
|
||||
// Simulate buffered output during early init
|
||||
coreEvents.emitOutput(false, 'early init message');
|
||||
|
||||
// Initialize with undefined config
|
||||
initializeOutputListenersAndFlush(undefined);
|
||||
|
||||
// Verify it went to stdout (default behavior)
|
||||
expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined);
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
|
||||
// Manually attach an Output listener
|
||||
coreEvents.on(CoreEvent.Output, vi.fn());
|
||||
|
||||
// Initialize - should still attach ConsoleLog and UserFeedback defaults
|
||||
initializeOutputListenersAndFlush(undefined);
|
||||
|
||||
// Simulate events
|
||||
coreEvents.emitConsoleLog('info', 'stray log');
|
||||
coreEvents.emitFeedback('info', 'stray feedback');
|
||||
|
||||
// Draining happens inside initializeOutputListenersAndFlush for existing backlog,
|
||||
// but here we check the newly attached listeners for immediate emission.
|
||||
expect(writeToStderr).toHaveBeenCalledWith('stray log\n');
|
||||
expect(writeToStderr).toHaveBeenCalledWith('stray feedback\n');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
|
||||
agentsCommand: { name: 'agents' },
|
||||
}));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
|
||||
bugMemoryCommand: { name: 'bug-memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({
|
||||
chatCommand: {
|
||||
name: 'chat',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
|
||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
||||
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||
authCommand,
|
||||
bugCommand,
|
||||
bugMemoryCommand,
|
||||
{
|
||||
...chatCommand,
|
||||
subCommands: chatResumeSubCommands,
|
||||
|
||||
@@ -34,13 +34,20 @@ import {
|
||||
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
||||
import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
export interface CommandDirectory {
|
||||
path: string;
|
||||
kind: CommandKind;
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
}
|
||||
|
||||
export interface CommandFileGroup {
|
||||
displayName: string;
|
||||
path: string;
|
||||
files: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
@@ -141,6 +148,59 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
return allCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists available .toml command files from user, project, and extension directories.
|
||||
*/
|
||||
async listAvailableFiles(): Promise<CommandFileGroup[]> {
|
||||
const directories = this.getCommandDirectories();
|
||||
const groups: CommandFileGroup[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const displayName = this.getDisplayName(dir);
|
||||
|
||||
try {
|
||||
const files = await glob('**/*.toml', { cwd: dir.path });
|
||||
if (files.length > 0) {
|
||||
groups.push({
|
||||
displayName,
|
||||
path: dir.path,
|
||||
files: [...files].sort(),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((e as { code?: string }).code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({
|
||||
displayName,
|
||||
path: dir.path,
|
||||
files: [],
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable display name for the command directory source.
|
||||
*/
|
||||
private getDisplayName(dir: CommandDirectory): string {
|
||||
switch (dir.kind) {
|
||||
case CommandKind.USER_FILE:
|
||||
return 'User';
|
||||
case CommandKind.WORKSPACE_FILE:
|
||||
return 'Project';
|
||||
case CommandKind.EXTENSION_FILE:
|
||||
return `Extension: ${dir.extensionName || 'Unknown'}`;
|
||||
default:
|
||||
return 'Custom';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all command directories in order for loading.
|
||||
* User commands → Project commands → Extension commands
|
||||
|
||||
@@ -135,7 +135,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getUseRipgrep: vi.fn().mockReturnValue(false),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
|
||||
@@ -592,6 +592,7 @@ const mockUIActions: UIActions = {
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
dismissLoginRestart: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
onHintClear: vi.fn(),
|
||||
|
||||
@@ -173,7 +173,6 @@ import {
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../utils/commands.js';
|
||||
@@ -756,7 +755,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (authState === AuthState.Authenticated && authContext.requiresRestart) {
|
||||
setAuthState(AuthState.AwaitingGoogleLoginRestart);
|
||||
setAuthState(AuthState.AwaitingLoginRestart);
|
||||
setAuthContext({});
|
||||
}
|
||||
}, [authState, authContext, setAuthState]);
|
||||
@@ -1128,18 +1127,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [config, historyManager]);
|
||||
|
||||
const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const cancelHandlerRef = useRef<
|
||||
(shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void
|
||||
>(() => {});
|
||||
|
||||
const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => {
|
||||
if (shouldRestorePrompt) {
|
||||
setPendingRestorePrompt(true);
|
||||
} else {
|
||||
setPendingRestorePrompt(false);
|
||||
cancelHandlerRef.current(false);
|
||||
}
|
||||
}, []);
|
||||
const onCancelSubmit = useCallback(
|
||||
(shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
|
||||
if (shouldRestorePrompt) {
|
||||
setPendingRestorePrompt(true);
|
||||
} else {
|
||||
setPendingRestorePrompt(false);
|
||||
cancelHandlerRef.current(false, clearBuffer);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingRestorePrompt) {
|
||||
@@ -1322,18 +1324,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
(shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => {
|
||||
if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
return; // Don't clear - user may be composing a follow-up message
|
||||
}
|
||||
if (isToolExecuting(pendingHistoryItems)) {
|
||||
buffer.setText(''); // Clear for Ctrl+C cancellation
|
||||
return;
|
||||
}
|
||||
|
||||
// If cancelling (shouldRestorePrompt=false), never modify the buffer
|
||||
// User is in control - preserve whatever text they typed, pasted, or restored
|
||||
// If cancelling (shouldRestorePrompt=false):
|
||||
if (!shouldRestorePrompt) {
|
||||
// Clear the buffer if explicitly requested (e.g., Ctrl+C)
|
||||
if (clearBuffer) {
|
||||
buffer.setText('');
|
||||
}
|
||||
// Otherwise (e.g., Escape), user is in control - preserve whatever text they typed
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2185,8 +2187,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const isAwaitingLoginRestart = authState === AuthState.AwaitingLoginRestart;
|
||||
const loginRestartMessage =
|
||||
settings.merged.security.auth.selectedType === AuthType.USE_VERTEX_AI
|
||||
? 'Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings.'
|
||||
: undefined;
|
||||
|
||||
const dialogsVisible =
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
@@ -2214,6 +2221,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!emptyWalletRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
isAwaitingLoginRestart ||
|
||||
!!newAgents;
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
@@ -2447,6 +2455,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
accountSuspensionInfo,
|
||||
isAuthDialogOpen,
|
||||
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
|
||||
isAwaitingLoginRestart,
|
||||
loginRestartMessage,
|
||||
apiKeyDefaultValue,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
@@ -2652,6 +2662,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
customDialog,
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
isAwaitingLoginRestart,
|
||||
loginRestartMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
@@ -2726,6 +2738,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
dismissLoginRestart: () => {
|
||||
setAuthContext({});
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
onHintClear: () => {},
|
||||
@@ -2832,18 +2848,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
if (authState === AuthState.AwaitingGoogleLoginRestart) {
|
||||
return (
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={() => {
|
||||
setAuthContext({});
|
||||
setAuthState(AuthState.Updating);
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
|
||||
@@ -243,6 +243,32 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.USE_VERTEX_AI);
|
||||
|
||||
expect(props.setAuthContext).toHaveBeenCalledWith({
|
||||
requiresRestart: true,
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', '');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.USE_VERTEX_AI);
|
||||
|
||||
expect(props.setAuthContext).toHaveBeenCalledWith({});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with empty object for other auth types', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
|
||||
@@ -119,7 +119,12 @@ export function AuthDialog({
|
||||
return;
|
||||
}
|
||||
if (authType) {
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
const needsRestart =
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
(authType === AuthType.USE_VERTEX_AI &&
|
||||
process.env['CLOUD_SHELL'] === 'true');
|
||||
|
||||
if (needsRestart) {
|
||||
setAuthContext({ requiresRestart: true });
|
||||
} else {
|
||||
setAuthContext({});
|
||||
|
||||
+16
-13
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||
import { LoginRestartDialog } from './LoginRestartDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ vi.mock('../../utils/cleanup.js', () => ({
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('LoginWithGoogleRestartDialog', () => {
|
||||
describe('LoginRestartDialog', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -44,11 +44,20 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
it('renders correctly with default message', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly with custom message', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<LoginRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
message="Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings."
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -57,10 +66,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
it('calls onDismiss when escape is pressed', async () => {
|
||||
const { unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
@@ -82,10 +88,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
+16
-8
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -10,15 +10,17 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
interface LoginWithGoogleRestartDialogProps {
|
||||
interface LoginRestartDialogProps {
|
||||
onDismiss: () => void;
|
||||
config: Config;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoginWithGoogleRestartDialog = ({
|
||||
export const LoginRestartDialog = ({
|
||||
onDismiss,
|
||||
config,
|
||||
}: LoginWithGoogleRestartDialogProps) => {
|
||||
message,
|
||||
}: LoginRestartDialogProps) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
@@ -44,14 +46,20 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const message =
|
||||
const displayMessage =
|
||||
message ??
|
||||
"You've successfully signed in with Google. Gemini CLI needs to be restarted.";
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={theme.status.warning} paddingX={1}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text color={theme.status.warning}>{displayMessage}</Text>
|
||||
<Text color={theme.status.warning}>
|
||||
{message} Press R to restart, or Esc to choose a different
|
||||
authentication method.
|
||||
Press R to restart, or Esc to choose a different authentication method.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LoginRestartDialog > renders correctly with custom message 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings. │
|
||||
│ Press R to restart, or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`LoginRestartDialog > renders correctly with default message 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ You've successfully signed in with Google. Gemini CLI needs to be restarted. │
|
||||
│ Press R to restart, or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart, │
|
||||
│ or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`commandsCommand > list > should list .toml files from available sources 1`] = `
|
||||
"### User Commands (/mock/user/commands)
|
||||
- user1.toml
|
||||
### Project Commands (/mock/project/commands)
|
||||
- proj1.toml
|
||||
### Extension: ext1 Commands (/mock/ext1/commands)
|
||||
- ext1.toml
|
||||
|
||||
_Note: MCP prompts are dynamically loaded from configured MCP servers._"
|
||||
`;
|
||||
@@ -110,7 +110,15 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 3,
|
||||
localCount: 2,
|
||||
remoteCount: 1,
|
||||
newAgents: ['new-agent'],
|
||||
updatedAgents: ['updated-agent'],
|
||||
deletedAgents: ['deleted-agent'],
|
||||
errors: [],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
@@ -120,7 +128,10 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
@@ -132,8 +143,42 @@ describe('agentsCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: expect.stringContaining('Agents reloaded successfully:'),
|
||||
});
|
||||
expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
|
||||
expect(result.content).toContain('- New: new-agent');
|
||||
expect(result.content).toContain('- Updated: updated-agent');
|
||||
expect(result.content).toContain('- Deleted: deleted-agent');
|
||||
expect(result.content).toContain(
|
||||
'Run /agents list to see all available agents.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "reloaded with errors" if errors occurred during reload', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 1,
|
||||
localCount: 1,
|
||||
remoteCount: 0,
|
||||
newAgents: [],
|
||||
updatedAgents: [],
|
||||
deletedAgents: [],
|
||||
errors: ['Some error'],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
);
|
||||
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(result.content).toContain('Agents reloaded with errors:');
|
||||
expect(result.content).toContain('- Errors: 1 encountered during reload');
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
|
||||
@@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = {
|
||||
text: 'Reloading agent registry...',
|
||||
});
|
||||
|
||||
await agentRegistry.reload();
|
||||
const summary = await agentRegistry.reload();
|
||||
|
||||
let content =
|
||||
summary.errors.length > 0
|
||||
? 'Agents reloaded with errors:'
|
||||
: 'Agents reloaded successfully:';
|
||||
content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
|
||||
|
||||
if (summary.newAgents.length > 0) {
|
||||
content += `\n- New: ${summary.newAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.updatedAgents.length > 0) {
|
||||
content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.deletedAgents.length > 0) {
|
||||
content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.errors.length > 0) {
|
||||
content += `\n- Errors: ${summary.errors.length} encountered during reload`;
|
||||
}
|
||||
|
||||
content += '\n\nRun /agents list to see all available agents.';
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
||||
import { getVersion, type Config } from '@google/gemini-cli-core';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
const { memoryUsageMock } = vi.hoisted(() => ({
|
||||
memoryUsageMock: vi.fn(() => ({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 4096 }),
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
memoryUsage: memoryUsageMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
const buildHighMemoryContext = (tempDir: string | undefined) =>
|
||||
createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => false,
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: { getChat: () => ({ getHistory: () => [] }) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = buildHighMemoryContext(tempDir);
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A memory bug');
|
||||
|
||||
const now = new Date('2024-01-01T00:00:00Z').getTime();
|
||||
const expectedSnapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${now}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem);
|
||||
const callOrder = addItem.mock.invocationCallOrder;
|
||||
const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
|
||||
// The URL message must precede the "capturing" message so the user sees
|
||||
// the URL before the 20+ second snapshot starts.
|
||||
expect(callOrder[0]).toBeLessThan(openOrder);
|
||||
expect(callOrder[1]).toBeGreaterThan(openOrder);
|
||||
expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
|
||||
expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
|
||||
expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
|
||||
});
|
||||
|
||||
it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 1 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A light bug');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error if the auto-capture fails but does not throw', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector failure'),
|
||||
);
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await expect(
|
||||
bugCommand.action(context, 'A memory bug'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItem[addItem.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||
import {
|
||||
captureHeapSnapshot,
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
|
||||
} from '../utils/memorySnapshot.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
const rss = process.memoryUsage().rss;
|
||||
const tempDir = config?.storage?.getProjectTempDir();
|
||||
if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
|
||||
const snapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await captureHeapSnapshot(snapshotPath);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(snapshotPath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(
|
||||
`Failed to capture heap snapshot for bug report: ${errorMessage}`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { bugMemoryCommand } from './bugMemoryCommand.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 1234 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeContextWithTempDir(tempDir: string | undefined) {
|
||||
return createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
} as unknown as Config,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('bugMemoryCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('declares itself as a non-auto-executing built-in command', () => {
|
||||
expect(bugMemoryCommand.name).toBe('bug-memory');
|
||||
expect(bugMemoryCommand.autoExecute).toBe(false);
|
||||
expect(bugMemoryCommand.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('captures a heap snapshot and reports the file path', async () => {
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = makeContextWithTempDir(tempDir);
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const expectedPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(2);
|
||||
expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[0][0].text).toContain(expectedPath);
|
||||
expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItemCalls[1][0].text).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('surfaces an error if capture fails', async () => {
|
||||
const context = makeContextWithTempDir('/tmp/gemini-test');
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector disconnected'),
|
||||
);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItemCalls[addItemCalls.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector disconnected');
|
||||
});
|
||||
|
||||
it('emits an error when no project temp directory is available', async () => {
|
||||
const context = makeContextWithTempDir(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(1);
|
||||
expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
|
||||
expect(addItemCalls[0][0].text).toContain('temp directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
export const bugMemoryCommand: SlashCommand = {
|
||||
name: 'bug-memory',
|
||||
description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const tempDir =
|
||||
context.services.agentContext?.config?.storage?.getProjectTempDir();
|
||||
if (!tempDir) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
const rss = process.memoryUsage().rss;
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await captureHeapSnapshot(filePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(`Failed to capture heap snapshot: ${message}`);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -4,11 +4,32 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Storage, type Config } from '@google/gemini-cli-core';
|
||||
import { commandsCommand } from './commandsCommand.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
|
||||
vi.mock('../../services/FileCommandLoader.js');
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@google/gemini-cli-core')
|
||||
>('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
Storage: class extends actual.Storage {
|
||||
static override getUserCommandsDir() {
|
||||
return '/mock/user/commands';
|
||||
}
|
||||
override getProjectCommandsDir() {
|
||||
return '/mock/project/commands';
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('commandsCommand', () => {
|
||||
let context: CommandContext;
|
||||
@@ -18,10 +39,27 @@ describe('commandsCommand', () => {
|
||||
context = createMockCommandContext({
|
||||
ui: {
|
||||
reloadCommands: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
services: {
|
||||
agentContext: {
|
||||
getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getExtensions: vi.fn().mockReturnValue([
|
||||
{ name: 'ext1', path: '/mock/ext1', isActive: true },
|
||||
{ name: 'ext2', path: '/mock/ext2', isActive: false },
|
||||
]),
|
||||
storage: new Storage('/mock/project'),
|
||||
} as unknown as Config,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('default action', () => {
|
||||
it('should return an info message prompting subcommand usage', async () => {
|
||||
const result = await commandsCommand.action!(context, '');
|
||||
@@ -30,7 +68,70 @@ describe('commandsCommand', () => {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Use "/commands reload" to reload custom command definitions from .toml files.',
|
||||
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list .toml files from available sources', async () => {
|
||||
vi.mocked(
|
||||
FileCommandLoader.prototype.listAvailableFiles,
|
||||
).mockResolvedValue([
|
||||
{
|
||||
displayName: 'User',
|
||||
path: '/mock/user/commands',
|
||||
files: ['user1.toml'],
|
||||
},
|
||||
{
|
||||
displayName: 'Project',
|
||||
path: '/mock/project/commands',
|
||||
files: ['proj1.toml'],
|
||||
},
|
||||
{
|
||||
displayName: 'Extension: ext1',
|
||||
path: '/mock/ext1/commands',
|
||||
files: ['ext1.toml'],
|
||||
},
|
||||
]);
|
||||
|
||||
const listCmd = commandsCommand.subCommands!.find(
|
||||
(s) => s.name === 'list',
|
||||
)!;
|
||||
|
||||
await listCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.any(String),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Snapshot the text content
|
||||
const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0];
|
||||
|
||||
expect((addItemCall as { text: string }).text).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show "No custom command files found" message if no .toml files exist', async () => {
|
||||
vi.mocked(
|
||||
FileCommandLoader.prototype.listAvailableFiles,
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const listCmd = commandsCommand.subCommands!.find(
|
||||
(s) => s.name === 'list',
|
||||
)!;
|
||||
|
||||
const result = await listCmd.action!(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'No custom command files (.toml) found.',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
* Action for the default `/commands` invocation.
|
||||
* Displays a message prompting the user to use a subcommand.
|
||||
*/
|
||||
async function listAction(
|
||||
async function defaultAction(
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
@@ -28,10 +29,64 @@ async function listAction(
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Use "/commands reload" to reload custom command definitions from .toml files.',
|
||||
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for `/commands list`.
|
||||
* Lists available .toml command files from user, project, and extension directories.
|
||||
*/
|
||||
async function listSubcommandAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
try {
|
||||
const config = context.services.agentContext?.config ?? null;
|
||||
const loader = new FileCommandLoader(config);
|
||||
const groups = await loader.listAvailableFiles();
|
||||
|
||||
const results: string[] = [];
|
||||
for (const group of groups) {
|
||||
results.push(`### ${group.displayName} Commands (${group.path})`);
|
||||
if (group.error) {
|
||||
results.push(`- (Error reading directory: ${group.error})`);
|
||||
} else {
|
||||
group.files.forEach((file) => results.push(`- ${file}`));
|
||||
}
|
||||
}
|
||||
|
||||
results.push(
|
||||
'\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
|
||||
);
|
||||
|
||||
if (results.length === 1) {
|
||||
// Only the note is present
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: results.join('\n'),
|
||||
} as HistoryItemInfo,
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`,
|
||||
} as HistoryItemError,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for `/commands reload`.
|
||||
* Triggers a full re-discovery and reload of all slash commands, including
|
||||
@@ -63,10 +118,18 @@ async function reloadAction(
|
||||
|
||||
export const commandsCommand: SlashCommand = {
|
||||
name: 'commands',
|
||||
description: 'Manage custom slash commands. Usage: /commands [reload]',
|
||||
description: 'Manage custom slash commands. Usage: /commands [list|reload]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'list',
|
||||
description:
|
||||
'List available custom command .toml files. Usage: /commands list',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: listSubcommandAction,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
@@ -77,5 +140,5 @@ export const commandsCommand: SlashCommand = {
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
action: defaultAction,
|
||||
};
|
||||
|
||||
@@ -782,6 +782,13 @@ describe('extensionsCommand', () => {
|
||||
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should expose "delete" as an alias', () => {
|
||||
const uninstallCmd = extensionsCommand(true).subCommands?.find(
|
||||
(cmd) => cmd.name === 'uninstall',
|
||||
);
|
||||
expect(uninstallCmd?.altNames).toContain('delete');
|
||||
});
|
||||
|
||||
it('should call uninstallExtension and show success message', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
await uninstallAction!(mockContext, extensionName);
|
||||
|
||||
@@ -838,6 +838,7 @@ const linkCommand: SlashCommand = {
|
||||
|
||||
const uninstallCommand: SlashCommand = {
|
||||
name: 'uninstall',
|
||||
altNames: ['delete'],
|
||||
description: 'Uninstall an extension',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
import { InboxDialog } from '../components/InboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
component: React.createElement(InboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
onReloadMemory: async () => {
|
||||
await refreshMemory(config);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
import { requestConsentInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -253,6 +254,36 @@ describe('skillsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass a cleanup callback for interactive workspace consent', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
context.ui.setConfirmationRequest = vi.fn();
|
||||
vi.mocked(linkSkill).mockImplementation(
|
||||
async (_sourcePath, _scope, _addItem, requestConsent) => {
|
||||
expect(requestConsent).toBeDefined();
|
||||
await requestConsent!(
|
||||
[{ name: 'test-skill', location: '/path' } as SkillDefinition],
|
||||
'/workspace/.gemini/skills',
|
||||
);
|
||||
return [{ name: 'test-skill', location: '/path' }];
|
||||
},
|
||||
);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
const requestConsentCall = vi
|
||||
.mocked(requestConsentInteractive)
|
||||
.mock.calls.at(-1);
|
||||
expect(requestConsentCall?.[1]).toEqual(expect.any(Function));
|
||||
|
||||
const clearConfirmationRequest = requestConsentCall?.[2];
|
||||
expect(clearConfirmationRequest).toBeTypeOf('function');
|
||||
|
||||
clearConfirmationRequest?.();
|
||||
expect(context.ui.setConfirmationRequest).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
|
||||
@@ -118,6 +118,7 @@ async function linkAction(
|
||||
return requestConsentInteractive(
|
||||
consentString,
|
||||
context.ui.setConfirmationRequest.bind(context.ui),
|
||||
() => context.ui.setConfirmationRequest(null),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface CommandContext {
|
||||
*
|
||||
* @param value The confirmation request details.
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
setConfirmationRequest: (value: ConfirmationRequest | null) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
|
||||
@@ -39,6 +39,7 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
import { LoginRestartDialog } from '../auth/LoginRestartDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -306,6 +307,17 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isAwaitingLoginRestart) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<LoginRestartDialog
|
||||
onDismiss={uiActions.dismissLoginRestart}
|
||||
config={config}
|
||||
message={uiState.loginRestartMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
+525
-14
@@ -6,19 +6,32 @@
|
||||
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
InboxSkill,
|
||||
InboxPatch,
|
||||
InboxMemoryPatch,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
dismissInboxMemoryPatch,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listInboxMemoryPatches,
|
||||
moveInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
applyInboxMemoryPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { SkillInboxDialog } from './SkillInboxDialog.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { InboxDialog } from './InboxDialog.js';
|
||||
|
||||
const altBufferSettings = createMockSettings({
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -27,11 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
dismissInboxMemoryPatch: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
listInboxPatches: vi.fn(),
|
||||
listInboxMemoryPatches: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
applyInboxPatch: vi.fn(),
|
||||
dismissInboxPatch: vi.fn(),
|
||||
applyInboxMemoryPatch: vi.fn(),
|
||||
isProjectSkillPatchTarget: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -41,10 +57,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
||||
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
||||
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
||||
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
|
||||
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
|
||||
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
@@ -76,6 +95,27 @@ const inboxPatch: InboxPatch = {
|
||||
extractedAt: '2025-01-20T14:00:00Z',
|
||||
};
|
||||
|
||||
const inboxMemoryPatch: InboxMemoryPatch = {
|
||||
kind: 'private',
|
||||
relativePath: 'private',
|
||||
name: 'Private memory',
|
||||
sourceFiles: ['update-memory.patch'],
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
isNewFile: false,
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-old',
|
||||
'+use focused tests',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
extractedAt: '2025-01-21T10:00:00Z',
|
||||
};
|
||||
|
||||
const workspacePatch: InboxPatch = {
|
||||
fileName: 'workspace-update.patch',
|
||||
name: 'workspace-update',
|
||||
@@ -137,11 +177,12 @@ const windowsGlobalPatch: InboxPatch = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
describe('InboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
@@ -158,6 +199,14 @@ describe('SkillInboxDialog', () => {
|
||||
success: true,
|
||||
message: 'Dismissed "update-docs.patch" from inbox.',
|
||||
});
|
||||
mockApplyInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Applied memory patch to 1 file.',
|
||||
});
|
||||
mockDismissInboxMemoryPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed 1 private memory patch from inbox.',
|
||||
});
|
||||
mockIsProjectSkillPatchTarget.mockImplementation(
|
||||
async (targetPath: string, config: Config) => {
|
||||
const projectSkillsDir = config.storage
|
||||
@@ -176,6 +225,64 @@ describe('SkillInboxDialog', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('reviews and applies memory patches', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn()}
|
||||
onReloadMemory={onReloadMemory}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Private memory');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Review');
|
||||
expect(frame).toMatch(/source patch/);
|
||||
});
|
||||
|
||||
// Memory patches default to Dismiss as the highlighted action so a stray
|
||||
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
|
||||
// press Enter to confirm.
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // arrow down → Apply
|
||||
await waitUntilReady();
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Aggregate apply: relativePath equals the kind name.
|
||||
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'private',
|
||||
'private',
|
||||
);
|
||||
expect(onReloadMemory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
@@ -183,7 +290,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -228,7 +335,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -276,7 +383,7 @@ describe('SkillInboxDialog', () => {
|
||||
.mockRejectedValue(new Error('reload hook failed'));
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -316,6 +423,83 @@ describe('SkillInboxDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
|
||||
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
|
||||
// the list with focus jumped back to row 0 instead of staying on the row
|
||||
// the user was on.
|
||||
const secondSkill: InboxSkill = {
|
||||
...inboxSkill,
|
||||
dirName: 'second-skill',
|
||||
name: 'Second Skill',
|
||||
};
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Arrow down to the second row.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Enter the second row's preview.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Esc back to list.
|
||||
await act(async () => {
|
||||
stdin.write('\x1b');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
// Re-enter (no arrow keys this time). The active row must still be the
|
||||
// SECOND skill, not the first — which is what the bug reproduced before.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review new skill');
|
||||
// The preview header echoes the highlighted skill's name.
|
||||
expect(frame).toContain('Second Skill');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('patch support', () => {
|
||||
it('shows patches alongside skills with section headers', async () => {
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
@@ -328,7 +512,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -360,7 +544,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -401,7 +585,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -449,7 +633,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -494,7 +678,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -538,7 +722,7 @@ describe('SkillInboxDialog', () => {
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
@@ -593,7 +777,7 @@ describe('SkillInboxDialog', () => {
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -628,7 +812,7 @@ describe('SkillInboxDialog', () => {
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
@@ -656,5 +840,332 @@ describe('SkillInboxDialog', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
|
||||
const tallPatch: InboxPatch = {
|
||||
fileName: 'tall.patch',
|
||||
name: 'tall-patch',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,4 +1,8 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+added-1',
|
||||
'+added-2',
|
||||
'+added-3',
|
||||
'+added-4',
|
||||
' line3',
|
||||
' line4',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
|
||||
// Alt-buffer mode has no terminal scrollback, so the dialog must
|
||||
// scroll inside itself. ScrollableList renders a `█` thumb when
|
||||
// content exceeds viewport height — the regression signal that the
|
||||
// diff is bounded and off-screen content is reachable via PgUp/PgDn.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{
|
||||
settings: altBufferSettings,
|
||||
uiState: { terminalHeight: 18 },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain('Dismiss');
|
||||
expect(frame).toContain('█');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ settings: altBufferSettings },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('update-docs');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('PgUp/PgDn to scroll');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
|
||||
// Non-alt-buffer mode uses the codebase's standard bounded
|
||||
// DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
|
||||
// FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
|
||||
// "... first/last N line(s) hidden ..." hint when it clips, which
|
||||
// is the regression signal that the diff is bounded.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18, constrainHeight: true } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame() ?? '').toMatch(/lines? hidden/);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
|
||||
// In non-alt-buffer mode the Ctrl+O affordance is rendered inline
|
||||
// by ShowMoreLines above the footer when the diff is clipped. The
|
||||
// footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
|
||||
// duplicating the hint there would be noisy.
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([tallPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18, constrainHeight: true } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('tall-patch');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Ctrl+O');
|
||||
expect(frame).not.toContain('PgUp/PgDn to scroll');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders each list row as exactly two lines even with long descriptions', async () => {
|
||||
// Reproduces the production bug: with the previous renderer, long
|
||||
// descriptions wrapped onto multiple lines (and the date sibling was
|
||||
// interleaved into the wrap), making each item 3-5 rows tall and
|
||||
// breaking the listMaxItemsToShow budget. The fix uses height={2}
|
||||
// and wrap="truncate-end" on every list row.
|
||||
const longDescription =
|
||||
'This is an extremely long description that would absolutely wrap to ' +
|
||||
'multiple lines if rendered without truncation, which used to push the ' +
|
||||
'list-phase footer off the bottom of the alternate buffer in production.';
|
||||
mockListInboxSkills.mockResolvedValue([
|
||||
{
|
||||
dirName: 'long-skill',
|
||||
name: 'long-skill',
|
||||
description: longDescription,
|
||||
content: '---\nname: x\ndescription: y\n---\n',
|
||||
},
|
||||
]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('long-skill');
|
||||
});
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).not.toContain('production');
|
||||
expect(frame).toContain('extremely long description');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps the list-phase footer on screen with many long-description skills', async () => {
|
||||
const longDesc =
|
||||
'A very long description that would wrap across multiple lines if not ' +
|
||||
'truncated, which was causing the dialog body to overflow the bottom ' +
|
||||
'of the alternate buffer';
|
||||
const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
|
||||
dirName: `skill-${i}`,
|
||||
name: `skill-${i}`,
|
||||
description: `${longDesc} (#${i})`,
|
||||
content: '---\nname: x\ndescription: y\n---\n',
|
||||
}));
|
||||
mockListInboxSkills.mockResolvedValue(manySkills);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 28 } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Memory Inbox');
|
||||
expect(frame).toContain('Esc to close');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps the list-phase footer on screen on short terminals', async () => {
|
||||
const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
dirName: `skill-${i}`,
|
||||
name: `Skill ${i}`,
|
||||
description: `Description ${i}`,
|
||||
content: '---\nname: Skill\ndescription: Skill\n---\n',
|
||||
}));
|
||||
mockListInboxSkills.mockResolvedValue(manySkills);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
mockListInboxMemoryPatches.mockResolvedValue([]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<InboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ uiState: { terminalHeight: 18 } },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Memory Inbox');
|
||||
expect(frame).toContain('Esc to close');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('clipboardy');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../hooks/useKittyKeyboardProtocol.js');
|
||||
|
||||
vi.mock('./ListeningIndicator.js', () => ({
|
||||
ListeningIndicator: vi.fn(({ color }) => <Text color={color}>~~~ </Text>),
|
||||
}));
|
||||
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
@@ -348,7 +350,7 @@ describe('InputPrompt', () => {
|
||||
visualToLogicalMap: [[0, 0]],
|
||||
visualToTransformedMap: [0],
|
||||
transformationsByLine: [],
|
||||
getOffset: vi.fn().mockReturnValue(0),
|
||||
getOffset: vi.fn().mockImplementation(() => mockBuffer.cursor[1]),
|
||||
pastedContent: {},
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
@@ -4979,9 +4981,9 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
|
||||
// Initially not recording
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
'Type your message or space to talk (Esc to exit)',
|
||||
);
|
||||
|
||||
// Press space to start
|
||||
@@ -4989,11 +4991,6 @@ describe('InputPrompt', () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Now should show listening
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -5001,7 +4998,7 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
@@ -5015,27 +5012,18 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('some existing text');
|
||||
mockBuffer.setText('First turn.');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
@@ -5047,21 +5035,15 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Should show voice mode hint even if buffer is not empty (new behavior)
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
expect(lastFrame()).toContain('some existing text');
|
||||
// Should show voice mode prefix even if buffer is not empty
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain('First turn.');
|
||||
|
||||
// Press space to start recording again
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -5069,7 +5051,7 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: false } as UIState,
|
||||
@@ -5085,7 +5067,6 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
// Should NOT show listening, instead should call handleInput which handles space
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -5117,17 +5098,15 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 'end');
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 13);
|
||||
});
|
||||
|
||||
// Emit turnComplete (Gemini Live starts over after this)
|
||||
// turnComplete advances the baseline; next turn appends after it
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'turnComplete',
|
||||
);
|
||||
});
|
||||
|
||||
// Emit second part (Gemini Live sends new turn text starting from empty)
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
@@ -5135,10 +5114,9 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Should have appended 'world' to the baseline 'initial hello'
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'initial hello world',
|
||||
'end',
|
||||
19,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5175,13 +5153,48 @@ describe('InputPrompt', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'First turn. Second turn.',
|
||||
'end',
|
||||
24,
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should insert transcription at cursor position when buffer has text before and after (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('hello world');
|
||||
mockBuffer.cursor = [0, 5]; // cursor after 'hello'
|
||||
});
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'there',
|
||||
);
|
||||
});
|
||||
|
||||
// 'hello'(5) + ' '(1) + 'there'(5) = cursor at 11; ' world' preserved after
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'hello there world',
|
||||
11,
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('push-to-talk', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -5202,7 +5215,10 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Voice mode: Hold Space to record');
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain(
|
||||
'Type your message or hold space to talk (Esc to exit)',
|
||||
);
|
||||
|
||||
// Press space once
|
||||
await act(async () => {
|
||||
@@ -5211,19 +5227,17 @@ describe('InputPrompt', () => {
|
||||
|
||||
// Should insert space optimistically
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
|
||||
// Advance timer past HOLD_DELAY_MS
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should start recording on hold (simulated by repeat spaces)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
@@ -5247,8 +5261,6 @@ describe('InputPrompt', () => {
|
||||
await waitFor(() => {
|
||||
// Should have backspaced the optimistic space
|
||||
expect(mockBuffer.backspace).toHaveBeenCalled();
|
||||
// Should show listening
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -5271,30 +5283,18 @@ describe('InputPrompt', () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
},
|
||||
{ interval: 10 },
|
||||
);
|
||||
|
||||
// Simulate heartbeat (held key) - send space first to reset timer, then advance
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain('~~~ >');
|
||||
|
||||
// Stop heartbeat (release)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { ListeningIndicator } from './ListeningIndicator.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -360,6 +361,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isShellSuggestionsVisible,
|
||||
} = completion;
|
||||
|
||||
const effectivePlaceholder = useMemo(() => {
|
||||
if (!isVoiceModeEnabled) return placeholder;
|
||||
const voiceAction =
|
||||
(settings.experimental.voice?.activationMode ?? 'push-to-talk') ===
|
||||
'push-to-talk'
|
||||
? 'hold space to talk'
|
||||
: 'space to talk';
|
||||
return ` Type your message or ${voiceAction} (Esc to exit)`;
|
||||
}, [
|
||||
isVoiceModeEnabled,
|
||||
placeholder,
|
||||
settings.experimental.voice?.activationMode,
|
||||
]);
|
||||
|
||||
const showCursor =
|
||||
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
|
||||
|
||||
@@ -1786,6 +1801,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||||
{isVoiceModeEnabled &&
|
||||
(isRecording ? (
|
||||
<ListeningIndicator color={theme.text.accent} />
|
||||
) : (
|
||||
<Text color={theme.text.accent}>🎤 </Text>
|
||||
))}
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
@@ -1810,37 +1831,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.status.success}>🎙️ Listening...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isVoiceModeEnabled && !isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>
|
||||
> Voice mode:{' '}
|
||||
{(settings.experimental.voice?.activationMode ??
|
||||
'push-to-talk') === 'push-to-talk'
|
||||
? 'Hold Space to record'
|
||||
: 'Space to start/stop recording'}{' '}
|
||||
(Esc to exit)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{buffer.text.length === 0 && !isRecording ? (
|
||||
!isVoiceModeEnabled && placeholder ? (
|
||||
{buffer.text.length === 0 ? (
|
||||
effectivePlaceholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
{chalk.inverse(effectivePlaceholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
{effectivePlaceholder.slice(1)}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{effectivePlaceholder}
|
||||
</Text>
|
||||
)
|
||||
) : null
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
|
||||
const WAVE_CHARS = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
const ANIMATION_SPEED = 0.4;
|
||||
const BAR_PHASE_OFFSET = 1.2;
|
||||
const MAX_HEIGHT_MULTIPLIER = 3.5;
|
||||
const FRAME_INTERVAL_MS = 40; // ~33 FPS
|
||||
|
||||
export interface ListeningIndicatorProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const ListeningIndicator: React.FC<ListeningIndicatorProps> = ({
|
||||
color,
|
||||
}) => {
|
||||
const [tick, setTick] = useState(0);
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (isScreenReaderEnabled) return;
|
||||
const timer = setInterval(() => setTick((t) => t + 1), FRAME_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [isScreenReaderEnabled]);
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
return <Text color={color}>Listening... </Text>;
|
||||
}
|
||||
|
||||
// Generate 3 bars for the wave
|
||||
const bars = Array.from({ length: 3 }).map((_, i) => {
|
||||
// Sine wave calculation to map to our 8 block characters (0-7)
|
||||
const phase = tick * ANIMATION_SPEED + i * BAR_PHASE_OFFSET;
|
||||
const height = Math.floor((Math.sin(phase) + 1) * MAX_HEIGHT_MULTIPLIER);
|
||||
return WAVE_CHARS[Math.max(0, Math.min(7, height))] ?? ' ';
|
||||
});
|
||||
|
||||
return <Text color={color}>{bars.join('')} </Text>;
|
||||
};
|
||||
@@ -15,6 +15,20 @@ import { Box, Text } from 'ink';
|
||||
import { act, useState, type JSX } from 'react';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
validatePlanPath: vi
|
||||
.fn()
|
||||
.mockResolvedValue('Storage must be initialized before use'),
|
||||
validatePlanContent: vi
|
||||
.fn()
|
||||
.mockResolvedValue('Storage must be initialized before use'),
|
||||
};
|
||||
});
|
||||
import {
|
||||
UIStateContext,
|
||||
useUIState,
|
||||
@@ -672,9 +686,15 @@ describe('MainContent', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
let lastFrame!: () => string;
|
||||
let unmount!: () => void;
|
||||
await act(async () => {
|
||||
const res = await renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
});
|
||||
lastFrame = res.lastFrame;
|
||||
unmount = res.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -683,6 +703,8 @@ describe('MainContent', () => {
|
||||
expect(output).not.toContain('Hidden content');
|
||||
// The output should contain the confirmation header
|
||||
expect(output).toContain('Ready to start implementation?');
|
||||
// Wait for the async error message to appear
|
||||
expect(output).toContain('File not found: /path/to/plan');
|
||||
});
|
||||
|
||||
// Snapshot will reveal if there are extra blank lines
|
||||
|
||||
@@ -1,876 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxPatch,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
|
||||
|
||||
type InboxItem =
|
||||
| { type: 'skill'; skill: InboxSkill }
|
||||
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
|
||||
| { type: 'header'; label: string };
|
||||
|
||||
interface DestinationChoice {
|
||||
destination: InboxSkillDestination;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PatchAction {
|
||||
action: 'apply' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
label: 'Global',
|
||||
description: '~/.gemini/skills — available in all projects',
|
||||
},
|
||||
{
|
||||
destination: 'project',
|
||||
label: 'Project',
|
||||
description: '.gemini/skills — available in this workspace',
|
||||
},
|
||||
];
|
||||
|
||||
interface SkillPreviewAction {
|
||||
action: 'move' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
|
||||
{
|
||||
action: 'move',
|
||||
label: 'Move',
|
||||
description: 'Choose where to install this skill',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
const PATCH_ACTION_CHOICES: PatchAction[] = [
|
||||
{
|
||||
action: 'apply',
|
||||
label: 'Apply',
|
||||
description: 'Apply patch and delete from inbox',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox without applying',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathForUi(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
function getPathBasename(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
const basename = path.posix.basename(normalizedPath);
|
||||
return basename === '.' ? filePath : basename;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
patch: InboxPatch,
|
||||
config: Config,
|
||||
): Promise<boolean> {
|
||||
const entryTargetsProjectSkills = await Promise.all(
|
||||
patch.entries.map((entry) =>
|
||||
isProjectSkillPatchTarget(entry.targetPath, config),
|
||||
),
|
||||
);
|
||||
return entryTargetsProjectSkills.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a bracketed origin tag from a skill file path,
|
||||
* matching the existing [Built-in] convention in SkillsList.
|
||||
*/
|
||||
function getSkillOriginTag(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
|
||||
if (normalizedPath.includes('/bundle/')) {
|
||||
return 'Built-in';
|
||||
}
|
||||
if (normalizedPath.includes('/extensions/')) {
|
||||
return 'Extension';
|
||||
}
|
||||
if (normalizedPath.includes('/.gemini/skills/')) {
|
||||
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
|
||||
.filter((homeDir): homeDir is string => Boolean(homeDir))
|
||||
.map(normalizePathForUi);
|
||||
if (
|
||||
homeDirs.some((homeDir) =>
|
||||
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
|
||||
)
|
||||
) {
|
||||
return 'Global';
|
||||
}
|
||||
return 'Workspace';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unified diff string representing a new file.
|
||||
*/
|
||||
function newFileDiff(filename: string, content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const hunkLines = lines.map((l) => `+${l}`).join('\n');
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${filename}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
hunkLines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
interface SkillInboxDialogProps {
|
||||
config: Config;
|
||||
onClose: () => void;
|
||||
onReloadSkills: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
config,
|
||||
onClose,
|
||||
onReloadSkills,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 80;
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
const [phase, setPhase] = useState<Phase>('list');
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Load inbox skills and patches on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [skills, patches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
]);
|
||||
const patchItems = await Promise.all(
|
||||
patches.map(async (patch): Promise<InboxItem> => {
|
||||
let targetsProjectSkills = false;
|
||||
try {
|
||||
targetsProjectSkills = await patchTargetsProjectSkills(
|
||||
patch,
|
||||
config,
|
||||
);
|
||||
} catch {
|
||||
targetsProjectSkills = false;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'patch',
|
||||
patch,
|
||||
targetsProjectSkills,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
const combined: InboxItem[] = [
|
||||
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
||||
...patchItems,
|
||||
];
|
||||
setItems(combined);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const getItemKey = useCallback(
|
||||
(item: InboxItem): string =>
|
||||
item.type === 'skill'
|
||||
? `skill:${item.skill.dirName}`
|
||||
: item.type === 'patch'
|
||||
? `patch:${item.patch.fileName}`
|
||||
: `header:${item.label}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
|
||||
const skills = items.filter((i) => i.type === 'skill');
|
||||
const patches = items.filter((i) => i.type === 'patch');
|
||||
const result: Array<SelectionListItem<InboxItem>> = [];
|
||||
|
||||
// Only show section headers when both types are present
|
||||
const showHeaders = skills.length > 0 && patches.length > 0;
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'New Skills' };
|
||||
result.push({
|
||||
key: 'header:new-skills',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of skills) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
|
||||
result.push({
|
||||
key: 'header:skill-updates',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of patches) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, getItemKey]);
|
||||
|
||||
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
||||
() =>
|
||||
SKILL_DESTINATION_CHOICES.map((choice) => {
|
||||
if (choice.destination === 'project' && !isTrustedFolder) {
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.destination,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder],
|
||||
);
|
||||
|
||||
const selectedPatchTargetsProjectSkills = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItem.targetsProjectSkills;
|
||||
}, [selectedItem]);
|
||||
|
||||
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
|
||||
() =>
|
||||
PATCH_ACTION_CHOICES.map((choice) => {
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
selectedPatchTargetsProjectSkills &&
|
||||
!isTrustedFolder
|
||||
) {
|
||||
return {
|
||||
key: choice.action,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder, selectedPatchTargetsProjectSkills],
|
||||
);
|
||||
|
||||
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
|
||||
useMemo(
|
||||
() =>
|
||||
SKILL_PREVIEW_CHOICES.map((choice) => ({
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback((item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
|
||||
);
|
||||
},
|
||||
[getItemKey],
|
||||
);
|
||||
|
||||
const handleSkillPreviewAction = useCallback(
|
||||
(choice: SkillPreviewAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
|
||||
if (choice.action === 'move') {
|
||||
setFeedback(null);
|
||||
setPhase('skill-action');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss
|
||||
setFeedback(null);
|
||||
const skill = selectedItem.skill;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dismissInboxSkill(config, skill.dirName);
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
if (result.success) {
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectDestination = useCallback(
|
||||
(choice: DestinationChoice) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
const skill = selectedItem.skill;
|
||||
|
||||
if (choice.destination === 'project' && !config.isTrustedFolder()) {
|
||||
setFeedback({
|
||||
text: 'Project skills are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await moveInboxSkill(
|
||||
config,
|
||||
skill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to install skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectPatchAction = useCallback(
|
||||
(choice: PatchAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') return;
|
||||
const patch = selectedItem.patch;
|
||||
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
!config.isTrustedFolder() &&
|
||||
selectedItem.targetsProjectSkills
|
||||
) {
|
||||
setFeedback({
|
||||
text: 'Project skill patches are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.action === 'apply') {
|
||||
result = await applyInboxPatch(config, patch.fileName);
|
||||
} else {
|
||||
result = await dismissInboxPatch(config, patch.fileName);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.action === 'apply') {
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (phase === 'skill-action') {
|
||||
setPhase('skill-preview');
|
||||
setFeedback(null);
|
||||
} else if (phase !== 'list') {
|
||||
setPhase('list');
|
||||
setSelectedItem(null);
|
||||
setFeedback(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true },
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text>Loading inbox…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0 && !feedback) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>Memory Inbox</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>No items in inbox.</Text>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Border + paddingX account for 6 chars of width
|
||||
const contentWidth = terminalWidth - 6;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
{phase === 'list' && (
|
||||
<>
|
||||
<Text bold>
|
||||
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Extracted from past sessions. Select one to review.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelectItem}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={8}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
if (item.value.type === 'header') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.value.type === 'skill') {
|
||||
const skill = item.value.skill;
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{skill.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{skill.description}
|
||||
</Text>
|
||||
{skill.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(skill.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const patch = item.value.patch;
|
||||
const fileNames = patch.entries.map((e) =>
|
||||
getPathBasename(e.targetPath),
|
||||
);
|
||||
const origin = getSkillOriginTag(
|
||||
patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={titleColor} bold>
|
||||
{patch.name}
|
||||
</Text>
|
||||
{origin && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{` [${origin}]`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{fileNames.join(', ')}
|
||||
</Text>
|
||||
{patch.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(patch.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.skill.name}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Review new skill before installing.
|
||||
</Text>
|
||||
|
||||
{selectedItem.skill.content && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
SKILL.md
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff(
|
||||
'SKILL.md',
|
||||
selectedItem.skill.content,
|
||||
)}
|
||||
filename="SKILL.md"
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<SkillPreviewAction>
|
||||
items={skillPreviewItems}
|
||||
onSelect={handleSkillPreviewAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedItem.skill.name}"</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose where to install this skill.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<DestinationChoice>
|
||||
items={destinationItems}
|
||||
onSelect={handleSelectDestination}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.patch.name}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
Review changes before applying.
|
||||
</Text>
|
||||
{(() => {
|
||||
const origin = getSkillOriginTag(
|
||||
selectedItem.patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return origin ? (
|
||||
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{selectedItem.patch.entries.map((entry, index) => (
|
||||
<Box
|
||||
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{entry.targetPath}
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={entry.diffContent}
|
||||
filename={entry.targetPath}
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<PatchAction>
|
||||
items={patchActionItems}
|
||||
onSelect={handleSelectPatchAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user