chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:58:09 +08:00
commit c48e26cdd0
2909 changed files with 1591131 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import os from 'node:os';
const artifactsDir = process.argv[2] || '.';
const MAX_HISTORY = 10;
// Find all report.json files recursively
function findReports(dir) {
const reports = [];
if (!fs.existsSync(dir)) return reports;
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
reports.push(...findReports(fullPath));
} else if (file === 'report.json') {
reports.push(fullPath);
}
}
return reports;
}
function getModelFromPath(reportPath) {
const parts = reportPath.split(path.sep);
// Find the part that starts with 'eval-logs-'
const artifactDir = parts.find((p) => p.startsWith('eval-logs-'));
if (!artifactDir) return 'unknown';
const matchNew = artifactDir.match(/^eval-logs-(.+)-(\d+)$/);
if (matchNew) return matchNew[1];
const matchOld = artifactDir.match(/^eval-logs-(\d+)$/);
if (matchOld) return 'gemini-2.5-pro'; // Legacy default
return 'unknown';
}
function getStats(reports) {
// Structure: { [model]: { [testName]: { passed, failed, total } } }
const statsByModel = {};
for (const reportPath of reports) {
try {
const model = getModelFromPath(reportPath);
if (!statsByModel[model]) {
statsByModel[model] = {};
}
const testStats = statsByModel[model];
const content = fs.readFileSync(reportPath, 'utf-8');
const json = JSON.parse(content);
for (const testResult of json.testResults) {
for (const assertion of testResult.assertionResults) {
const name = assertion.title;
if (!testStats[name]) {
testStats[name] = { passed: 0, failed: 0, total: 0 };
}
testStats[name].total++;
if (assertion.status === 'passed') {
testStats[name].passed++;
} else {
testStats[name].failed++;
}
}
}
} catch (error) {
console.error(`Error processing report at ${reportPath}:`, error);
}
}
return statsByModel;
}
function fetchHistoricalData() {
const history = [];
try {
// Determine branch
const branch = 'main';
// Get recent runs
const cmd = `gh run list --workflow evals-nightly.yml --branch "${branch}" --limit ${
MAX_HISTORY + 5
} --json databaseId,createdAt,url,displayTitle,status,conclusion`;
const runsJson = execSync(cmd, { encoding: 'utf-8' });
let runs = JSON.parse(runsJson);
// Filter out current run
const currentRunId = process.env.GITHUB_RUN_ID;
if (currentRunId) {
runs = runs.filter((r) => r.databaseId.toString() !== currentRunId);
}
// Filter for runs that likely have artifacts (completed) and take top N
// We accept 'failure' too because we want to see stats.
runs = runs.filter((r) => r.status === 'completed').slice(0, MAX_HISTORY);
// Fetch artifacts for each run
for (const run of runs) {
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-evals-${run.databaseId}-`),
);
try {
// Download report.json files.
// The artifacts are named 'eval-logs-X' or 'eval-logs-MODEL-X'.
// We use -p to match pattern.
execSync(
`gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`,
{ stdio: 'ignore' },
);
const runReports = findReports(tmpDir);
if (runReports.length > 0) {
history.push({
run,
stats: getStats(runReports), // Now returns stats grouped by model
});
}
} catch (error) {
console.error(
`Failed to download or process artifacts for run ${run.databaseId}:`,
error,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
} catch (error) {
console.error('Failed to fetch historical data:', error);
}
return history;
}
function generateMarkdown(currentStatsByModel, history) {
console.log('### Evals Nightly Summary\n');
console.log(
'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n',
);
// Reverse history to show oldest first
const reversedHistory = [...history].reverse();
const models = Object.keys(currentStatsByModel).sort();
const getPassRate = (statsForModel) => {
if (!statsForModel) return '-';
const totalStats = Object.values(statsForModel).reduce(
(acc, stats) => {
acc.passed += stats.passed;
acc.total += stats.total;
return acc;
},
{ passed: 0, total: 0 },
);
return totalStats.total > 0
? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%'
: '-';
};
for (const model of models) {
const currentStats = currentStatsByModel[model];
const totalPassRate = getPassRate(currentStats);
console.log(`#### Model: ${model}`);
console.log(`**Total Pass Rate: ${totalPassRate}**\n`);
// Header
let header = '| Test Name |';
let separator = '| :--- |';
let passRateRow = '| **Overall Pass Rate** |';
for (const item of reversedHistory) {
header += ` [${item.run.databaseId}](${item.run.url}) |`;
separator += ' :---: |';
passRateRow += ` **${getPassRate(item.stats[model])}** |`;
}
// Add Current column last
header += ' Current |';
separator += ' :---: |';
passRateRow += ` **${totalPassRate}** |`;
console.log(header);
console.log(separator);
console.log(passRateRow);
// Collect all test names for this model
const allTestNames = new Set(Object.keys(currentStats));
for (const item of reversedHistory) {
if (item.stats[model]) {
Object.keys(item.stats[model]).forEach((name) =>
allTestNames.add(name),
);
}
}
for (const name of Array.from(allTestNames).sort()) {
const searchUrl = `https://github.com/search?q=repo%3Agoogle-gemini%2Fgemini-cli%20%22${encodeURIComponent(name)}%22&type=code`;
let row = `| [${name}](${searchUrl}) |`;
// History
for (const item of reversedHistory) {
const stat = item.stats[model] ? item.stats[model][name] : null;
if (stat) {
const passRate = ((stat.passed / stat.total) * 100).toFixed(0) + '%';
row += ` ${passRate} |`;
} else {
row += ' - |';
}
}
// Current
const curr = currentStats[name];
if (curr) {
const passRate = ((curr.passed / curr.total) * 100).toFixed(0) + '%';
row += ` ${passRate} |`;
} else {
row += ' - |';
}
console.log(row);
}
console.log('\n');
}
}
// --- Main ---
const currentReports = findReports(artifactsDir);
if (currentReports.length === 0) {
console.log('No reports found.');
// We don't exit here because we might still want to see history if available,
// but practically if current has no reports, something is wrong.
// Sticking to original behavior roughly, but maybe we can continue.
process.exit(0);
}
const currentStats = getStats(currentReports);
const history = fetchHistoricalData();
generateMarkdown(currentStats, history);
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# scripts/batch_triage.sh
# Usage: ./scripts/batch_triage.sh [repository]
# Example: ./scripts/batch_triage.sh google-gemini/maintainers-gemini-cli
set -e
set -o pipefail
REPO="${1:-google-gemini/gemini-cli}"
WORKFLOW="gemini-automated-issue-triage.yml"
echo "🔍 Searching for open issues in '${REPO}' that need triage (missing 'area/' label)..."
# Fetch open issues with number, title, and labels
# We fetch up to 1000 issues.
ISSUES_JSON=$(gh issue list --repo "${REPO}" --state open --limit 1000 --json number,title,labels)
# Filter issues that DO NOT have a label starting with 'area/'
TARGET_ISSUES=$(echo "${ISSUES_JSON}" | jq '[.[] | select(.labels | map(.name) | any(startswith("area/")) | not)]')
# Avoid masking return value
COUNT=$(jq '. | length' <<< "${TARGET_ISSUES}")
if [[ "${COUNT}" -eq 0 ]]; then
echo "✅ No issues found needing triage in '${REPO}'."
exit 0
fi
echo "🚀 Found ${COUNT} issues to triage."
# Loop through and trigger workflow
echo "${TARGET_ISSUES}" | jq -r '.[] | "\(.number)|\(.title)"' | while IFS="|" read -r number title; do
echo "▶️ Triggering triage for #${number}: ${title}"
# Trigger the workflow dispatch event
gh workflow run "${WORKFLOW}" --repo "${REPO}" -f issue_number="${number}"
# Sleep briefly to be nice to the API
sleep 1
done
echo "🎉 All triage workflows triggered!"
+80
View File
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
// npm install if node_modules was removed (e.g. via npm run clean or scripts/clean.js)
if (!existsSync(join(root, 'node_modules'))) {
execSync('npm install', { stdio: 'inherit', cwd: root });
}
// build all workspaces/packages
execSync('npm run generate', { stdio: 'inherit', cwd: root });
if (process.env.CI) {
console.log('CI environment detected. Building workspaces sequentially...');
execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root });
} else {
// Build core first because everyone depends on it
console.log('Building @google/gemini-cli-core...');
execSync('npm run build -w @google/gemini-cli-core', {
stdio: 'inherit',
cwd: root,
});
// Build the rest in parallel
console.log('Building other workspaces in parallel...');
const workspaceInfo = JSON.parse(
execSync('npm query .workspace --json', { cwd: root, encoding: 'utf-8' }),
);
const parallelWorkspaces = workspaceInfo
.map((w) => w.name)
.filter((name) => name !== '@google/gemini-cli-core');
execSync(
`npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
{ stdio: 'inherit', cwd: root },
);
}
// also build container image if sandboxing is enabled
// skip (-s) npm install + build since we did that above
try {
execSync('node scripts/sandbox_command.js -q', {
stdio: 'inherit',
cwd: root,
});
if (
process.env.BUILD_SANDBOX === '1' ||
process.env.BUILD_SANDBOX === 'true'
) {
execSync('node scripts/build_sandbox.js -s', {
stdio: 'inherit',
cwd: root,
});
}
} catch {
// ignore
}
+515
View File
@@ -0,0 +1,515 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
cpSync,
rmSync,
mkdirSync,
existsSync,
copyFileSync,
writeFileSync,
readFileSync,
chmodSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import { globSync } from 'glob';
import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const distDir = join(root, 'dist');
const bundleDir = join(root, 'bundle');
const stagingDir = join(bundleDir, 'native_modules');
const seaConfigPath = join(root, 'sea-config.json');
const manifestPath = join(bundleDir, 'manifest.json');
const entitlementsPath = join(root, 'scripts/entitlements.plist');
// --- Helper Functions ---
/**
* Safely executes a command using spawnSync.
* @param {string} command
* @param {string[]} args
* @param {object} options
*/
function runCommand(command, args, options = {}) {
let finalCommand = command;
let useShell = options.shell || false;
// On Windows, npm/npx are batch files and need a shell
if (
process.platform === 'win32' &&
(command === 'npm' || command === 'npx')
) {
finalCommand = `${command}.cmd`;
useShell = true;
}
const finalOptions = {
stdio: 'inherit',
cwd: root,
shell: useShell,
...options,
};
const result = spawnSync(finalCommand, args, finalOptions);
if (result.status !== 0) {
if (result.error) {
throw result.error;
}
throw new Error(
`Command failed with exit code ${result.status}: ${command}`,
);
}
return result;
}
/**
* Removes existing digital signatures from a binary.
* @param {string} filePath
*/
function removeSignature(filePath) {
console.log(`Removing signature from ${filePath}...`);
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync('codesign', ['--remove-signature', filePath], {
stdio: 'ignore',
});
} else if (platform === 'win32') {
spawnSync('signtool', ['remove', '/s', filePath], {
stdio: 'ignore',
});
}
} catch {
// Best effort: Ignore failures
}
}
/**
* Signs a binary using hardcoded tools for the platform.
* @param {string} filePath
*/
function signFile(filePath) {
if (process.env.SKIP_SIGNING === 'true') {
console.log(`Skipping signing for ${filePath} (SKIP_SIGNING=true)`);
return;
}
const platform = process.platform;
if (platform === 'darwin') {
const identity = process.env.APPLE_IDENTITY || '-';
console.log(`Signing ${filePath} (Identity: ${identity})...`);
const args = [
'--sign',
identity,
'--force',
'--timestamp',
'--options',
'runtime',
];
if (existsSync(entitlementsPath)) {
args.push('--entitlements', entitlementsPath);
}
args.push(filePath);
runCommand('codesign', args);
} else if (platform === 'win32') {
const args = ['sign'];
if (process.env.WINDOWS_PFX_FILE && process.env.WINDOWS_PFX_PASSWORD) {
args.push(
'/f',
process.env.WINDOWS_PFX_FILE,
'/p',
process.env.WINDOWS_PFX_PASSWORD,
);
} else {
args.push('/a');
}
args.push(
'/fd',
'SHA256',
'/td',
'SHA256',
'/tr',
'http://timestamp.digicert.com',
filePath,
);
console.log(`Signing ${filePath}...`);
try {
runCommand('signtool', args, { stdio: 'pipe' });
} catch (e) {
let msg = e.message;
if (process.env.WINDOWS_PFX_PASSWORD) {
msg = msg.replaceAll(process.env.WINDOWS_PFX_PASSWORD, '******');
}
throw new Error(msg);
}
} else if (platform === 'linux') {
console.log(`Skipping signing for ${filePath} on Linux.`);
}
}
console.log('Build Binary Script Started...');
// 1. Clean dist
if (existsSync(distDir)) {
console.log('Cleaning dist directory...');
rmSync(distDir, { recursive: true, force: true });
}
mkdirSync(distDir, { recursive: true });
// 2. Build Bundle
console.log('Running npm clean, install, and bundle...');
try {
runCommand('npm', ['run', 'clean']);
runCommand('npm', ['install']);
runCommand('npm', ['run', 'bundle']);
} catch (e) {
console.error('Build step failed:', e.message);
process.exit(1);
}
// 2b. Copy host-platform ripgrep binary into the bundle for the SEA.
// (npm tarballs omit these to stay under the registry upload limit.)
const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep');
const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep');
if (existsSync(ripgrepVendorSrc)) {
const rgBinName = `rg-${process.platform}-${process.arch}${
process.platform === 'win32' ? '.exe' : ''
}`;
const rgSrc = join(ripgrepVendorSrc, rgBinName);
if (existsSync(rgSrc)) {
mkdirSync(ripgrepVendorDest, { recursive: true });
cpSync(rgSrc, join(ripgrepVendorDest, rgBinName), { dereference: true });
console.log(`Copied ${rgBinName} to bundle/vendor/ripgrep/`);
} else {
console.warn(
`Warning: bundled ripgrep binary not found for ${process.platform}/${process.arch} at ${rgSrc}. ` +
`The SEA will fall back to system grep at runtime.`,
);
}
}
// 3. Stage & Sign Native Modules
const includeNativeModules = process.env.BUNDLE_NATIVE_MODULES !== 'false';
console.log(`Include Native Modules: ${includeNativeModules}`);
if (includeNativeModules) {
console.log('Staging and signing native modules...');
// Prepare staging
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
mkdirSync(stagingDir, { recursive: true });
// Copy @lydell/node-pty to staging
const lydellSrc = join(root, 'node_modules/@lydell');
const lydellStaging = join(stagingDir, 'node_modules/@lydell');
if (existsSync(lydellSrc)) {
mkdirSync(dirname(lydellStaging), { recursive: true });
cpSync(lydellSrc, lydellStaging, { recursive: true });
} else {
console.warn(
'Warning: @lydell/node-pty not found in node_modules. Native terminal features may fail.',
);
}
// Copy @github/keytar to staging
const githubSrc = join(root, 'node_modules/@github');
const githubStaging = join(stagingDir, 'node_modules/@github');
if (existsSync(githubSrc)) {
mkdirSync(dirname(githubStaging), { recursive: true });
cpSync(githubSrc, githubStaging, { recursive: true });
} else {
console.warn(
'Warning: @github/keytar not found in node_modules. Secure keychain features will use file fallback.',
);
}
// Sign Staged .node files
try {
const nodeFiles = globSync('**/*.node', {
cwd: stagingDir,
absolute: true,
});
for (const file of nodeFiles) {
signFile(file);
}
} catch (e) {
console.warn('Warning: Failed to sign native modules:', e.code);
}
} else {
console.log('Skipping native modules bundling (BUNDLE_NATIVE_MODULES=false)');
}
// 4. Generate SEA Configuration and Manifest
console.log('Generating SEA configuration and manifest...');
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
);
// Helper to calc hash
const sha256 = (content) => createHash('sha256').update(content).digest('hex');
const assets = {
'manifest.json': 'bundle/manifest.json',
};
const manifest = {
main: 'gemini.mjs',
mainHash: '',
version: packageJson.version,
files: [],
};
// Add all javascript chunks from the bundle directory
const jsFiles = globSync('*.js', { cwd: bundleDir });
for (const jsFile of jsFiles) {
const fsPath = join(bundleDir, jsFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
// Node SEA requires the main entry point to be explicitly mapped
if (jsFile === 'gemini.js') {
assets['gemini.mjs'] = fsPath;
manifest.mainHash = hash;
} else {
// Other chunks need to be mapped exactly as they are named so dynamic imports find them
assets[jsFile] = fsPath;
manifest.files.push({ key: jsFile, path: jsFile, hash: hash });
}
}
// Helper to recursively find files from STAGING
function addAssetsFromDir(baseDir, runtimePrefix) {
const fullDir = join(stagingDir, baseDir);
if (!existsSync(fullDir)) return;
const items = globSync('**/*', { cwd: fullDir, nodir: true });
for (const item of items) {
const relativePath = join(runtimePrefix, item);
const assetKey = `files:${relativePath}`;
const fsPath = join(fullDir, item);
// Calc hash
const content = readFileSync(fsPath);
const hash = sha256(content);
assets[assetKey] = fsPath;
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
}
}
// Add sb files
const sbFiles = globSync('sandbox-macos-*.sb', { cwd: bundleDir });
for (const sbFile of sbFiles) {
const fsPath = join(bundleDir, sbFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
assets[sbFile] = fsPath;
manifest.files.push({ key: sbFile, path: sbFile, hash: hash });
}
// Add policy files
const policyDir = join(bundleDir, 'policies');
if (existsSync(policyDir)) {
const policyFiles = globSync('*.toml', { cwd: policyDir });
for (const policyFile of policyFiles) {
const fsPath = join(policyDir, policyFile);
const relativePath = join('policies', policyFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
// Use a unique key to avoid collision if filenames overlap (though unlikely here)
// But sea-launch writes to 'path', so key is just for lookup.
const assetKey = `policies:${policyFile}`;
assets[assetKey] = fsPath;
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
}
}
// Add ripgrep binary (copied in step 2b). Must be registered here so that
// sea-launch.cjs extracts it to runtimeDir/vendor/ripgrep/ on startup; the
// runtime resolver in packages/core/src/tools/ripGrep.ts uses __dirname-
// relative paths to find it.
if (existsSync(ripgrepVendorDest)) {
const rgFiles = globSync('*', { cwd: ripgrepVendorDest, nodir: true });
for (const rgFile of rgFiles) {
const fsPath = join(ripgrepVendorDest, rgFile);
const relativePath = join('vendor', 'ripgrep', rgFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
const assetKey = `vendor:${rgFile}`;
assets[assetKey] = fsPath;
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
}
}
// Add assets from Staging
if (includeNativeModules) {
addAssetsFromDir('node_modules/@lydell', 'node_modules/@lydell');
addAssetsFromDir('node_modules/@github', 'node_modules/@github');
}
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
const seaConfig = {
main: 'sea/sea-launch.cjs',
output: 'dist/sea-prep.blob',
disableExperimentalSEAWarning: true,
assets: assets,
};
writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2));
console.log(`Configured ${Object.keys(assets).length} embedded assets.`);
// 5. Generate SEA Blob
console.log('Generating SEA blob...');
try {
runCommand('node', ['--experimental-sea-config', 'sea-config.json']);
} catch (e) {
console.error('Failed to generate SEA blob:', e.message);
// Cleanup
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
if (existsSync(manifestPath)) rmSync(manifestPath);
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
process.exit(1);
}
// Check blob existence
const blobPath = join(distDir, 'sea-prep.blob');
if (!existsSync(blobPath)) {
console.error('Error: sea-prep.blob not found in dist/');
process.exit(1);
}
// 6. Identify Target & Prepare Binary
const platform = process.platform;
const arch = process.arch;
const targetName = `${platform}-${arch}`;
console.log(`Targeting: ${targetName}`);
const targetDir = join(distDir, targetName);
mkdirSync(targetDir, { recursive: true });
const nodeBinary = process.execPath;
const binaryName = platform === 'win32' ? 'gemini.exe' : 'gemini';
const targetBinaryPath = join(targetDir, binaryName);
console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`);
copyFileSync(nodeBinary, targetBinaryPath);
if (platform === 'darwin') {
console.log(`Thinning universal binary for ${arch}...`);
try {
// Attempt to thin the binary. Will fail safely if it's not a fat binary.
runCommand('lipo', [
targetBinaryPath,
'-thin',
arch,
'-output',
targetBinaryPath,
]);
} catch (e) {
console.log(`Skipping lipo thinning: ${e.message}`);
}
}
// Remove existing signature using helper
removeSignature(targetBinaryPath);
// Copy standard bundle assets (policies, .sb files)
console.log('Copying additional resources...');
if (existsSync(bundleDir)) {
cpSync(bundleDir, targetDir, { recursive: true });
}
// Clean up source JS files from output (we only want embedded)
const filesToRemove = [
'gemini.mjs',
'gemini.mjs.map',
'gemini-sea.cjs',
'sea-launch.cjs',
'manifest.json',
'native_modules',
'policies',
];
filesToRemove.forEach((f) => {
const p = join(targetDir, f);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
});
// Remove all chunk and entry .js/.js.map files
const jsFilesToRemove = globSync('*.{js,js.map}', { cwd: targetDir });
for (const f of jsFilesToRemove) {
rmSync(join(targetDir, f));
}
// Remove .sb files from targetDir
const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir });
for (const f of sbFilesToRemove) {
rmSync(join(targetDir, f));
}
// 7. Inject Blob
console.log('Injecting SEA blob...');
const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
try {
chmodSync(targetBinaryPath, 0o755);
const args = [
'postject',
targetBinaryPath,
'NODE_SEA_BLOB',
blobPath,
'--sentinel-fuse',
sentinelFuse,
];
if (platform === 'darwin') {
args.push('--macho-segment-name', 'NODE_SEA');
}
runCommand('npx', ['--yes', ...args]);
console.log('Injection successful.');
} catch (e) {
console.error('Postject failed:', e.message);
process.exit(1);
}
// 8. Final Signing
console.log('Signing final executable...');
try {
signFile(targetBinaryPath);
} catch (e) {
console.warn('Warning: Final signing failed:', e.code);
console.warn('Continuing without signing...');
}
// 9. Cleanup
console.log('Cleaning up artifacts...');
rmSync(blobPath);
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
if (existsSync(manifestPath)) rmSync(manifestPath);
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
console.log(`Binary built successfully in ${targetDir}`);
+58
View File
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import { writeFileSync, existsSync, cpSync } from 'node:fs';
import { join, basename } from 'node:path';
if (!process.cwd().includes('packages')) {
console.error('must be invoked from a package directory');
process.exit(1);
}
const packageName = basename(process.cwd());
// build typescript files
execSync('tsc --build', { stdio: 'inherit' });
// Run package-specific bundling if the script exists
const bundleScript = join(process.cwd(), 'scripts', 'bundle-browser-mcp.mjs');
if (packageName === 'core' && existsSync(bundleScript)) {
console.log('Running chrome devtools MCP bundling...');
execSync('npm run bundle:browser-mcp', {
stdio: 'inherit',
});
}
// copy .{md,json} files
execSync('node ../../scripts/copy_files.js', { stdio: 'inherit' });
// Copy documentation for the core package
if (packageName === 'core') {
const docsSource = join(process.cwd(), '..', '..', 'docs');
const docsTarget = join(process.cwd(), 'dist', 'docs');
if (existsSync(docsSource)) {
cpSync(docsSource, docsTarget, { recursive: true, dereference: true });
console.log('Copied documentation to dist/docs');
}
}
// touch dist/.last_build
writeFileSync(join(process.cwd(), 'dist', '.last_build'), '');
process.exit(0);
+192
View File
@@ -0,0 +1,192 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import {
chmodSync,
existsSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import os from 'node:os';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import cliPkgJson from '../packages/cli/package.json' with { type: 'json' };
const argv = yargs(hideBin(process.argv))
.option('s', {
alias: 'skip-npm-install-build',
type: 'boolean',
default: false,
description: 'skip npm install + npm run build',
})
.option('f', {
alias: 'dockerfile',
type: 'string',
default: 'Dockerfile',
description: 'use <dockerfile> for custom image',
})
.option('i', {
alias: 'image',
type: 'string',
default: cliPkgJson.config.sandboxImageUri,
description: 'use <image> name for custom image',
})
.option('output-file', {
type: 'string',
description:
'Path to write the final image URI. Used for CI/CD pipeline integration.',
}).argv;
let sandboxCommand;
try {
sandboxCommand = execSync('node scripts/sandbox_command.js')
.toString()
.trim();
} catch (e) {
console.warn('ERROR: could not detect sandbox container command');
console.error(e);
process.exit(process.env.CI ? 1 : 0);
}
if (sandboxCommand === 'sandbox-exec') {
console.warn(
'WARNING: container-based sandboxing is disabled (see README.md#sandboxing)',
);
process.exit(0);
}
console.log(`using ${sandboxCommand} for sandboxing`);
const image = argv.i;
const dockerFile = argv.f;
if (!image.length) {
console.warn(
'No default image tag specified in gemini-cli/packages/cli/package.json',
);
}
if (!argv.s) {
execSync('npm install', { stdio: 'inherit' });
execSync('npm run build --workspaces', { stdio: 'inherit' });
}
console.log('packing @google/gemini-cli ...');
const cliPackageDir = join('packages', 'cli');
rmSync(join(cliPackageDir, 'dist', 'google-gemini-cli-*.tgz'), { force: true });
execSync(
`npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist`,
{
stdio: 'ignore',
},
);
console.log('packing @google/gemini-cli-core ...');
const corePackageDir = join('packages', 'core');
rmSync(join(corePackageDir, 'dist', 'google-gemini-cli-core-*.tgz'), {
force: true,
});
execSync(
`npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist`,
{ stdio: 'ignore' },
);
const packageVersion = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
).version;
chmodSync(
join(cliPackageDir, 'dist', `google-gemini-cli-${packageVersion}.tgz`),
0o755,
);
chmodSync(
join(corePackageDir, 'dist', `google-gemini-cli-core-${packageVersion}.tgz`),
0o755,
);
const buildStdout = process.env.VERBOSE ? 'inherit' : 'ignore';
// Determine the appropriate shell based on OS
const isWindows = os.platform() === 'win32';
const shellToUse = isWindows ? 'powershell.exe' : '/bin/bash';
function buildImage(imageName, dockerfile) {
console.log(`building ${imageName} ... (can be slow first time)`);
let buildCommandArgs = '';
let tempAuthFile = '';
if (sandboxCommand === 'podman') {
if (isWindows) {
// PowerShell doesn't support <() process substitution.
// Create a temporary auth file that we will clean up after.
tempAuthFile = join(os.tmpdir(), `gemini-auth-${Date.now()}.json`);
writeFileSync(tempAuthFile, '{}');
buildCommandArgs = `--authfile="${tempAuthFile}"`;
} else {
// Use bash-specific syntax for Linux/macOS
buildCommandArgs = `--authfile=<(echo '{}')`;
}
}
const npmPackageVersion = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
).version;
const imageTag =
process.env.GEMINI_SANDBOX_IMAGE_TAG || imageName.split(':')[1];
const finalImageName = `${imageName.split(':')[0]}:${imageTag}`;
try {
execSync(
`${sandboxCommand} build ${buildCommandArgs} ${
process.env.BUILD_SANDBOX_FLAGS || ''
} --build-arg CLI_VERSION_ARG=${npmPackageVersion} -f "${dockerfile}" -t "${finalImageName}" .`,
{ stdio: buildStdout, shell: shellToUse },
);
console.log(`built ${finalImageName}`);
// If an output file path was provided via command-line, write the final image URI to it.
if (argv.outputFile) {
console.log(
`Writing final image URI for CI artifact to: ${argv.outputFile}`,
);
// The publish step only supports one image. If we build multiple, only the last one
// will be published. Throw an error to make this failure explicit if the file already exists.
if (existsSync(argv.outputFile)) {
throw new Error(
`CI artifact file ${argv.outputFile} already exists. Refusing to overwrite.`,
);
}
writeFileSync(argv.outputFile, finalImageName);
}
} finally {
// If we created a temp file, delete it now.
if (tempAuthFile) {
rmSync(tempAuthFile, { force: true });
}
}
}
buildImage(image, dockerFile);
execSync(`${sandboxCommand} image prune -f`, { stdio: 'ignore' });
+30
View File
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
execSync('npm --workspace=gemini-cli-vscode-ide-companion run package', {
stdio: 'inherit',
cwd: root,
});
+103
View File
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const CORE_STEERING_PATHS = [
'packages/core/src/prompts/',
'packages/core/src/tools/',
];
const TEST_PATHS = ['evals/'];
const STEERING_SIGNATURES = [
'LocalAgentDefinition',
'LocalInvocation',
'ToolDefinition',
'inputSchema',
"kind: 'local'",
];
function main() {
const targetBranch = process.env.GITHUB_BASE_REF || 'main';
const verbose = process.argv.includes('--verbose');
const steeringOnly = process.argv.includes('--steering-only');
try {
const remoteUrl = process.env.GITHUB_REPOSITORY
? `https://github.com/${process.env.GITHUB_REPOSITORY}.git`
: 'origin';
// Fetch target branch from the remote.
execSync(`git fetch ${remoteUrl} ${targetBranch}`, {
stdio: 'ignore',
});
// Get changed files using the triple-dot syntax which correctly handles merge commits
const head = process.env.PR_HEAD_SHA || 'HEAD';
const changedFiles = execSync(`git diff --name-only FETCH_HEAD...${head}`, {
encoding: 'utf-8',
})
.split('\n')
.filter(Boolean);
let detected = false;
const reasons = [];
// 1. Path-based detection
for (const file of changedFiles) {
if (CORE_STEERING_PATHS.some((prefix) => file.startsWith(prefix))) {
detected = true;
reasons.push(`Matched core steering path: ${file}`);
if (!verbose) break;
}
if (
!steeringOnly &&
TEST_PATHS.some((prefix) => file.startsWith(prefix))
) {
detected = true;
reasons.push(`Matched test path: ${file}`);
if (!verbose) break;
}
}
// 2. Signature-based detection (only in packages/core/src/ and only if not already detected or if verbose)
if (!detected || verbose) {
const coreChanges = changedFiles.filter((f) =>
f.startsWith('packages/core/src/'),
);
if (coreChanges.length > 0) {
// Get the actual diff content for core files
const diff = execSync(
`git diff -U0 FETCH_HEAD...${head} -- packages/core/src/`,
{ encoding: 'utf-8' },
);
for (const sig of STEERING_SIGNATURES) {
if (diff.includes(sig)) {
detected = true;
reasons.push(`Matched steering signature in core: ${sig}`);
if (!verbose) break;
}
}
}
}
if (verbose && reasons.length > 0) {
process.stderr.write('Detection reasons:\n');
reasons.forEach((r) => process.stderr.write(` - ${r}\n`));
}
process.stdout.write(detected ? 'true' : 'false');
} catch (error) {
// If anything fails (e.g., no git history), run evals/guidance to be safe
process.stderr.write(
'Warning: Failed to determine if changes occurred. Defaulting to true.\n',
);
process.stderr.write(String(error) + '\n');
process.stdout.write('true');
}
}
main();
+148
View File
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os'; // Import os module
// --- Configuration ---
const cliPackageDir = path.resolve('packages', 'cli'); // Base directory for the CLI package
const buildTimestampPath = path.join(cliPackageDir, 'dist', '.last_build'); // Path to the timestamp file within the CLI package
const sourceDirs = [path.join(cliPackageDir, 'src')]; // Source directory within the CLI package
const filesToWatch = [
path.join(cliPackageDir, 'package.json'),
path.join(cliPackageDir, 'tsconfig.json'),
]; // Specific files within the CLI package
const buildDir = path.join(cliPackageDir, 'dist'); // Build output directory within the CLI package
const warningsFilePath = path.join(os.tmpdir(), 'gemini-cli-warnings.txt'); // Temp file for warnings
// ---------------------
function getMtime(filePath) {
try {
return fs.statSync(filePath).mtimeMs; // Use mtimeMs for higher precision
} catch (err) {
if (err.code === 'ENOENT') {
return null; // File doesn't exist
}
console.error(`Error getting stats for ${filePath}:`, err);
process.exit(1); // Exit on unexpected errors getting stats
}
}
function findSourceFiles(dir, allFiles = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Simple check to avoid recursing into node_modules or build dir itself
if (
entry.isDirectory() &&
entry.name !== 'node_modules' &&
fullPath !== buildDir
) {
findSourceFiles(fullPath, allFiles);
} else if (entry.isFile()) {
allFiles.push(fullPath);
}
}
return allFiles;
}
console.log('Checking build status...');
// Clean up old warnings file before check
try {
if (fs.existsSync(warningsFilePath)) {
fs.unlinkSync(warningsFilePath);
}
} catch (err) {
console.warn(
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
);
}
const buildMtime = getMtime(buildTimestampPath);
if (!buildMtime) {
// If build is missing, write that as a warning and exit(0) so app can display it
const errorMessage = `ERROR: Build timestamp file (${path.relative(process.cwd(), buildTimestampPath)}) not found. Run \`npm run build\` first.`;
console.error(errorMessage); // Still log error here
try {
fs.writeFileSync(warningsFilePath, errorMessage);
} catch (writeErr) {
console.error(
`[Check Script] Error writing missing build warning file: ${writeErr.message}`,
);
}
process.exit(0); // Allow app to start and show the error
}
let newerSourceFileFound = false;
const warningMessages = []; // Collect warnings here
const allSourceFiles = [];
// Collect files from specified directories
sourceDirs.forEach((dir) => {
const dirPath = path.resolve(dir);
if (fs.existsSync(dirPath)) {
findSourceFiles(dirPath, allSourceFiles);
} else {
console.warn(`Warning: Source directory "${dir}" not found.`);
}
});
// Add specific files
filesToWatch.forEach((file) => {
const filePath = path.resolve(file);
if (fs.existsSync(filePath)) {
allSourceFiles.push(filePath);
} else {
console.warn(`Warning: Watched file "${file}" not found.`);
}
});
// Check modification times
for (const file of allSourceFiles) {
const sourceMtime = getMtime(file);
const relativePath = path.relative(process.cwd(), file);
const isNewer = sourceMtime && sourceMtime > buildMtime;
if (isNewer) {
const warning = `Warning: Source file "${relativePath}" has been modified since the last build.`;
console.warn(warning); // Keep console warning for script debugging
warningMessages.push(warning);
newerSourceFileFound = true;
// break; // Uncomment to stop checking after the first newer file
}
}
if (newerSourceFileFound) {
const finalWarning =
'\nRun "npm run build" to incorporate changes before starting.';
warningMessages.push(finalWarning);
console.warn(finalWarning);
// Write warnings to the temp file
try {
fs.writeFileSync(warningsFilePath, warningMessages.join('\n'));
// Removed debug log
} catch (err) {
console.error(`[Check Script] Error writing warnings file: ${err.message}`);
// Proceed without writing, app won't show warnings
}
} else {
console.log('Build is up-to-date.');
// Ensure no stale warning file exists if build is ok
try {
if (fs.existsSync(warningsFilePath)) {
fs.unlinkSync(warningsFilePath);
}
} catch (err) {
console.warn(
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
);
}
}
process.exit(0); // Always exit successfully so the app starts
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Diagnostic: instantiate the real Config and call the same listing functions
* the inbox UI uses. Should print out all skills + skill patches + memory
* patches the user would see in `/memory inbox`.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
const corePath = path.join(REPO_ROOT, 'packages/core/dist/src/index.js');
const { Storage, listInboxSkills, listInboxPatches, listInboxMemoryPatches } =
await import(corePath);
const cwd = process.cwd();
const storage = new Storage(cwd);
await storage.initialize();
const config = {
storage,
isTrustedFolder: () => true,
getProjectRoot: () => cwd,
};
const [skills, skillPatches, memoryPatches] = await Promise.all([
listInboxSkills(config),
listInboxPatches(config),
listInboxMemoryPatches(config),
]);
console.log(`\nInbox content for ${cwd}\n`);
console.log(`Skills (${skills.length}):`);
for (const s of skills) {
console.log(` - ${s.name} (${s.dirName})`);
}
console.log(`\nSkill update patches (${skillPatches.length}):`);
for (const p of skillPatches) {
console.log(` - ${p.name}${p.entries.length} entry/entries`);
}
console.log(`\nMemory patches (${memoryPatches.length}):`);
for (const m of memoryPatches) {
console.log(
` - [${m.kind}] ${m.relativePath}${m.entries.length} entry/entries`,
);
for (const e of m.entries) {
console.log(` ${e.isNewFile ? 'CREATE' : 'UPDATE'} ${e.targetPath}`);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const lockfilePath = join(root, 'package-lock.json');
function readJsonFile(filePath) {
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(fileContent);
} catch (error) {
console.error(`Error reading or parsing ${filePath}:`, error);
return null;
}
}
console.log('Checking lockfile...');
const lockfile = readJsonFile(lockfilePath);
if (lockfile === null) {
process.exit(1);
}
const packages = lockfile.packages || {};
const invalidPackages = [];
for (const [location, details] of Object.entries(packages)) {
// 1. Skip the root package itself.
if (location === '') {
continue;
}
// 2. Skip local workspace packages.
// They are identifiable in two ways:
// a) As a symlink within node_modules.
// b) As the source package definition, whose path is not in node_modules.
if (details.link === true || !location.includes('node_modules')) {
continue;
}
// 3. Any remaining package should be a third-party dependency.
// 1) Registry package with both "resolved" and "integrity" fields is valid.
if (details.resolved && details.integrity) {
continue;
}
// 2) Git and file dependencies only need a "resolved" field.
const isGitOrFileDep =
details.resolved?.startsWith('git') ||
details.resolved?.startsWith('file:');
if (isGitOrFileDep) {
continue;
}
// Mark the left dependency as invalid.
invalidPackages.push(location);
}
if (invalidPackages.length > 0) {
console.error(
'\nError: The following dependencies in package-lock.json are missing the "resolved" or "integrity" field:',
);
invalidPackages.forEach((pkg) => console.error(`- ${pkg}`));
process.exitCode = 1;
} else {
console.log('Lockfile check passed.');
}
// Check that gaxios v7+ is NOT resolved in any workspace node_modules.
// gaxios v7.x has a bug where Array.toString() joins stream chunks with
// commas, corrupting error response JSON at TCP chunk boundaries.
// See: https://github.com/google-gemini/gemini-cli/pull/21884
const gaxiosViolations = [];
for (const [location, details] of Object.entries(packages)) {
if (
location.match(/(^|\/)node_modules\/gaxios$/) &&
!location.includes('@google/genai/node_modules') &&
details.version &&
parseInt(details.version.split('.')[0], 10) >= 7
) {
gaxiosViolations.push(`${location} (v${details.version})`);
}
}
if (gaxiosViolations.length > 0) {
console.error(
'\nError: gaxios v7+ detected in workspace node_modules. This version has a stream corruption bug.',
);
console.error('See: https://github.com/google-gemini/gemini-cli/pull/21884');
gaxiosViolations.forEach((v) => console.error(`- ${v}`));
console.error(
'\nDo NOT upgrade @google/genai or google-auth-library until the gaxios v7 bug is fixed upstream.',
);
process.exitCode = 1;
}
if (!process.exitCode) {
process.exitCode = 0;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { rmSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
// remove npm install/build artifacts
rmSync(join(root, 'node_modules'), { recursive: true, force: true });
rmSync(join(root, 'bundle'), { recursive: true, force: true });
rmSync(join(root, 'packages/cli/src/generated/'), {
recursive: true,
force: true,
});
const RMRF_OPTIONS = { recursive: true, force: true };
rmSync(join(root, 'bundle'), RMRF_OPTIONS);
// Dynamically clean dist directories in all workspaces
const rootPackageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf-8'),
);
for (const workspace of rootPackageJson.workspaces) {
// Note: this is a simple glob implementation that only supports "packages/*".
const workspaceDir = join(root, dirname(workspace));
const packageDirs = readdirSync(workspaceDir);
for (const pkg of packageDirs) {
const pkgDir = join(workspaceDir, pkg);
try {
if (statSync(pkgDir).isDirectory()) {
rmSync(join(pkgDir, 'dist'), RMRF_OPTIONS);
}
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}
}
}
}
// Clean up vscode-ide-companion package
rmSync(join(root, 'packages/vscode-ide-companion/node_modules'), {
recursive: true,
force: true,
});
const vscodeCompanionDir = join(root, 'packages/vscode-ide-companion');
try {
const files = readdirSync(vscodeCompanionDir);
for (const file of files) {
if (file.endsWith('.vsix')) {
rmSync(join(vscodeCompanionDir, file), RMRF_OPTIONS);
}
}
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import * as readline from 'node:readline/promises';
import * as process from 'node:process';
function runCmd(cmd: string): string {
return execSync(cmd, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
}
async function main() {
try {
runCmd('gh --version');
} catch {
console.error(
'Error: "gh" CLI is required but not installed or not working.',
);
process.exit(1);
}
try {
runCmd('git --version');
} catch {
console.error('Error: "git" is required.');
process.exit(1);
}
console.log('Fetching remote branches from origin...');
let allBranchesOutput = '';
try {
// Also fetch to ensure we have the latest commit dates
console.log(
'Running git fetch to ensure we have up-to-date commit dates and prune stale branches...',
);
runCmd('git fetch origin --prune');
// Get all branches with their commit dates
allBranchesOutput = runCmd(
"git for-each-ref --format='%(refname:lstrip=3) %(committerdate:unix)' refs/remotes/origin",
);
} catch {
console.error('Failed to fetch branches from origin.');
process.exit(1);
}
const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60;
const now = Math.floor(Date.now() / 1000);
const remoteBranches: { name: string; lastCommitDate: number }[] =
allBranchesOutput
.split(/\r?\n/)
.map((line) => {
const parts = line.split(' ');
if (parts.length < 2) return null;
const date = parseInt(parts.pop() || '0', 10);
const name = parts.join(' ');
return { name, lastCommitDate: date };
})
.filter((b): b is { name: string; lastCommitDate: number } => b !== null);
console.log(`Found ${remoteBranches.length} branches on origin.`);
console.log('Fetching open PRs...');
let openPrsJson = '[]';
try {
openPrsJson = runCmd(
'gh pr list --state open --limit 5000 --json headRefName',
);
} catch {
console.error('Failed to fetch open PRs.');
process.exit(1);
}
const openPrs = JSON.parse(openPrsJson);
const openPrBranches = new Set(
openPrs.map((pr: { headRefName: string }) => pr.headRefName),
);
const protectedPattern =
/^(main|master|next|release[-/].*|hotfix[-/].*|v\d+.*|HEAD|gh-readonly-queue.*)$/;
const branchesToDelete = remoteBranches.filter((branch) => {
if (protectedPattern.test(branch.name)) {
return false;
}
if (openPrBranches.has(branch.name)) {
return false;
}
const ageInSeconds = now - branch.lastCommitDate;
if (ageInSeconds < THIRTY_DAYS_IN_SECONDS) {
return false; // Skip branches pushed to recently
}
return true;
});
if (branchesToDelete.length === 0) {
console.log('No remote branches to delete.');
return;
}
console.log(
'\nThe following remote branches are NOT release branches, have NO active PR, and are OLDER than 30 days:',
);
console.log(
'---------------------------------------------------------------------',
);
branchesToDelete.forEach((b) => console.log(` - ${b.name}`));
console.log(
'---------------------------------------------------------------------',
);
console.log(`Total to delete: ${branchesToDelete.length}`);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(
`\nDo you want to delete these ${branchesToDelete.length} remote branches from origin? (y/N) `,
);
rl.close();
if (answer.toLowerCase() === 'y') {
console.log('Deleting remote branches...');
// Delete in batches to avoid hitting command line length limits
const batchSize = 50;
for (let i = 0; i < branchesToDelete.length; i += batchSize) {
const batch = branchesToDelete.slice(i, i + batchSize).map((b) => b.name);
const branchList = batch.join(' ');
console.log(`Deleting remote batch ${Math.floor(i / batchSize) + 1}...`);
try {
execSync(`git push origin --delete ${branchList}`, {
stdio: 'inherit',
});
} catch {
console.warn('Batch failed, trying to delete branches individually...');
for (const branch of batch) {
try {
execSync(`git push origin --delete ${branch}`, {
stdio: 'pipe',
});
} catch (err: unknown) {
const error = err as { stderr?: Buffer; message?: string };
const stderr = error.stderr?.toString() || '';
if (!stderr.includes('remote ref does not exist')) {
console.error(
`Failed to delete branch "${branch}":`,
stderr.trim() || error.message,
);
}
}
}
}
}
console.log('Cleaning up local tracking branches...');
try {
execSync('git remote prune origin', { stdio: 'inherit' });
} catch {
console.error('Failed to prune local tracking branches.');
}
console.log('Cleanup complete.');
} else {
console.log('Operation cancelled.');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Octokit } from '@octokit/rest';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import prompts from 'prompts';
if (!process.env.GITHUB_TOKEN) {
console.error('Error: GITHUB_TOKEN environment variable is required.');
process.exit(1);
}
const argv = yargs(hideBin(process.argv))
.option('query', {
alias: 'q',
type: 'string',
description:
'Search query to find duplicate issues (e.g. "function response parts")',
demandOption: true,
})
.option('canonical', {
alias: 'c',
type: 'number',
description: 'The canonical issue number to duplicate others to',
demandOption: true,
})
.option('pr', {
type: 'string',
description:
'Optional Pull Request URL or ID to mention in the closing comment',
})
.option('owner', {
type: 'string',
default: 'google-gemini',
description: 'Repository owner',
})
.option('repo', {
type: 'string',
default: 'gemini-cli',
description: 'Repository name',
})
.option('dry-run', {
alias: 'd',
type: 'boolean',
default: false,
description: 'Run without making actual changes (read-only mode)',
})
.option('auto', {
type: 'boolean',
default: false,
description:
'Automatically close all duplicates without prompting (batch mode)',
})
.help()
.parse();
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const { query, canonical, pr, owner, repo, dryRun, auto } = argv;
// Construct the full search query ensuring it targets the specific repo and open issues
const fullSearchQuery = `repo:${owner}/${repo} is:issue is:open ${query}`;
async function run() {
console.log(`Searching for issues matching: ${fullSearchQuery}`);
if (dryRun) {
console.log('--- DRY RUN MODE: No changes will be made ---');
}
try {
const issues = await octokit.paginate(
octokit.rest.search.issuesAndPullRequests,
{
q: fullSearchQuery,
},
);
console.log(`Found ${issues.length} issues.`);
for (const issue of issues) {
if (issue.number === canonical) {
console.log(`Skipping canonical issue #${issue.number}`);
continue;
}
console.log(
`Processing issue #${issue.number}: ${issue.title} (by @${issue.user?.login})`,
);
if (!auto && !dryRun) {
const response = await prompts({
type: 'confirm',
name: 'value',
message: `Close issue #${issue.number} "${issue.title}" created by @${issue.user?.login}?`,
initial: true,
});
if (!response.value) {
console.log(`Skipping issue #${issue.number}`);
continue;
}
}
let commentBody = `Closing this issue as a duplicate of #${canonical}.`;
if (pr) {
commentBody += ` Please note that this issue should be resolved by PR ${pr}.`;
}
try {
if (!dryRun) {
// Add comment
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: commentBody,
});
console.log(` Added comment.`);
// Close issue
await octokit.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'duplicate',
});
console.log(` Closed issue.`);
} else {
console.log(` [DRY RUN] Would add comment: "${commentBody}"`);
console.log(` [DRY RUN] Would close issue #${issue.number}`);
}
} catch (error) {
console.error(
` Failed to process issue #${issue.number}:`,
error.message,
);
}
}
} catch (error) {
console.error('Error searching for issues:', error.message);
process.exit(1);
}
}
run().catch(console.error);
+142
View File
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Compares PR evaluation results against historical nightly baselines.
*
* This script generates a Markdown report for use in PR comments. It aligns with
* the 6-day lookback logic to show accurate historical pass rates and filters out
* pre-existing or noisy failures to ensure only actionable regressions are reported.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fetchNightlyHistory } from './eval_utils.js';
/**
* Main execution logic.
*/
function main() {
const prReportPath = 'evals/logs/pr_final_report.json';
const targetModel = process.argv[2];
if (!targetModel) {
console.error('❌ Error: No target model specified.');
process.exit(1);
}
if (!fs.existsSync(prReportPath)) {
console.error('No PR report found.');
return;
}
const prReport = JSON.parse(fs.readFileSync(prReportPath, 'utf-8'));
const history = fetchNightlyHistory(6); // Use same 6-day lookback
const latestNightly = aggregateHistoricalStats(history, targetModel);
const regressions = [];
const passes = [];
for (const [testName, pr] of Object.entries(prReport.results)) {
const prRate = pr.passed / pr.total;
if (pr.status === 'regression' || (prRate <= 0.34 && !pr.status)) {
// Use relative path from workspace root
const relativeFile = pr.file
? path.relative(process.cwd(), pr.file)
: 'evals/';
regressions.push({
name: testName,
file: relativeFile,
nightly: latestNightly[testName]
? (latestNightly[testName].passRate * 100).toFixed(0) + '%'
: 'N/A',
pr: (prRate * 100).toFixed(0) + '%',
});
} else {
passes.push(testName);
}
}
if (regressions.length > 0) {
let markdown = '### 🚨 Action Required: Eval Regressions Detected\n\n';
markdown += `**Model:** \`${targetModel}\`\n\n`;
markdown +=
'The following trustworthy evaluations passed on **`main`** and in **recent Nightly runs**, but failed in this PR. These regressions must be addressed before merging.\n\n';
markdown += '| Test Name | Nightly | PR Result | Status |\n';
markdown += '| :--- | :---: | :---: | :--- |\n';
for (const r of regressions) {
markdown += `| ${r.name} | ${r.nightly} | ${r.pr} | ❌ **Regression** |\n`;
}
markdown += `\n*The check passed or was cleared for ${passes.length} other trustworthy evaluations.*\n\n`;
markdown += '<details>\n';
markdown +=
'<summary><b>🛠️ Troubleshooting & Fix Instructions</b></summary>\n\n';
for (let i = 0; i < regressions.length; i++) {
const r = regressions[i];
if (regressions.length > 1) {
markdown += `### Failure ${i + 1}: ${r.name}\n\n`;
}
markdown += '#### 1. Ask Gemini CLI to fix it (Recommended)\n';
markdown += 'Copy and paste this prompt to the agent:\n';
markdown += '```text\n';
markdown += `The eval "${r.name}" in ${r.file} is failing. Investigate and fix it using the behavioral-evals skill.\n`;
markdown += '```\n\n';
markdown += '#### 2. Reproduce Locally\n';
markdown += 'Run the following command to see the failure trajectory:\n';
markdown += '```bash\n';
const pattern = r.name.replace(/'/g, '.');
markdown += `GEMINI_MODEL=${targetModel} npm run test:all_evals -- ${r.file} --testNamePattern="${pattern}"\n`;
markdown += '```\n\n';
if (i < regressions.length - 1) {
markdown += '---\n\n';
}
}
markdown += '#### 3. Manual Fix\n';
markdown +=
'See the [Fixing Guide](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#fixing-evaluations) for detailed troubleshooting steps.\n';
markdown += '</details>\n';
process.stdout.write(markdown);
} else if (passes.length > 0) {
// Success State
process.stdout.write(
`✅ **${passes.length}** tests passed successfully on **${targetModel}**.\n`,
);
}
}
/**
* Aggregates stats from history for a specific model.
*/
function aggregateHistoricalStats(history, model) {
const stats = {};
for (const item of history) {
const modelStats = item.stats[model];
if (!modelStats) continue;
for (const [testName, stat] of Object.entries(modelStats)) {
if (!stats[testName]) stats[testName] = { passed: 0, total: 0 };
stats[testName].passed += stat.passed;
stats[testName].total += stat.total;
}
}
for (const name in stats) {
stats[name].passRate = stats[name].passed / stats[name].total;
}
return stats;
}
main();
+118
View File
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { copyFileSync, existsSync, mkdirSync, cpSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { glob } from 'glob';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const bundleDir = join(root, 'bundle');
// Create the bundle directory if it doesn't exist
if (!existsSync(bundleDir)) {
mkdirSync(bundleDir);
}
// 1. Copy Sandbox definitions (.sb)
const sbFiles = glob.sync('packages/**/*.sb', { cwd: root });
for (const file of sbFiles) {
copyFileSync(join(root, file), join(bundleDir, basename(file)));
}
// 2. Copy Policy definitions (.toml)
const policyDir = join(bundleDir, 'policies');
if (!existsSync(policyDir)) {
mkdirSync(policyDir);
}
// Locate policy files specifically in the core package
const policyFiles = glob.sync('packages/core/src/policy/policies/*.toml', {
cwd: root,
});
for (const file of policyFiles) {
copyFileSync(join(root, file), join(policyDir, basename(file)));
}
console.log(`Copied ${policyFiles.length} policy files to bundle/policies/`);
// Also copy policies to a2a-server dist directory for bundled execution
const a2aPolicyDir = join(root, 'packages/a2a-server/dist/policies');
if (!existsSync(a2aPolicyDir)) {
mkdirSync(a2aPolicyDir, { recursive: true });
}
for (const file of policyFiles) {
copyFileSync(join(root, file), join(a2aPolicyDir, basename(file)));
}
console.log(
`Copied ${policyFiles.length} policy files to packages/a2a-server/dist/policies/`,
);
// 3. Copy Documentation (docs/)
const docsSrc = join(root, 'docs');
const docsDest = join(bundleDir, 'docs');
if (existsSync(docsSrc)) {
cpSync(docsSrc, docsDest, { recursive: true, dereference: true });
console.log('Copied docs to bundle/docs/');
}
// 4. Copy Built-in Skills (packages/core/src/skills/builtin)
const builtinSkillsSrc = join(root, 'packages/core/src/skills/builtin');
const builtinSkillsDest = join(bundleDir, 'builtin');
if (existsSync(builtinSkillsSrc)) {
cpSync(builtinSkillsSrc, builtinSkillsDest, {
recursive: true,
dereference: true,
});
console.log('Copied built-in skills to bundle/builtin/');
}
// 5. Copy bundled chrome-devtools-mcp
const bundleMcpSrc = join(root, 'packages/core/dist/bundled');
const bundleMcpDest = join(bundleDir, 'bundled');
if (!existsSync(bundleMcpSrc)) {
console.error(
`Error: chrome-devtools-mcp bundle not found at ${bundleMcpSrc}.\n` +
`Run "npm run bundle:browser-mcp -w @google/gemini-cli-core" first.`,
);
process.exit(1);
}
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
// 6. Copy Extension Examples
const extensionExamplesSrc = join(
root,
'packages/cli/src/commands/extensions/examples',
);
const extensionExamplesDest = join(bundleDir, 'examples');
const EXCLUDED_EXAMPLE_DIRS = ['node_modules', 'dist'];
if (existsSync(extensionExamplesSrc)) {
cpSync(extensionExamplesSrc, extensionExamplesDest, {
recursive: true,
dereference: true,
filter: (src) => !EXCLUDED_EXAMPLE_DIRS.some((dir) => src.includes(dir)),
});
console.log('Copied extension examples to bundle/examples/');
}
console.log('Assets copied to bundle/');
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import fs from 'node:fs';
import path from 'node:path';
const sourceDir = path.join('src');
const targetDir = path.join('dist', 'src');
const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe'];
function copyFilesRecursive(source, target) {
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
const items = fs.readdirSync(source, { withFileTypes: true });
for (const item of items) {
const sourcePath = path.join(source, item.name);
const targetPath = path.join(target, item.name);
if (item.isDirectory()) {
copyFilesRecursive(sourcePath, targetPath);
} else if (extensionsToCopy.includes(path.extname(item.name))) {
fs.copyFileSync(sourcePath, targetPath);
}
}
}
if (!fs.existsSync(sourceDir)) {
console.error(`Source directory ${sourceDir} not found.`);
process.exit(1);
}
copyFilesRecursive(sourceDir, targetDir);
// Copy example extensions into the bundle.
const packageName = path.basename(process.cwd());
if (packageName === 'cli') {
const examplesSource = path.join(
sourceDir,
'commands',
'extensions',
'examples',
);
const examplesTarget = path.join(
targetDir,
'commands',
'extensions',
'examples',
);
if (fs.existsSync(examplesSource)) {
fs.cpSync(examplesSource, examplesTarget, { recursive: true });
}
}
// Copy built-in skills for the core package.
if (packageName === 'core') {
const builtinSkillsSource = path.join(sourceDir, 'skills', 'builtin');
const builtinSkillsTarget = path.join(targetDir, 'skills', 'builtin');
if (fs.existsSync(builtinSkillsSource)) {
fs.cpSync(builtinSkillsSource, builtinSkillsTarget, { recursive: true });
}
}
console.log('Successfully copied files.');
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# This script creates an alias for the Gemini CLI
# Determine the project directory
PROJECT_DIR=$(cd "$(dirname "$0")/.." && pwd)
ALIAS_COMMAND="alias gemini='node "${PROJECT_DIR}/scripts/start.js"'"
# Detect shell and set config file path
if [[ "${SHELL}" == *"/bash" ]]; then
CONFIG_FILE="${HOME}/.bashrc"
elif [[ "${SHELL}" == *"/zsh" ]]; then
CONFIG_FILE="${HOME}/.zshrc"
else
echo "Unsupported shell. Only bash and zsh are supported."
exit 1
fi
echo "This script will add the following alias to your shell configuration file (${CONFIG_FILE}):"
echo " ${ALIAS_COMMAND}"
echo ""
# Check if the alias already exists
if grep -q "alias gemini=" "${CONFIG_FILE}"; then
echo "A 'gemini' alias already exists in ${CONFIG_FILE}. No changes were made."
exit 0
fi
read -p "Do you want to proceed? (y/n) " -n 1 -r
echo ""
if [[ "${REPLY}" =~ ^[Yy]$ ]]; then
echo "${ALIAS_COMMAND}" >> "${CONFIG_FILE}"
echo ""
echo "Alias added to ${CONFIG_FILE}."
echo "Please run 'source ${CONFIG_FILE}' or open a new terminal to use the 'gemini' command."
else
echo "Aborted. No changes were made."
fi
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
// Script to deflake tests
// Ex. npm run deflake -- --command="npm run test:e2e -- --test-name-pattern 'extension'" --runs=3
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const dockerIgnorePath = path.join(projectRoot, '.dockerignore');
const DOCKERIGNORE_CONTENT = `.integration-tests`.trim();
/**
* Runs a command and streams its output to the console.
* @param {string} command The command string to execute (e.g., 'npm run test:e2e -- --watch').
* @returns {Promise<number>} A Promise that resolves with the exit code of the process.
*/
function runCommand(cmd, args = []) {
if (!cmd) {
return Promise.resolve(1);
}
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
shell: true,
stdio: 'inherit',
env: { ...process.env },
});
child.on('close', (code) => {
resolve(code ?? 1); // code can be null if the process was killed
});
child.on('error', (err) => {
// An error occurred in spawning the process (e.g., command not found).
console.error(`Failed to start command: ${err.message}`);
reject(err);
});
});
}
// -------------------------------------------------------------------
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('command', {
type: 'string',
demandOption: true,
description: 'The command to run',
})
.option('runs', {
type: 'number',
default: 5,
description: 'The number of runs to perform',
}).argv;
const NUM_RUNS = argv.runs;
const COMMAND = argv.command;
const ARGS = argv._;
let failures = 0;
const backupDockerIgnorePath = dockerIgnorePath + '.bak';
let originalDockerIgnoreRenamed = false;
console.log(`--- Starting Deflake Run (${NUM_RUNS} iterations) ---`);
try {
try {
// Try to rename to back up an existing .dockerignore
await fs.rename(dockerIgnorePath, backupDockerIgnorePath);
originalDockerIgnoreRenamed = true;
} catch (err) {
// If the file doesn't exist, that's fine. Otherwise, rethrow.
if (err.code !== 'ENOENT') throw err;
}
// Create the temporary .dockerignore for this run.
await fs.writeFile(dockerIgnorePath, DOCKERIGNORE_CONTENT);
for (let i = 1; i <= NUM_RUNS; i++) {
console.log(`\n[RUN ${i}/${NUM_RUNS}]`);
try {
const exitCode = await runCommand(COMMAND, ARGS);
if (exitCode === 0) {
console.log('✅ Run PASS');
} else {
console.log(`❌ Run FAIL (Exit Code: ${exitCode})`);
failures++;
}
} catch (error) {
console.error('❌ Run FAIL (Execution Error)', error);
failures++;
}
}
} finally {
try {
// Clean up the temporary .dockerignore
await fs.unlink(dockerIgnorePath);
} catch (err) {
console.error('Failed to remove temporary .dockerignore:', err);
}
if (originalDockerIgnoreRenamed) {
try {
// Restore the original .dockerignore if it was backed up.
await fs.rename(backupDockerIgnorePath, dockerIgnorePath);
} catch (err) {
console.error('Failed to restore original .dockerignore:', err);
}
}
}
console.log('\n--- FINAL DEFLAKE SUMMARY ---');
console.log(`Total Runs: ${NUM_RUNS}`);
console.log(`Total Failures: ${failures}`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((error) => {
console.error('Error in deflake:', error);
process.exit(1);
});
+146
View File
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview This script downloads pre-built ripgrep binaries for all supported
* architectures and platforms. These binaries are checked into the repository
* under packages/core/vendor/ripgrep.
*
* Maintainers should periodically run this script to upgrade the version
* of ripgrep being distributed.
*
* Usage: npx tsx scripts/download-ripgrep-binaries.ts
*/
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import { execFileSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep');
const VERSION = 'v13.0.0-10';
interface Target {
platform: string;
arch: string;
file: string;
}
const targets: Target[] = [
{ platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' },
{ platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' },
{
platform: 'linux',
arch: 'arm64',
file: 'aarch64-unknown-linux-gnu.tar.gz',
},
{ platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' },
{ platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' },
];
async function downloadBinary() {
await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true });
for (const target of targets) {
const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`;
const archivePath = path.join(CORE_VENDOR_DIR, target.file);
const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`;
const finalBinPath = path.join(CORE_VENDOR_DIR, binName);
if (fs.existsSync(finalBinPath)) {
console.log(`[Cache] ${binName} already exists.`);
continue;
}
console.log(`[Download] ${url} -> ${archivePath}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
if (!response.body) {
throw new Error(`Response body is null for ${url}`);
}
const fileStream = createWriteStream(archivePath);
// Node 18+ global fetch response.body is a ReadableStream (web stream)
// pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+
await pipeline(
Readable.fromWeb(response.body as ReadableStream),
fileStream,
);
console.log(`[Extract] Extracting ${archivePath}...`);
// Extract using shell commands for simplicity
if (target.file.endsWith('.tar.gz')) {
execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]);
// Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg');
if (fs.existsSync(sourceBin)) {
await fsPromises.rename(sourceBin, finalBinPath);
} else {
// Fallback for sub-directory if it happens
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`;
const fallbackSourceBin = path.join(
CORE_VENDOR_DIR,
extractedDirName,
'rg',
);
if (fs.existsSync(fallbackSourceBin)) {
await fsPromises.rename(fallbackSourceBin, finalBinPath);
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
recursive: true,
force: true,
});
} else {
throw new Error(
`Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`,
);
}
}
} else if (target.file.endsWith('.zip')) {
execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]);
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe');
if (fs.existsSync(sourceBin)) {
await fsPromises.rename(sourceBin, finalBinPath);
} else {
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`;
const fallbackSourceBin = path.join(
CORE_VENDOR_DIR,
extractedDirName,
'rg.exe',
);
if (fs.existsSync(fallbackSourceBin)) {
await fsPromises.rename(fallbackSourceBin, finalBinPath);
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
recursive: true,
force: true,
});
} else {
throw new Error(
`Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`,
);
}
}
}
// Clean up archive
await fsPromises.unlink(archivePath);
console.log(`[Success] Saved to ${finalBinPath}`);
}
}
downloadBinary().catch((err) => {
console.error(err);
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation (Required for Node.js/V8) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow executable memory modification (Required for Node.js/V8) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading unsigned libraries (Helpful for native modules extracted to temp) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow access to environment variables (Standard for CLI tools) -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env tsx
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview CLI entry point for the eval inventory command.
*
* Scans all eval source files, runs the static analyzer on each,
* and prints an inventory report grouped by policy, file, and suite.
*
* Usage:
* npm run eval:inventory
* npm run eval:inventory -- --json
* npm run eval:inventory -- --root /path/to/repo
* npm run eval:inventory -- --root /path/to/repo --json
*/
import {
collectInventory,
formatInventoryJson,
formatInventoryReport,
} from './utils/eval-inventory.js';
async function main() {
const rootFlagIndex = process.argv.indexOf('--root');
const rootFlagValue =
rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined;
if (rootFlagIndex !== -1 && rootFlagValue === undefined) {
console.error(
'Error: --root requires a directory path argument but none was provided.',
);
process.exit(1);
}
if (rootFlagValue && rootFlagValue.startsWith('--')) {
console.error(
`Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`,
);
process.exit(1);
}
const repoRoot = rootFlagValue ?? process.cwd();
const jsonMode = process.argv.includes('--json');
const result = await collectInventory(repoRoot);
if (result.totalFiles === 0) {
console.error('No eval files found under evals/.');
process.exit(1);
}
console.log(
jsonMode ? formatInventoryJson(result) : formatInventoryReport(result),
);
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import os from 'node:os';
/**
* Finds all report.json files recursively in a directory.
*/
export function findReports(dir) {
const reports = [];
if (!fs.existsSync(dir)) return reports;
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
reports.push(...findReports(fullPath));
} else if (file === 'report.json') {
reports.push(fullPath);
}
}
return reports;
}
/**
* Extracts the model name from the artifact path.
*/
export function getModelFromPath(reportPath) {
const parts = reportPath.split(path.sep);
// Look for the directory that follows the 'eval-logs-' pattern
const artifactDir = parts.find((p) => p.startsWith('eval-logs-'));
if (!artifactDir) return 'unknown';
const match = artifactDir.match(/^eval-logs-(.+)-(\d+)$/);
return match ? match[1] : 'unknown';
}
/**
* Escapes special characters in a string for use in a regular expression.
*/
export function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Aggregates stats from a list of report.json files.
* @returns {Record<string, Record<string, {passed: number, total: number, file?: string}>>} statsByModel
*/
export function getStatsFromReports(reports) {
const statsByModel = {};
for (const reportPath of reports) {
try {
const model = getModelFromPath(reportPath);
if (!statsByModel[model]) {
statsByModel[model] = {};
}
const testStats = statsByModel[model];
const content = fs.readFileSync(reportPath, 'utf-8');
const json = JSON.parse(content);
for (const testResult of json.testResults) {
const filePath = testResult.name;
for (const assertion of testResult.assertionResults) {
const name = assertion.title;
if (!testStats[name]) {
testStats[name] = { passed: 0, total: 0, file: filePath };
}
testStats[name].total++;
if (assertion.status === 'passed') {
testStats[name].passed++;
}
}
}
} catch (error) {
console.error(`Error processing report at ${reportPath}:`, error.message);
}
}
return statsByModel;
}
/**
* Fetches historical nightly data using the GitHub CLI.
* @returns {Array<{runId: string, stats: Record<string, any>}>} history
*/
export function fetchNightlyHistory(lookbackCount) {
const history = [];
try {
const cmd = `gh run list --workflow evals-nightly.yml --branch main --limit ${
lookbackCount + 2
} --json databaseId,status`;
const runsJson = execSync(cmd, { encoding: 'utf-8' });
let runs = JSON.parse(runsJson);
// Filter for completed runs and take the top N
runs = runs.filter((r) => r.status === 'completed').slice(0, lookbackCount);
for (const run of runs) {
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-evals-hist-${run.databaseId}-`),
);
try {
execSync(
`gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`,
{ stdio: 'ignore' },
);
const runReports = findReports(tmpDir);
if (runReports.length > 0) {
history.push({
runId: run.databaseId,
stats: getStatsFromReports(runReports),
});
}
} catch (error) {
console.error(
`Failed to process artifacts for run ${run.databaseId}:`,
error.message,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
} catch (error) {
console.error('Failed to fetch history:', error.message);
}
return history;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readPackageUp } from 'read-package-up';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const scriptPath = relative(root, fileURLToPath(import.meta.url));
const generatedCliDir = join(root, 'packages/cli/src/generated');
const cliGitCommitFile = join(generatedCliDir, 'git-commit.ts');
const generatedCoreDir = join(root, 'packages/core/src/generated');
const coreGitCommitFile = join(generatedCoreDir, 'git-commit.ts');
let gitCommitInfo = 'N/A';
let cliVersion = 'UNKNOWN';
if (!existsSync(generatedCliDir)) {
mkdirSync(generatedCliDir, { recursive: true });
}
if (!existsSync(generatedCoreDir)) {
mkdirSync(generatedCoreDir, { recursive: true });
}
try {
// Check for GIT_COMMIT env var first (e.g. when building inside Docker
// without a .git directory available)
const envCommit = process.env.GIT_COMMIT;
if (envCommit && /^[0-9a-f]+$/i.test(envCommit)) {
gitCommitInfo = envCommit;
} else {
const gitHash = execSync('git rev-parse --short HEAD', {
encoding: 'utf-8',
}).trim();
if (gitHash) {
gitCommitInfo = gitHash;
}
}
const result = await readPackageUp();
cliVersion = result?.packageJson?.version ?? 'UNKNOWN';
} catch {
// ignore
}
const fileContent = `/**
* @license
* Copyright ${new Date().getUTCFullYear()} Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// This file is auto-generated by the build script (${scriptPath})
// Do not edit this file manually.
export const GIT_COMMIT_INFO = '${gitCommitInfo}';
export const CLI_VERSION = '${cliVersion}';
`;
writeFileSync(cliGitCommitFile, fileContent);
writeFileSync(coreGitCommitFile, fileContent);
+177
View File
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFile, writeFile } from 'node:fs/promises';
import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js';
import {
commandCategories,
commandDescriptions,
defaultKeyBindingConfig,
Command,
getPlatformUndoBindings,
getPlatformRedoBindings,
} from '../packages/cli/src/ui/key/keyBindings.js';
import {
formatWithPrettier,
injectBetweenMarkers,
normalizeForCompare,
} from './utils/autogen.js';
const START_MARKER = '<!-- KEYBINDINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- KEYBINDINGS-AUTOGEN:END -->';
const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md'];
import { formatKeyBinding } from '../packages/cli/src/ui/key/keybindingUtils.js';
export interface KeybindingDocCommand {
command: string;
description: string;
bindings: readonly KeyBinding[];
}
export interface KeybindingDocSection {
title: string;
commands: readonly KeybindingDocCommand[];
}
export async function main(argv = process.argv.slice(2)) {
const checkOnly = argv.includes('--check');
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const docPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH);
const sections = buildDefaultDocSections();
const generatedBlock = renderDocumentation(sections);
const currentDoc = await readFile(docPath, 'utf8');
const injectedDoc = injectBetweenMarkers({
document: currentDoc,
startMarker: START_MARKER,
endMarker: END_MARKER,
newContent: generatedBlock,
paddingBefore: '\n\n',
paddingAfter: '\n',
});
const updatedDoc = await formatWithPrettier(injectedDoc, docPath);
if (normalizeForCompare(updatedDoc) === normalizeForCompare(currentDoc)) {
if (!checkOnly) {
console.log('Keybinding documentation already up to date.');
}
return;
}
if (checkOnly) {
console.error(
'Keybinding documentation is out of date. Run `npm run docs:keybindings` to regenerate.',
);
process.exitCode = 1;
return;
}
await writeFile(docPath, updatedDoc, 'utf8');
console.log('Keybinding documentation regenerated.');
}
export function buildDefaultDocSections(): readonly KeybindingDocSection[] {
return commandCategories.map((category) => ({
title: category.title,
commands: category.commands.map((command) => {
// For UNDO and REDO, we want to show all platform variants in the docs
if (command === Command.UNDO) {
return {
command: command,
description: commandDescriptions[command],
bindings: getMergedPlatformBindings(getPlatformUndoBindings),
};
}
if (command === Command.REDO) {
return {
command: command,
description: commandDescriptions[command],
bindings: getMergedPlatformBindings(getPlatformRedoBindings),
};
}
return {
command: command,
description: commandDescriptions[command],
bindings: defaultKeyBindingConfig.get(command) ?? [],
};
}),
}));
}
function getMergedPlatformBindings(
getBindings: (platform: string) => readonly KeyBinding[],
): readonly KeyBinding[] {
const win32 = getBindings('win32');
const darwin = getBindings('darwin');
const linux = getBindings('linux');
const all = [...win32, ...darwin, ...linux];
const seen = new Set<string>();
const unique: KeyBinding[] = [];
for (const b of all) {
const key = `${b.name}-${b.ctrl}-${b.shift}-${b.alt}-${b.cmd}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(b);
}
}
return unique;
}
export function renderDocumentation(
sections: readonly KeybindingDocSection[],
): string {
const renderedSections = sections.map((section) => {
const rows = section.commands.map((command) => {
const formattedBindings = formatBindings(command.bindings);
const keysCell = formattedBindings.join('<br />');
return `| \`${command.command}\` | ${command.description} | ${keysCell} |`;
});
return [
`#### ${section.title}`,
'',
'| Command | Action | Keys |',
'| --- | --- | --- |',
...rows,
].join('\n');
});
return renderedSections.join('\n\n');
}
function formatBindings(bindings: readonly KeyBinding[]): string[] {
const seen = new Set<string>();
const results: string[] = [];
for (const binding of bindings) {
const label = formatKeyBinding(binding, 'default');
if (label && !seen.has(label)) {
seen.add(label);
results.push(`\`${label}\``);
}
}
return results;
}
if (process.argv[1]) {
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
if (entryUrl === import.meta.url) {
await main();
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFile, writeFile } from 'node:fs/promises';
import { generateSettingsSchema } from './generate-settings-schema.js';
import {
escapeBackticks,
formatDefaultValue,
formatWithPrettier,
injectBetweenMarkers,
normalizeForCompare,
} from './utils/autogen.js';
import type {
SettingDefinition,
SettingsSchema,
SettingsSchemaType,
} from '../packages/cli/src/config/settingsSchema.js';
const START_MARKER = '<!-- SETTINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- SETTINGS-AUTOGEN:END -->';
const MANUAL_TOP_LEVEL = new Set(['mcpServers', 'telemetry', 'extensions']);
interface DocEntry {
path: string;
type: string;
label: string;
category: string;
description: string;
defaultValue: string;
requiresRestart: boolean;
enumValues?: string[];
}
export async function main(argv = process.argv.slice(2)) {
const checkOnly = argv.includes('--check');
await generateSettingsSchema({ checkOnly });
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const docPath = path.join(repoRoot, 'docs/reference/configuration.md');
const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md');
const { getSettingsSchema } = await loadSettingsSchemaModule();
const schema = getSettingsSchema();
const allSettingsSections = collectEntries(schema, { includeAll: true });
const filteredSettingsSections = collectEntries(schema, {
includeAll: false,
});
const generatedBlock = renderSections(allSettingsSections);
const generatedTableBlock = renderTableSections(filteredSettingsSections);
await updateFile(docPath, generatedBlock, checkOnly);
await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly);
}
async function updateFile(
filePath: string,
newContent: string,
checkOnly: boolean,
) {
const doc = await readFile(filePath, 'utf8');
const injectedDoc = injectBetweenMarkers({
document: doc,
startMarker: START_MARKER,
endMarker: END_MARKER,
newContent: newContent,
paddingBefore: '\n',
paddingAfter: '\n',
});
const formattedDoc = await formatWithPrettier(injectedDoc, filePath);
if (normalizeForCompare(doc) === normalizeForCompare(formattedDoc)) {
if (!checkOnly) {
console.log(
`Settings documentation (${path.basename(filePath)}) already up to date.`,
);
}
return;
}
if (checkOnly) {
console.error(
'Settings documentation (' +
path.basename(filePath) +
') is out of date. Run `npm run docs:settings` to regenerate.',
);
process.exitCode = 1;
return;
}
await writeFile(filePath, formattedDoc);
console.log(
`Settings documentation (${path.basename(filePath)}) regenerated.`,
);
}
async function loadSettingsSchemaModule() {
const modulePath = '../packages/cli/src/config/settingsSchema.ts';
return import(modulePath);
}
function collectEntries(
schema: SettingsSchemaType,
options: { includeAll?: boolean } = {},
) {
const sections = new Map<string, DocEntry[]>();
const visit = (
current: SettingsSchema,
pathSegments: string[],
topLevel?: string,
) => {
for (const [key, definition] of Object.entries(current)) {
if (pathSegments.length === 0 && MANUAL_TOP_LEVEL.has(key)) {
continue;
}
const newPathSegments = [...pathSegments, key];
const sectionKey = topLevel ?? key;
const hasChildren =
definition.type === 'object' &&
definition.properties &&
Object.keys(definition.properties).length > 0;
if (definition.ignoreInDocs) {
continue;
}
if (!hasChildren && (options.includeAll || definition.showInDialog)) {
if (!sections.has(sectionKey)) {
sections.set(sectionKey, []);
}
sections.get(sectionKey)!.push({
path: newPathSegments.join('.'),
type: formatType(definition),
label: definition.label,
category: definition.category,
description: formatDescription(definition),
defaultValue: formatDefaultValue(definition.default, {
quoteStrings: true,
}),
requiresRestart: Boolean(definition.requiresRestart),
enumValues: definition.options?.map((option) =>
formatDefaultValue(option.value, { quoteStrings: true }),
),
});
}
if (hasChildren && definition.properties) {
visit(definition.properties, newPathSegments, sectionKey);
}
}
};
visit(schema, []);
return sections;
}
function formatDescription(definition: SettingDefinition) {
if (definition.description?.trim()) {
return definition.description.trim();
}
return 'Description not provided.';
}
function formatType(definition: SettingDefinition): string {
switch (definition.ref) {
case 'StringOrStringArray':
return 'string | string[]';
case 'BooleanOrString':
return 'boolean | string';
default:
return definition.type;
}
}
function renderSections(sections: Map<string, DocEntry[]>) {
const lines: string[] = [];
for (const [section, entries] of sections) {
if (entries.length === 0) {
continue;
}
lines.push('#### `' + section + '`');
lines.push('');
for (const entry of entries) {
lines.push('- **`' + entry.path + '`** (' + entry.type + '):');
lines.push(' - **Description:** ' + entry.description);
if (entry.defaultValue.includes('\n')) {
lines.push(' - **Default:**');
lines.push('');
lines.push(' ```json');
lines.push(
entry.defaultValue
.split('\n')
.map((line) => ' ' + line)
.join('\n'),
);
lines.push(' ```');
} else {
lines.push(
' - **Default:** `' + escapeBackticks(entry.defaultValue) + '`',
);
}
if (entry.enumValues && entry.enumValues.length > 0) {
const values = entry.enumValues
.map((value) => '`' + escapeBackticks(value) + '`')
.join(', ');
lines.push(' - **Values:** ' + values);
}
if (entry.requiresRestart) {
lines.push(' - **Requires restart:** Yes');
}
lines.push('');
}
}
return lines.join('\n').trimEnd();
}
function renderTableSections(sections: Map<string, DocEntry[]>) {
const lines: string[] = [];
for (const [section, entries] of sections) {
if (entries.length === 0) {
continue;
}
let title = section.charAt(0).toUpperCase() + section.slice(1);
if (title === 'Ui') {
title = 'UI';
} else if (title === 'Ide') {
title = 'IDE';
}
lines.push(`### ${title}`);
lines.push('');
lines.push('| UI Label | Setting | Description | Default |');
lines.push('| --- | --- | --- | --- |');
for (const entry of entries) {
const val = entry.defaultValue.replace(/\n/g, ' ');
const defaultVal = '`' + escapeBackticks(val) + '`';
lines.push(
'| ' +
entry.label +
' | `' +
entry.path +
'` | ' +
entry.description +
' | ' +
defaultVal +
' |',
);
}
lines.push('');
}
return lines.join('\n').trimEnd();
}
if (process.argv[1]) {
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
if (entryUrl === import.meta.url) {
await main();
}
}
+362
View File
@@ -0,0 +1,362 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import {
getSettingsSchema,
type SettingCollectionDefinition,
type SettingDefinition,
type SettingsSchema,
type SettingsSchemaType,
SETTINGS_SCHEMA_DEFINITIONS,
type SettingsJsonSchemaDefinition,
} from '../packages/cli/src/config/settingsSchema.js';
import {
formatDefaultValue,
formatWithPrettier,
normalizeForCompare,
} from './utils/autogen.js';
const OUTPUT_RELATIVE_PATH = ['schemas', 'settings.schema.json'];
const SCHEMA_ID =
'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json';
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
interface JsonSchema {
[key: string]: JsonValue | JsonSchema | JsonSchema[] | undefined;
$schema?: string;
$id?: string;
title?: string;
description?: string;
markdownDescription?: string;
type?: string | string[];
enum?: JsonPrimitive[];
default?: JsonValue;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: boolean | JsonSchema;
required?: string[];
$ref?: string;
anyOf?: JsonSchema[];
}
interface GenerateOptions {
checkOnly: boolean;
}
export async function generateSettingsSchema(
options: GenerateOptions,
): Promise<void> {
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const outputPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH);
await mkdir(path.dirname(outputPath), { recursive: true });
const schemaObject = buildSchemaObject(getSettingsSchema());
const formatted = await formatWithPrettier(
JSON.stringify(schemaObject, null, 2),
outputPath,
);
let existing: string | undefined;
try {
existing = await readFile(outputPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
if (
existing &&
normalizeForCompare(existing) === normalizeForCompare(formatted)
) {
if (!options.checkOnly) {
console.log('Settings JSON schema already up to date.');
}
return;
}
if (options.checkOnly) {
console.error(
'Settings JSON schema is out of date. Run `npm run schema:settings` to regenerate.',
);
process.exitCode = 1;
return;
}
await writeFile(outputPath, formatted);
console.log('Settings JSON schema regenerated.');
}
export async function main(argv = process.argv.slice(2)): Promise<void> {
const checkOnly = argv.includes('--check');
await generateSettingsSchema({ checkOnly });
}
function buildSchemaObject(schema: SettingsSchemaType): JsonSchema {
const defs = new Map<string, JsonSchema>(
Object.entries(SETTINGS_SCHEMA_DEFINITIONS as Record<string, JsonSchema>),
);
const root: JsonSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: SCHEMA_ID,
title: 'Gemini CLI Settings',
description:
'Configuration file schema for Gemini CLI settings. This schema enables IDE completion for `settings.json`.',
type: 'object',
additionalProperties: false,
properties: {},
};
root.properties!['$schema'] = {
title: 'Schema',
description:
'The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.',
type: 'string',
default: SCHEMA_ID,
};
for (const [key, definition] of Object.entries(schema)) {
root.properties![key] = buildSettingSchema(definition, [key], defs);
}
if (defs.size > 0) {
root.$defs = Object.fromEntries(defs.entries());
}
return root;
}
function buildSettingSchema(
definition: SettingDefinition,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
const base: JsonSchema = {
title: definition.label,
description: definition.description,
markdownDescription: buildMarkdownDescription(definition),
};
if (definition.default !== undefined) {
base.default = definition.default as JsonValue;
}
const schemaShape = definition.ref
? buildRefSchema(definition.ref, defs)
: buildSchemaForType(definition, pathSegments, defs);
return { ...base, ...schemaShape };
}
function buildCollectionSchema(
collection: SettingCollectionDefinition,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
if (collection.ref) {
return buildRefSchema(collection.ref, defs);
}
return buildSchemaForType(collection, pathSegments, defs);
}
function buildSchemaForType(
source: SettingDefinition | SettingCollectionDefinition,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
switch (source.type) {
case 'boolean':
case 'string':
case 'number':
return { type: source.type };
case 'enum':
return buildEnumSchema(source.options);
case 'array': {
const itemPath = [...pathSegments, '<items>'];
const items = isSettingDefinition(source)
? source.items
? buildCollectionSchema(source.items, itemPath, defs)
: {}
: source.properties
? buildInlineObjectSchema(source.properties, itemPath, defs)
: {};
return { type: 'array', items };
}
case 'object':
return isSettingDefinition(source)
? buildObjectDefinitionSchema(source, pathSegments, defs)
: buildObjectCollectionSchema(source, pathSegments, defs);
default:
return {};
}
}
function buildEnumSchema(
options:
| SettingDefinition['options']
| SettingCollectionDefinition['options'],
): JsonSchema {
const values = options?.map((option) => option.value) ?? [];
const inferred = inferTypeFromValues(values);
return {
type: inferred ?? undefined,
enum: values,
};
}
function buildObjectDefinitionSchema(
definition: SettingDefinition,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
const properties = definition.properties
? buildObjectProperties(definition.properties, pathSegments, defs)
: undefined;
const schema: JsonSchema = {
type: 'object',
};
if (properties && Object.keys(properties).length > 0) {
schema.properties = properties;
}
if (definition.additionalProperties) {
schema.additionalProperties = buildCollectionSchema(
definition.additionalProperties,
[...pathSegments, '<additionalProperties>'],
defs,
);
} else if (!definition.properties) {
schema.additionalProperties = true;
} else {
schema.additionalProperties = false;
}
return schema;
}
function buildObjectCollectionSchema(
collection: SettingCollectionDefinition,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
if (collection.properties) {
return buildInlineObjectSchema(collection.properties, pathSegments, defs);
}
return { type: 'object', additionalProperties: true };
}
function buildObjectProperties(
properties: SettingsSchema,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): Record<string, JsonSchema> {
const result: Record<string, JsonSchema> = {};
for (const [childKey, childDefinition] of Object.entries(properties)) {
result[childKey] = buildSettingSchema(
childDefinition,
[...pathSegments, childKey],
defs,
);
}
return result;
}
function buildInlineObjectSchema(
properties: SettingsSchema,
pathSegments: string[],
defs: Map<string, JsonSchema>,
): JsonSchema {
const childSchemas = buildObjectProperties(properties, pathSegments, defs);
return {
type: 'object',
properties: childSchemas,
additionalProperties: false,
};
}
function buildRefSchema(
ref: string,
defs: Map<string, JsonSchema>,
): JsonSchema {
ensureDefinition(ref, defs);
return { $ref: `#/$defs/${ref}` };
}
function isSettingDefinition(
source: SettingDefinition | SettingCollectionDefinition,
): source is SettingDefinition {
return 'label' in source;
}
function buildMarkdownDescription(definition: SettingDefinition): string {
const lines: string[] = [];
if (definition.description?.trim()) {
lines.push(definition.description.trim());
} else {
lines.push('Description not provided.');
}
lines.push('');
lines.push(`- Category: \`${definition.category}\``);
lines.push(
`- Requires restart: \`${definition.requiresRestart ? 'yes' : 'no'}\``,
);
if (definition.default !== undefined) {
lines.push(`- Default: \`${formatDefaultValue(definition.default)}\``);
}
return lines.join('\n');
}
function inferTypeFromValues(
values: Array<string | number>,
): string | undefined {
if (values.length === 0) {
return undefined;
}
if (values.every((value) => typeof value === 'string')) {
return 'string';
}
if (values.every((value) => typeof value === 'number')) {
return 'number';
}
return undefined;
}
function ensureDefinition(ref: string, defs: Map<string, JsonSchema>): void {
if (defs.has(ref)) {
return;
}
const predefined = SETTINGS_SCHEMA_DEFINITIONS[ref] as
| SettingsJsonSchemaDefinition
| undefined;
if (predefined) {
defs.set(ref, predefined as JsonSchema);
} else {
defs.set(ref, { description: `Definition for ${ref}` });
}
}
if (process.argv[1]) {
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
if (entryUrl === import.meta.url) {
await main();
}
}
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import semver from 'semver';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
const TAG_LATEST = 'latest';
const TAG_NIGHTLY = 'nightly';
const TAG_PREVIEW = 'preview';
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
function getArgs() {
return yargs(hideBin(process.argv))
.option('type', {
description: 'The type of release to generate a version for.',
choices: [TAG_NIGHTLY, 'promote-nightly', 'stable', TAG_PREVIEW, 'patch'],
default: TAG_NIGHTLY,
})
.option('patch-from', {
description: 'When type is "patch", specifies the source branch.',
choices: ['stable', TAG_PREVIEW],
string: true,
})
.option('stable_version_override', {
description: 'Override the calculated stable version.',
string: true,
})
.option('cli-package-name', {
description:
'fully qualified package name with scope (e.g @google/gemini-cli)',
string: true,
default: '@google/gemini-cli',
})
.option('preview_version_override', {
description: 'Override the calculated preview version.',
string: true,
})
.option('stable-base-version', {
description: 'Base version to use for calculating next preview/nightly.',
string: true,
})
.help(false)
.version(false)
.parse();
}
function getLatestTag(pattern) {
const command = `git tag -l '${pattern}'`;
try {
const tags = execSync(command)
.toString()
.trim()
.split('\n')
.filter(Boolean);
if (tags.length === 0) return '';
// Convert tags to versions (remove 'v' prefix) and sort by semver
const versions = tags
.map((tag) => tag.replace(/^v/, ''))
.filter((version) => semver.valid(version))
.sort((a, b) => semver.rcompare(a, b)); // rcompare for descending order
if (versions.length === 0) return '';
// Return the latest version with 'v' prefix restored
return `v${versions[0]}`;
} catch (error) {
console.error(
`Failed to get latest git tag for pattern "${pattern}": ${error.message}`,
);
return '';
}
}
function getVersionFromNPM({ args, npmDistTag } = {}) {
const command = `npm view ${args['cli-package-name']} version --tag=${npmDistTag}`;
try {
return execSync(command).toString().trim();
} catch (error) {
console.error(
`Failed to get NPM version for dist-tag "${npmDistTag}": ${error.message}`,
);
return '';
}
}
function getAllVersionsFromNPM({ args } = {}) {
const command = `npm view ${args['cli-package-name']} versions --json`;
try {
const versionsJson = execSync(command).toString().trim();
return JSON.parse(versionsJson);
} catch (error) {
console.error(`Failed to get all NPM versions: ${error.message}`);
return [];
}
}
function isVersionDeprecated({ args, version } = {}) {
const command = `npm view ${args['cli-package-name']}@${version} deprecated`;
try {
const output = execSync(command).toString().trim();
return output.length > 0;
} catch (error) {
// This command shouldn't fail for existing versions, but as a safeguard:
console.error(
`Failed to check deprecation status for ${version}: ${error.message}`,
);
return false; // Assume not deprecated on error to avoid breaking the release.
}
}
function detectRollbackAndGetBaseline({ args, npmDistTag } = {}) {
// Get the current dist-tag version
const distTagVersion = getVersionFromNPM({ args, npmDistTag });
if (!distTagVersion) return { baseline: '', isRollback: false };
// Get all published versions
const allVersions = getAllVersionsFromNPM({ args });
if (allVersions.length === 0)
return { baseline: distTagVersion, isRollback: false };
// Filter versions by type to match the dist-tag
let matchingVersions;
if (npmDistTag === TAG_LATEST) {
// Stable versions: no prerelease identifiers
matchingVersions = allVersions.filter(
(v) => semver.valid(v) && !semver.prerelease(v),
);
} else if (npmDistTag === TAG_PREVIEW) {
// Preview versions: contain -preview
matchingVersions = allVersions.filter(
(v) => semver.valid(v) && v.includes('-preview'),
);
} else if (npmDistTag === TAG_NIGHTLY) {
// Nightly versions: contain -nightly
matchingVersions = allVersions.filter(
(v) => semver.valid(v) && v.includes('-nightly'),
);
} else {
// For other dist-tags, just use the dist-tag version
return { baseline: distTagVersion, isRollback: false };
}
if (matchingVersions.length === 0)
return { baseline: distTagVersion, isRollback: false };
// Sort by semver to get a list from highest to lowest
matchingVersions.sort((a, b) => semver.rcompare(a, b));
// Find the highest non-deprecated version with a git tag
let highestExistingVersion = '';
for (const version of matchingVersions) {
if (!isVersionDeprecated({ version, args })) {
try {
// Only consider versions that have a corresponding git tag.
// This prevents picking up versions that were published to NPM but failed before the github release/tag.
let tagExists = false;
try {
execSync(`git rev-parse v${version}^{commit} 2>/dev/null`);
tagExists = true;
} catch {
const remoteTag = execSync(
`git ls-remote --tags origin refs/tags/v${version} 2>/dev/null`,
)
.toString()
.trim();
if (remoteTag) {
tagExists = true;
}
}
if (!tagExists) {
throw new Error(`Tag v${version} not found`);
}
highestExistingVersion = version;
break; // Found the one we want
} catch {
console.error(
`Ignoring version ${version} because it lacks a git tag (likely a failed release).`,
);
}
} else {
console.error(`Ignoring deprecated version: ${version}`);
}
}
// If all matching versions were deprecated, fall back to the dist-tag version
if (!highestExistingVersion) {
highestExistingVersion = distTagVersion;
}
// Check if we're in a rollback scenario
const isRollback = semver.gt(highestExistingVersion, distTagVersion);
return {
baseline: isRollback ? highestExistingVersion : distTagVersion,
isRollback,
distTagVersion,
highestExistingVersion,
};
}
function doesVersionExist({ args, version } = {}) {
// Check NPM
try {
const command = `npm view ${args['cli-package-name']}@${version} version 2>/dev/null`;
const output = execSync(command).toString().trim();
if (output === version) {
console.error(`Version ${version} already exists on NPM.`);
return true;
}
} catch {
// This is expected if the version doesn't exist.
}
// Check Git tags
try {
const command = `git tag -l 'v${version}'`;
const tagOutput = execSync(command).toString().trim();
if (tagOutput === `v${version}`) {
console.error(`Git tag v${version} already exists.`);
return true;
}
} catch (error) {
console.error(`Failed to check git tags for conflicts: ${error.message}`);
}
// Check GitHub releases
try {
const command = `gh release view "v${version}" --json tagName --jq .tagName 2>/dev/null`;
const output = execSync(command).toString().trim();
if (output === `v${version}`) {
console.error(`GitHub release v${version} already exists.`);
return true;
}
} catch (error) {
const isExpectedNotFound =
error.message.includes('release not found') ||
error.message.includes('Not Found') ||
error.message.includes('not found') ||
error.status === 1;
if (!isExpectedNotFound) {
console.error(
`Failed to check GitHub releases for conflicts: ${error.message}`,
);
}
}
return false;
}
function getAndVerifyTags({ npmDistTag, args } = {}) {
// Detect rollback scenarios and get the correct baseline
const rollbackInfo = detectRollbackAndGetBaseline({ args, npmDistTag });
const baselineVersion = rollbackInfo.baseline;
if (!baselineVersion) {
throw new Error(`Unable to determine baseline version for ${npmDistTag}`);
}
if (rollbackInfo.isRollback) {
// Rollback scenario: warn about the rollback but don't fail
console.error(
`Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation (highest existing version).`,
);
}
// Not verifying against git tags or GitHub releases as per user request.
return {
latestVersion: baselineVersion,
latestTag: `v${baselineVersion}`,
};
}
function getStableBaseVersion(args) {
let latestStableVersion = args['stable-base-version'];
if (!latestStableVersion) {
const { latestVersion } = getAndVerifyTags({
npmDistTag: TAG_LATEST,
args,
});
latestStableVersion = latestVersion;
}
return latestStableVersion;
}
function promoteNightlyVersion({ args } = {}) {
const latestStableVersion = getStableBaseVersion(args);
const { latestTag: previousNightlyTag } = getAndVerifyTags({
npmDistTag: TAG_NIGHTLY,
args,
});
const major = semver.major(latestStableVersion);
const minor = semver.minor(latestStableVersion);
const nextMinor = minor + 2;
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
return {
releaseVersion: `${major}.${nextMinor}.0-nightly.${date}.g${gitShortHash}`,
npmTag: TAG_NIGHTLY,
previousReleaseTag: previousNightlyTag,
};
}
function getNightlyVersion() {
const packageJson = readJson('package.json');
const baseVersion = packageJson.version.split('-')[0];
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
const releaseVersion = `${baseVersion}-nightly.${date}.g${gitShortHash}`;
const previousReleaseTag = getLatestTag('v*-nightly*');
return {
releaseVersion,
npmTag: TAG_NIGHTLY,
previousReleaseTag,
};
}
function validateVersion(version, format, name) {
const versionRegex = {
'X.Y.Z': /^\d+\.\d+\.\d+$/,
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
};
if (!versionRegex[format] || !versionRegex[format].test(version)) {
throw new Error(
`Invalid ${name}: ${version}. Must be in ${format} format.`,
);
}
}
function getStableVersion(args) {
const { latestVersion: latestPreviewVersion } = getAndVerifyTags({
npmDistTag: TAG_PREVIEW,
args,
});
let releaseVersion;
if (args['stable_version_override']) {
const overrideVersion = args['stable_version_override'].replace(/^v/, '');
validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override');
releaseVersion = overrideVersion;
} else {
releaseVersion = latestPreviewVersion.replace(/-preview.*/, '');
}
const { latestTag: previousStableTag } = getAndVerifyTags({
npmDistTag: TAG_LATEST,
args,
});
return {
releaseVersion,
npmTag: TAG_LATEST,
previousReleaseTag: previousStableTag,
};
}
function getPreviewVersion(args) {
const latestStableVersion = getStableBaseVersion(args);
let releaseVersion;
if (args['preview_version_override']) {
const overrideVersion = args['preview_version_override'].replace(/^v/, '');
validateVersion(
overrideVersion,
'X.Y.Z-preview.N',
'preview_version_override',
);
releaseVersion = overrideVersion;
} else {
const major = semver.major(latestStableVersion);
const minor = semver.minor(latestStableVersion);
const nextMinor = minor + 1;
releaseVersion = `${major}.${nextMinor}.0-preview.0`;
}
const { latestTag: previousPreviewTag } = getAndVerifyTags({
npmDistTag: TAG_PREVIEW,
args,
});
return {
releaseVersion,
npmTag: TAG_PREVIEW,
previousReleaseTag: previousPreviewTag,
};
}
function getPatchVersion(args) {
const patchFrom = args['patch-from'];
if (!patchFrom || (patchFrom !== 'stable' && patchFrom !== TAG_PREVIEW)) {
throw new Error(
'Patch type must be specified with --patch-from=stable or --patch-from=preview',
);
}
const distTag = patchFrom === 'stable' ? TAG_LATEST : TAG_PREVIEW;
const { latestVersion, latestTag } = getAndVerifyTags({
npmDistTag: distTag,
args,
});
if (patchFrom === 'stable') {
// For stable versions, increment the patch number: 0.5.4 -> 0.5.5
const versionParts = latestVersion.split('.');
const major = versionParts[0];
const minor = versionParts[1];
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0;
const releaseVersion = `${major}.${minor}.${patch + 1}`;
return {
releaseVersion,
npmTag: distTag,
previousReleaseTag: latestTag,
};
} else {
// For preview versions, increment the preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
const [version, prereleasePart] = latestVersion.split('-');
if (!prereleasePart || !prereleasePart.startsWith('preview.')) {
throw new Error(
`Invalid preview version format: ${latestVersion}. Expected format like "0.6.0-preview.2"`,
);
}
const previewNumber = parseInt(prereleasePart.split('.')[1]);
if (isNaN(previewNumber)) {
throw new Error(`Could not parse preview number from: ${prereleasePart}`);
}
const releaseVersion = `${version}-preview.${previewNumber + 1}`;
return {
releaseVersion,
npmTag: distTag,
previousReleaseTag: latestTag,
};
}
}
export function getVersion(options = {}) {
const args = { ...getArgs(), ...options };
const type = args['type'] || TAG_NIGHTLY; // Nightly is the default.
let versionData;
switch (type) {
case TAG_NIGHTLY:
versionData = getNightlyVersion();
// Nightly versions include a git hash, so conflicts are highly unlikely
// and indicate a problem. We'll still validate but not auto-increment.
if (doesVersionExist({ args, version: versionData.releaseVersion })) {
throw new Error(
`Version conflict! Nightly version ${versionData.releaseVersion} already exists.`,
);
}
break;
case 'promote-nightly':
versionData = promoteNightlyVersion({ args });
// A promoted nightly version is still a nightly, so we should check for conflicts.
if (doesVersionExist({ args, version: versionData.releaseVersion })) {
throw new Error(
`Version conflict! Promoted nightly version ${versionData.releaseVersion} already exists.`,
);
}
break;
case 'stable':
versionData = getStableVersion(args);
break;
case TAG_PREVIEW:
versionData = getPreviewVersion(args);
break;
case 'patch':
versionData = getPatchVersion(args);
break;
default:
throw new Error(`Unknown release type: ${type}`);
}
// For patchable versions, check for existence and increment if needed.
if (type === 'stable' || type === TAG_PREVIEW || type === 'patch') {
let releaseVersion = versionData.releaseVersion;
while (doesVersionExist({ args, version: releaseVersion })) {
console.error(`Version ${releaseVersion} exists, incrementing.`);
if (releaseVersion.includes('-preview.')) {
// Increment preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
const [version, prereleasePart] = releaseVersion.split('-');
const previewNumber = parseInt(prereleasePart.split('.')[1]);
releaseVersion = `${version}-preview.${previewNumber + 1}`;
} else {
// Increment patch number: 0.5.4 -> 0.5.5
const versionParts = releaseVersion.split('.');
const major = versionParts[0];
const minor = versionParts[1];
const patch = parseInt(versionParts[2]);
releaseVersion = `${major}.${minor}.${patch + 1}`;
}
}
versionData.releaseVersion = releaseVersion;
}
// All checks are done, construct the final result.
const result = {
releaseTag: `v${versionData.releaseVersion}`,
...versionData,
};
return result;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(getVersion(getArgs()), null, 2));
}
+125
View File
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Identifies "Trustworthy" behavioral evaluations from nightly history.
*
* This script analyzes the last 6 days of nightly runs to find tests that meet
* strict stability criteria (80% aggregate pass rate and 60% daily floor).
* It outputs a list of files and a Vitest pattern used by the PR regression check
* to ensure high-signal validation and minimize noise.
*/
import { fetchNightlyHistory, escapeRegex } from './eval_utils.js';
const LOOKBACK_COUNT = 6;
const MIN_VALID_RUNS = 5; // At least 5 out of 6 must be available
const PASS_RATE_THRESHOLD = 0.6; // Daily floor (e.g., 2/3)
const AGGREGATE_PASS_RATE_THRESHOLD = 0.8; // Weekly signal (e.g., 15/18)
/**
* Main execution logic.
*/
function main() {
const targetModel = process.argv[2];
if (!targetModel) {
console.error('❌ Error: No target model specified.');
process.exit(1);
}
console.error(`🔍 Identifying trustworthy evals for model: ${targetModel}`);
const history = fetchNightlyHistory(LOOKBACK_COUNT);
if (history.length === 0) {
console.error('❌ No historical data found.');
process.exit(1);
}
// Aggregate results for the target model across all history
const testHistories = {}; // { [testName]: { totalPassed: 0, totalRuns: 0, dailyRates: [], file: string } }
for (const item of history) {
const modelStats = item.stats[targetModel];
if (!modelStats) continue;
for (const [testName, stat] of Object.entries(modelStats)) {
if (!testHistories[testName]) {
testHistories[testName] = {
totalPassed: 0,
totalRuns: 0,
dailyRates: [],
file: stat.file,
};
}
testHistories[testName].totalPassed += stat.passed;
testHistories[testName].totalRuns += stat.total;
testHistories[testName].dailyRates.push(stat.passed / stat.total);
}
}
const trustworthyTests = [];
const trustworthyFiles = new Set();
const volatileTests = [];
const newTests = [];
for (const [testName, info] of Object.entries(testHistories)) {
const dailyRates = info.dailyRates;
const aggregateRate = info.totalPassed / info.totalRuns;
// 1. Minimum data points required
if (dailyRates.length < MIN_VALID_RUNS) {
newTests.push(testName);
continue;
}
// 2. Trustworthy Criterion:
// - Every single day must be above the floor (e.g. > 60%)
// - The overall aggregate must be high-signal (e.g. > 80%)
const isDailyStable = dailyRates.every(
(rate) => rate > PASS_RATE_THRESHOLD,
);
const isAggregateHighSignal = aggregateRate > AGGREGATE_PASS_RATE_THRESHOLD;
if (isDailyStable && isAggregateHighSignal) {
trustworthyTests.push(testName);
if (info.file) {
const match = info.file.match(/evals\/.*\.eval\.ts/);
if (match) {
trustworthyFiles.add(match[0]);
}
}
} else {
volatileTests.push(testName);
}
}
console.error(
`✅ Found ${trustworthyTests.length} trustworthy tests across ${trustworthyFiles.size} files:`,
);
trustworthyTests.sort().forEach((name) => console.error(` - ${name}`));
console.error(`\n⚪ Ignored ${volatileTests.length} volatile tests.`);
console.error(
`🆕 Ignored ${newTests.length} tests with insufficient history.`,
);
// Output the list of names as a regex-friendly pattern for vitest -t
const pattern = trustworthyTests.map((name) => escapeRegex(name)).join('|');
// Also output unique file paths as a space-separated string
const files = Array.from(trustworthyFiles).join(' ');
// Print the combined output to stdout for use in shell scripts (only if piped/CI)
if (!process.stdout.isTTY) {
// Format: FILE_LIST --test-pattern TEST_PATTERN
// This allows the workflow to easily use it
process.stdout.write(`${files} --test-pattern ${pattern || ''}\n`);
} else {
console.error(
'\n💡 Note: Raw regex pattern and file list are hidden in interactive terminal. It will be printed when piped or in CI.',
);
}
}
main();
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# Gemini API Reliability Harvester
# -------------------------------
# This script gathers data about 500 API errors encountered during evaluation runs
# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused
# by transient API failures.
#
# Usage:
# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH]
#
# Examples:
# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches
# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500
# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch
#
# Prerequisites:
# - GitHub CLI (gh) installed and authenticated (`gh auth login`)
# - jq installed
# Arguments & Defaults
if [[ -n "${1}" && "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="${1}"
elif [[ -n "${1}" && "${1}" =~ ^([0-9]+)d$ ]]; then
DAYS="${BASH_REMATCH[1]}"
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
else
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
fi
else
# Default to 7 days ago in YYYY-MM-DD format (UTC)
os_type="$(uname || true)"
if [[ "${os_type}" == "darwin"* ]]; then
SINCE=$(date -u -v-7d +%Y-%m-%d)
else
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
fi
fi
LIMIT=${2:-300}
BRANCH=${3:-""}
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
DEST_DIR="$(mktemp -d -t gemini-reliability-XXXXXX)"
MERGED_FILE="api-reliability-summary.jsonl"
# Ensure cleanup on exit
trap 'rm -rf "${DEST_DIR}"' EXIT
if ! command -v gh &> /dev/null; then
echo "❌ Error: GitHub CLI (gh) is not installed."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ Error: jq is not installed."
exit 1
fi
# Clean start
rm -f "${MERGED_FILE}"
# gh run list --created expects a date (YYYY-MM-DD) or a range
CREATED_QUERY=">=${SINCE}"
for WORKFLOW in "${WORKFLOWS[@]}"; do
echo "🔍 Fetching runs for '${WORKFLOW}' created since ${SINCE} (max ${LIMIT} runs, branch: ${BRANCH:-all})..."
# Construct arguments for gh run list
GH_ARGS=("--workflow" "${WORKFLOW}" "--created" "${CREATED_QUERY}" "--limit" "${LIMIT}" "--json" "databaseId" "--jq" ".[].databaseId")
if [[ -n "${BRANCH}" ]]; then
GH_ARGS+=("--branch" "${BRANCH}")
fi
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
exit_code=$?
if [[ "${exit_code}" -ne 0 ]]; then
echo "❌ Failed to fetch runs for '${WORKFLOW}' (exit code: ${exit_code}). Please check 'gh auth status' and permissions." >&2
continue
fi
if [[ -z "${RUN_IDS}" ]]; then
echo "📭 No runs found for workflow '${WORKFLOW}' since ${SINCE}."
continue
fi
for ID in ${RUN_IDS}; do
# Download artifacts named 'eval-logs-*'
# Silencing output because many older runs won't have artifacts
gh run download "${ID}" -p "eval-logs-*" -D "${DEST_DIR}/${ID}" &>/dev/null || continue
# Append to master log
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
find "${DEST_DIR}/${ID}" -type f -name "api-reliability.jsonl" -exec cat {} + >> "${MERGED_FILE}" 2>/dev/null
done
done
if [[ ! -f "${MERGED_FILE}" ]]; then
echo "📭 No reliability data found in the retrieved logs."
exit 0
fi
echo -e "\n✅ Harvest Complete! Data merged into: ${MERGED_FILE}"
echo "------------------------------------------------"
echo "📊 Gemini API Reliability Summary (Since ${SINCE})"
echo "------------------------------------------------"
# shellcheck disable=SC2312
cat "${MERGED_FILE}" | jq -s '
group_by(.model) | map({
model: .[0].model,
"500s": (map(select(.errorCode == "500")) | length),
"503s": (map(select(.errorCode == "503")) | length),
retries: (map(select(.status == "RETRY")) | length),
skips: (map(select(.status == "SKIP")) | length)
})'
# shellcheck disable=SC2312
echo -e "\n💡 Total events captured: $(wc -l < "${MERGED_FILE}")"
+518
View File
@@ -0,0 +1,518 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import {
mkdirSync,
rmSync,
readFileSync,
existsSync,
lstatSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const ACTIONLINT_VERSION = '1.7.7';
const SHELLCHECK_VERSION = '0.11.0';
const YAMLLINT_VERSION = '1.35.1';
const TEMP_DIR =
process.env.GEMINI_LINT_TEMP_DIR || join(tmpdir(), 'gemini-cli-linters');
function getPlatformArch() {
const platform = process.platform;
const arch = process.arch;
if (platform === 'linux' && arch === 'x64') {
return {
actionlint: 'linux_amd64',
shellcheck: 'linux.x86_64',
};
}
if (platform === 'darwin' && arch === 'x64') {
return {
actionlint: 'darwin_amd64',
shellcheck: 'darwin.x86_64',
};
}
if (platform === 'darwin' && arch === 'arm64') {
return {
actionlint: 'darwin_arm64',
shellcheck: 'darwin.aarch64',
};
}
if (platform === 'win32' && arch === 'x64') {
return {
actionlint: 'windows_amd64',
// shellcheck is not used for Windows since it uses the .zip release
// which has a consistent name across architectures
};
}
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
}
const platformArch = getPlatformArch();
const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv');
const pythonVenvPythonPath = join(
PYTHON_VENV_PATH,
process.platform === 'win32' ? 'Scripts' : 'bin',
process.platform === 'win32' ? 'python.exe' : 'python',
);
const isWindows = process.platform === 'win32';
const actionlintCheck = isWindows
? `where actionlint 2>nul`
: 'command -v actionlint';
const actionlintInstaller = isWindows
? `powershell -Command "` +
`New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
`Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
: `
mkdir -p "${TEMP_DIR}/actionlint"
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
`;
const shellcheckCheck = isWindows
? `where shellcheck 2>nul`
: 'command -v shellcheck';
const shellcheckInstaller = isWindows
? `powershell -Command "` +
`Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
: `
mkdir -p "${TEMP_DIR}/shellcheck"
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
`;
const yamllintCheck = isWindows
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
const yamllintInstaller = isWindows
? `python -m venv "${PYTHON_VENV_PATH}" && ` +
`"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
`"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
: `
python3 -m venv "${PYTHON_VENV_PATH}" && \
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
`;
/**
* @typedef {{
* check: string;
* installer: string;
* run: string;
* }}
*/
/**
* @type {{[linterName: string]: Linter}}
*/
const LINTERS = {
actionlint: {
check: actionlintCheck,
installer: actionlintInstaller,
run: `
actionlint \
-color \
-ignore 'SC2002:' \
-ignore 'SC2016:' \
-ignore 'SC2129:' \
-ignore 'label ".+" is unknown'
`,
},
shellcheck: {
check: shellcheckCheck,
installer: shellcheckInstaller,
run: `
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
| xargs shellcheck \
--check-sourced \
--enable=all \
--exclude=SC2002,SC2129,SC2310 \
--severity=style \
--format=gcc \
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
`,
},
yamllint: {
check: yamllintCheck,
installer: yamllintInstaller,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
},
};
function runCommand(command, stdio = 'inherit') {
try {
const env = { ...process.env };
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
const sep = isWindows ? ';' : ':';
const pythonBin = isWindows
? join(PYTHON_VENV_PATH, 'Scripts')
: join(PYTHON_VENV_PATH, 'bin');
// Windows sometimes uses 'Path' instead of 'PATH'
const pathKey = 'Path' in env ? 'Path' : 'PATH';
env[pathKey] = [
nodeBin,
join(TEMP_DIR, 'actionlint'),
join(TEMP_DIR, 'shellcheck'),
pythonBin,
env[pathKey],
].join(sep);
execSync(command, { stdio, env, shell: true });
return true;
} catch {
return false;
}
}
export function setupLinters() {
console.log('Setting up linters...');
if (!process.env.GEMINI_LINT_TEMP_DIR) {
rmSync(TEMP_DIR, { recursive: true, force: true });
}
mkdirSync(TEMP_DIR, { recursive: true });
for (const linter in LINTERS) {
const { check, installer } = LINTERS[linter];
if (!runCommand(check, 'ignore')) {
console.log(`Installing ${linter}...`);
if (!runCommand(installer)) {
console.error(
`Failed to install ${linter}. Please install it manually.`,
);
process.exit(1);
}
}
}
console.log('All required linters are available.');
}
export function runESLint() {
console.log('\nRunning ESLint...');
if (!runCommand('npm run lint')) {
process.exit(1);
}
}
export function runActionlint() {
console.log('\nRunning actionlint...');
if (!runCommand(LINTERS.actionlint.run)) {
process.exit(1);
}
}
export function runShellcheck() {
console.log('\nRunning shellcheck...');
if (!runCommand(LINTERS.shellcheck.run)) {
process.exit(1);
}
}
export function runYamllint() {
console.log('\nRunning yamllint...');
if (!runCommand(LINTERS.yamllint.run)) {
process.exit(1);
}
}
export function runPrettier() {
console.log('\nRunning Prettier...');
if (!runCommand('prettier --check .')) {
console.log(
'Prettier check failed. Please run "npm run format" to fix formatting issues.',
);
process.exit(1);
}
}
export function runSensitiveKeywordLinter() {
console.log('\nRunning sensitive keyword linter...');
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
const ALLOWED_KEYWORDS = new Set([
'gemini-3.1',
'gemini-3',
'gemini-3.0',
'gemini-2.5',
'gemini-2.0',
'gemini-1.5',
'gemini-1.0',
]);
function getChangedFiles() {
const baseRef = process.env.GITHUB_BASE_REF || 'main';
try {
execSync(`git fetch origin ${baseRef}`);
const mergeBase = execSync(`git merge-base HEAD origin/${baseRef}`)
.toString()
.trim();
return execSync(`git diff --name-only ${mergeBase}..HEAD`)
.toString()
.trim()
.split('\n')
.filter(Boolean);
} catch {
console.error(`Could not get changed files against origin/${baseRef}.`);
try {
console.log('Falling back to diff against HEAD~1');
return execSync(`git diff --name-only HEAD~1..HEAD`)
.toString()
.trim()
.split('\n')
.filter(Boolean);
} catch {
console.error('Could not get changed files against HEAD~1 either.');
process.exit(1);
}
}
}
const changedFiles = getChangedFiles();
let violationsFound = false;
for (const file of changedFiles) {
if (!existsSync(file) || lstatSync(file).isDirectory()) {
continue;
}
const content = readFileSync(file, 'utf-8');
const lines = content.split('\n');
let match;
while ((match = SENSITIVE_PATTERN.exec(content)) !== null) {
const keyword = match[0];
if (!ALLOWED_KEYWORDS.has(keyword)) {
violationsFound = true;
const matchIndex = match.index;
let lineNum = 0;
let charCount = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (charCount + line.length + 1 > matchIndex) {
lineNum = i + 1;
const colNum = matchIndex - charCount + 1;
console.log(
`::warning file=${file},line=${lineNum},col=${colNum}::Found sensitive keyword "${keyword}". Please make sure this change is appropriate to submit.`,
);
break;
}
charCount += line.length + 1; // +1 for the newline
}
}
}
}
if (!violationsFound) {
console.log('No sensitive keyword violations found.');
}
}
function stripJSONComments(json) {
return json.replace(
/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
(m, g) => (g ? '' : m),
);
}
export function runTSConfigLinter() {
console.log('\nRunning tsconfig linter...');
let files = [];
try {
// Find all tsconfig.json files under packages/ using a git pathspec
files = execSync("git ls-files 'packages/**/tsconfig.json'")
.toString()
.trim()
.split('\n')
.filter(Boolean);
} catch (e) {
console.error('Error finding tsconfig.json files:', e.message);
process.exit(1);
}
let hasError = false;
for (const file of files) {
const tsconfigPath = join(process.cwd(), file);
if (!existsSync(tsconfigPath)) {
console.error(`Error: ${tsconfigPath} does not exist.`);
hasError = true;
continue;
}
try {
const content = readFileSync(tsconfigPath, 'utf-8');
const config = JSON.parse(stripJSONComments(content));
// Check if exclude exists and matches exactly
if (config.exclude) {
if (!Array.isArray(config.exclude)) {
console.error(
`Error: ${file} "exclude" must be an array. Found: ${JSON.stringify(
config.exclude,
)}`,
);
hasError = true;
} else {
const allowedExclude = new Set(['node_modules', 'dist']);
const invalidExcludes = config.exclude.filter(
(item) => !allowedExclude.has(item),
);
if (invalidExcludes.length > 0) {
console.error(
`Error: ${file} "exclude" contains invalid items: ${JSON.stringify(
invalidExcludes,
)}. Only "node_modules" and "dist" are allowed.`,
);
hasError = true;
}
}
}
} catch (error) {
console.error(`Error parsing ${tsconfigPath}: ${error.message}`);
hasError = true;
}
}
if (hasError) {
process.exit(1);
}
}
export function runGithubActionsPinningLinter() {
console.log('\nRunning GitHub Actions pinning linter...');
let files = [];
try {
files = execSync(
"git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '.github/actions/**/*.yml' '.github/actions/**/*.yaml'",
)
.toString()
.trim()
.split('\n')
.filter(Boolean);
} catch (e) {
console.error('Error finding GitHub Actions workflow files:', e.message);
process.exit(1);
}
let violationsFound = false;
// Improved regex to capture action name and ref, handling optional quotes and comments.
const USES_PATTERN = /uses:\s*['"]?([^@\s'"]+)@([^#\s'"]+)['"]?/;
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
for (const file of files) {
if (!existsSync(file) || lstatSync(file).isDirectory()) {
continue;
}
const content = readFileSync(file, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(USES_PATTERN);
if (match) {
const action = match[1];
let ref = match[2];
// Clean up any trailing quotes that might have been captured
ref = ref.replace(/['"]$/, '');
// Skip local actions (starting with ./), docker actions, and explicit exclusions
if (
action.startsWith('./') ||
action.startsWith('docker://') ||
line.includes('# github-actions-pinning:ignore')
) {
continue;
}
if (!SHA_PATTERN.test(ref)) {
violationsFound = true;
const lineNum = i + 1;
console.error(
`::error file=${file},line=${lineNum}::Action "${action}" uses "${ref}" instead of a 40-character SHA.`,
);
}
}
}
}
if (violationsFound) {
console.error(`
GitHub Actions pinning violations found. Please use exact commit hashes.
To automatically fix these, you can use the "ratchet" tool (https://github.com/sethvargo/ratchet):
- Mac/Linux (Homebrew): brew install ratchet && ratchet pin .github/workflows/*.yml .github/actions/**/*.yml
- Other platforms: Download from GitHub releases and run "ratchet pin .github/workflows/*.yml .github/actions/**/*.yml"
If you must use a tag, you can ignore this check by adding a comment (discouraged):
uses: some-action@v1 # github-actions-pinning:ignore
`);
process.exit(1);
} else {
console.log('No GitHub Actions pinning violations found.');
}
}
function main() {
const args = process.argv.slice(2);
if (args.includes('--setup')) {
setupLinters();
}
if (args.includes('--eslint')) {
runESLint();
}
if (args.includes('--actionlint')) {
runActionlint();
}
if (args.includes('--shellcheck')) {
runShellcheck();
}
if (args.includes('--yamllint')) {
runYamllint();
}
if (args.includes('--prettier')) {
runPrettier();
}
if (args.includes('--sensitive-keywords')) {
runSensitiveKeywordLinter();
}
if (args.includes('--tsconfig')) {
runTSConfigLinter();
}
if (args.includes('--check-github-actions-pinning')) {
runGithubActionsPinningLinter();
}
if (args.length === 0) {
setupLinters();
runESLint();
runActionlint();
runShellcheck();
runYamllint();
runPrettier();
runSensitiveKeywordLinter();
runTSConfigLinter();
runGithubActionsPinningLinter();
console.log('\nAll linting checks passed!');
}
}
main();
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
BIN_DIR,
OTEL_DIR,
ensureBinary,
fileExists,
manageTelemetrySettings,
registerCleanup,
waitForPort,
} from './telemetry_utils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-local.yaml');
const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector.log');
const JAEGER_LOG_FILE = path.join(OTEL_DIR, 'jaeger.log');
const JAEGER_PORT = 16686;
// This configuration is for the primary otelcol-contrib instance.
// It receives from the CLI on 4317, exports traces to Jaeger on 14317,
// and sends metrics/logs to the debug log.
const OTEL_CONFIG_CONTENT = `
receivers:
otlp:
protocols:
grpc:
endpoint: "localhost:4317"
processors:
batch:
timeout: 1s
exporters:
otlp:
endpoint: "localhost:14317"
tls:
insecure: true
debug:
verbosity: detailed
service:
telemetry:
logs:
level: "debug"
metrics:
level: "none"
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug]
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug]
`;
async function main() {
// 1. Ensure binaries are available, downloading if necessary.
// Binaries are stored in the project's .gemini/otel/bin directory
// to avoid modifying the user's system.
if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
const otelcolPath = await ensureBinary(
'otelcol-contrib',
'open-telemetry/opentelemetry-collector-releases',
(version, platform, arch, ext) =>
`otelcol-contrib_${version}_${platform}_${arch}.${ext}`,
'otelcol-contrib',
false, // isJaeger = false
).catch((e) => {
console.error(`🛑 Error getting otelcol-contrib: ${e.message}`);
return null;
});
if (!otelcolPath) process.exit(1);
const jaegerPath = await ensureBinary(
'jaeger',
'jaegertracing/jaeger',
(version, platform, arch, ext) =>
`jaeger-${version}-${platform}-${arch}.${ext}`,
'jaeger',
true, // isJaeger = true
).catch((e) => {
console.error(`🛑 Error getting jaeger: ${e.message}`);
return null;
});
if (!jaegerPath) process.exit(1);
// 2. Kill any existing processes to ensure a clean start.
console.log('🧹 Cleaning up old processes and logs...');
try {
execSync('pkill -f "otelcol-contrib"');
console.log('✅ Stopped existing otelcol-contrib process.');
} catch {} // eslint-disable-line no-empty
try {
execSync('pkill -f "jaeger"');
console.log('✅ Stopped existing jaeger process.');
} catch {} // eslint-disable-line no-empty
try {
if (fileExists(OTEL_LOG_FILE)) fs.unlinkSync(OTEL_LOG_FILE);
console.log('✅ Deleted old collector log.');
} catch (e) {
if (e.code !== 'ENOENT') console.error(e);
}
try {
if (fileExists(JAEGER_LOG_FILE)) fs.unlinkSync(JAEGER_LOG_FILE);
console.log('✅ Deleted old jaeger log.');
} catch (e) {
if (e.code !== 'ENOENT') console.error(e);
}
let jaegerProcess, collectorProcess;
let jaegerLogFd, collectorLogFd;
const originalSandboxSetting = manageTelemetrySettings(
true,
'http://localhost:4317',
'local',
);
registerCleanup(
() => [jaegerProcess, collectorProcess],
() => [jaegerLogFd, collectorLogFd],
originalSandboxSetting,
);
if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true });
fs.writeFileSync(OTEL_CONFIG_FILE, OTEL_CONFIG_CONTENT);
console.log('📄 Wrote OTEL collector config.');
// Start Jaeger
console.log(`🚀 Starting Jaeger service... Logs: ${JAEGER_LOG_FILE}`);
jaegerLogFd = fs.openSync(JAEGER_LOG_FILE, 'a');
jaegerProcess = spawn(
jaegerPath,
['--set=receivers.otlp.protocols.grpc.endpoint=localhost:14317'],
{ stdio: ['ignore', jaegerLogFd, jaegerLogFd] },
);
console.log(`⏳ Waiting for Jaeger to start (PID: ${jaegerProcess.pid})...`);
try {
await waitForPort(JAEGER_PORT);
console.log(`✅ Jaeger started successfully.`);
} catch {
console.error(`🛑 Error: Jaeger failed to start on port ${JAEGER_PORT}.`);
if (jaegerProcess && jaegerProcess.pid) {
process.kill(jaegerProcess.pid, 'SIGKILL');
}
if (fileExists(JAEGER_LOG_FILE)) {
console.error('📄 Jaeger Log Output:');
console.error(fs.readFileSync(JAEGER_LOG_FILE, 'utf-8'));
}
process.exit(1);
}
// Start the primary OTEL collector
console.log(`🚀 Starting OTEL collector... Logs: ${OTEL_LOG_FILE}`);
collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a');
collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], {
stdio: ['ignore', collectorLogFd, collectorLogFd],
});
console.log(
`⏳ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`,
);
try {
await waitForPort(4317);
console.log(`✅ OTEL collector started successfully.`);
} catch {
console.error(`🛑 Error: OTEL collector failed to start on port 4317.`);
if (collectorProcess && collectorProcess.pid) {
process.kill(collectorProcess.pid, 'SIGKILL');
}
if (fileExists(OTEL_LOG_FILE)) {
console.error('📄 OTEL Collector Log Output:');
console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8'));
}
process.exit(1);
}
[jaegerProcess, collectorProcess].forEach((proc) => {
if (proc) {
proc.on('error', (err) => {
console.error(`${proc.spawnargs[0]} process error:`, err);
process.exit(1);
});
}
});
console.log(`
✨ Local telemetry environment is running.`);
console.log(
`
🔎 View traces in the Jaeger UI: http://localhost:${JAEGER_PORT}`,
);
console.log(`📊 View metrics in the logs and metrics: ${OTEL_LOG_FILE}`);
console.log(
`
📄 Tail logs and metrics in another terminal: tail -f ${OTEL_LOG_FILE}`,
);
console.log(`
Press Ctrl+C to exit.`);
}
main();
+22
View File
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import lintStaged from 'lint-staged';
try {
// Get repository root
const root = execSync('git rev-parse --show-toplevel').toString().trim();
// Run lint-staged with API directly
const passed = await lintStaged({ cwd: root });
// Exit with appropriate code
process.exit(passed ? 0 : 1);
} catch {
// Exit with error code
process.exit(1);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
const rootDir = process.cwd();
function updatePackageJson(packagePath, updateFn) {
const packageJsonPath = path.resolve(rootDir, packagePath);
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
updateFn(packageJson);
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
}
// Copy bundle directory into packages/cli
const sourceBundleDir = path.resolve(rootDir, 'bundle');
const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle');
if (fs.existsSync(sourceBundleDir)) {
fs.rmSync(destBundleDir, { recursive: true, force: true });
fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true });
console.log('Copied bundle/ directory to packages/cli/');
} else {
console.error(
'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.',
);
process.exit(1);
}
// Overwrite the .npmrc in the core package to point to the GitHub registry.
const coreNpmrcPath = path.resolve(rootDir, 'packages/core/.npmrc');
fs.writeFileSync(
coreNpmrcPath,
'@google-gemini:registry=https://npm.pkg.github.com/',
);
console.log('Wrote .npmrc for @google-gemini scope to packages/core/');
// Update @google/gemini-cli
updatePackageJson('packages/cli/package.json', (pkg) => {
pkg.name = '@google-gemini/gemini-cli';
pkg.files = ['bundle/'];
pkg.bin = {
gemini: 'bundle/gemini.js',
};
// Remove fields that are not relevant to the bundled package.
delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.scripts;
delete pkg.main;
delete pkg.config; // Deletes the sandboxImageUri
});
// Update @google/gemini-cli-a2a-server
updatePackageJson('packages/a2a-server/package.json', (pkg) => {
pkg.name = '@google-gemini/gemini-cli-a2a-server';
});
// Update @google/gemini-cli-core
updatePackageJson('packages/core/package.json', (pkg) => {
pkg.name = '@google-gemini/gemini-cli-core';
});
console.log('Successfully prepared packages for GitHub release.');
+67
View File
@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
const rootDir = process.cwd();
function readJson(filePath) {
return JSON.parse(fs.readFileSync(path.resolve(rootDir, filePath), 'utf-8'));
}
function writeJson(filePath, data) {
fs.writeFileSync(
path.resolve(rootDir, filePath),
JSON.stringify(data, null, 2),
);
}
// Copy bundle directory into packages/cli
const sourceBundleDir = path.resolve(rootDir, 'bundle');
const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle');
if (fs.existsSync(sourceBundleDir)) {
fs.rmSync(destBundleDir, { recursive: true, force: true });
fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true });
console.log('Copied bundle/ directory to packages/cli/');
} else {
console.error(
'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.',
);
process.exit(1);
}
// Inherit optionalDependencies from root package.json, excluding dev-only packages.
const rootPkg = readJson('package.json');
const optionalDependencies = { ...(rootPkg.optionalDependencies || {}) };
delete optionalDependencies['gemini-cli-devtools'];
// Update @google/gemini-cli package.json for bundled npm release
const cliPkgPath = 'packages/cli/package.json';
const cliPkg = readJson(cliPkgPath);
cliPkg.files = ['bundle/'];
cliPkg.bin = {
gemini: 'bundle/gemini.js',
};
delete cliPkg.dependencies;
delete cliPkg.devDependencies;
delete cliPkg.scripts;
delete cliPkg.main;
delete cliPkg.config;
cliPkg.optionalDependencies = optionalDependencies;
writeJson(cliPkgPath, cliPkg);
console.log('Updated packages/cli/package.json for bundled npm release.');
console.log(
'optionalDependencies:',
JSON.stringify(optionalDependencies, null, 2),
);
console.log('Successfully prepared packages for npm release.');
+51
View File
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
function copyFiles(packageName, filesToCopy) {
const packageDir = path.resolve(rootDir, 'packages', packageName);
if (!fs.existsSync(packageDir)) {
console.error(`Error: Package directory not found at ${packageDir}`);
process.exit(1);
}
console.log(`Preparing package: ${packageName}`);
for (const [source, dest] of Object.entries(filesToCopy)) {
const sourcePath = path.resolve(rootDir, source);
const destPath = path.resolve(packageDir, dest);
try {
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied ${source} to packages/${packageName}/`);
} catch (err) {
console.error(`Error copying ${source}:`, err);
process.exit(1);
}
}
}
// Prepare 'core' package
copyFiles('core', {
'README.md': 'README.md',
LICENSE: 'LICENSE',
'.npmrc': '.npmrc',
});
// Prepare 'cli' package
copyFiles('cli', {
'README.md': 'README.md',
LICENSE: 'LICENSE',
});
console.log('Successfully prepared all packages.');
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# scripts/relabel_issues.sh
# Usage: ./scripts/relabel_issues.sh <old-label> <new-label> [repository]
set -e
set -o pipefail
OLD_LABEL="${1}"
NEW_LABEL="${2}"
REPO="${3:-google-gemini/gemini-cli}"
if [[ -z "${OLD_LABEL}" ]] || [[ -z "${NEW_LABEL}" ]]; then
echo "Usage: $0 <old-label> <new-label> [repository]"
echo "Example: $0 'area/models' 'area/agent'"
exit 1
fi
echo "🔍 Searching for open issues in '${REPO}' with label '${OLD_LABEL}'..."
# Fetch issues with the old label
ISSUES=$(gh issue list --repo "${REPO}" --label "${OLD_LABEL}" --state open --limit 1000 --json number,title)
# Avoid masking return value
COUNT=$(jq '. | length' <<< "${ISSUES}")
if [[ "${COUNT}" -eq 0 ]]; then
echo "✅ No issues found with label '${OLD_LABEL}'."
exit 0
fi
echo "found ${COUNT} issues to relabel."
# Iterate and update
echo "${ISSUES}" | jq -r '.[] | "\(.number) \(.title)"' | while read -r number title; do
echo "🔄 Processing #${number}: ${title}"
echo " - Removing: ${OLD_LABEL}"
echo " + Adding: ${NEW_LABEL}"
gh issue edit "${number}" --repo "${REPO}" --add-label "${NEW_LABEL}" --remove-label "${OLD_LABEL}"
echo " ✅ Done."
done
echo "🎉 All issues relabeled!"
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('commit', {
alias: 'c',
description: 'The commit SHA to cherry-pick for the patch.',
type: 'string',
demandOption: true,
})
.option('pullRequestNumber', {
alias: 'pr',
description: "The pr number that we're cherry picking",
type: 'number',
demandOption: true,
})
.option('channel', {
alias: 'ch',
description: 'The release channel to patch.',
choices: ['stable', 'preview'],
demandOption: true,
})
.option('cli-package-name', {
description:
'fully qualified package name with scope (e.g @google/gemini-cli)',
string: true,
default: '@google/gemini-cli',
})
.option('dry-run', {
description: 'Whether to run in dry-run mode.',
type: 'boolean',
default: false,
})
.help()
.alias('help', 'h').argv;
const { commit, channel, dryRun, pullRequestNumber } = argv;
console.log(`Starting patch process for commit: ${commit}`);
console.log(`Targeting channel: ${channel}`);
if (dryRun) {
console.log('Running in dry-run mode.');
}
run('git fetch --all --tags --prune', dryRun);
const releaseInfo = getLatestReleaseInfo({ argv, channel });
const latestTag = releaseInfo.currentTag;
const nextVersion = releaseInfo.nextVersion;
const releaseBranch = `release/${latestTag}-pr-${pullRequestNumber}`;
const hotfixBranch = `hotfix/${latestTag}/${nextVersion}/${channel}/cherry-pick-${commit.substring(0, 7)}/pr-${pullRequestNumber}`;
// Create the release branch from the tag if it doesn't exist.
if (!branchExists(releaseBranch)) {
console.log(
`Release branch ${releaseBranch} does not exist. Creating it from tag ${latestTag}...`,
);
try {
run(`git checkout -b ${releaseBranch} ${latestTag}`, dryRun);
run(`git push origin ${releaseBranch}`, dryRun);
} catch (error) {
// Check if this is a GitHub App workflows permission error
if (
error.message.match(/refusing to allow a GitHub App/i) &&
error.message.match(/workflows?['`]? permission/i)
) {
console.error(
`❌ Failed to create release branch due to insufficient GitHub App permissions.`,
);
console.log(
`\n📋 Please run these commands manually to create the branch:`,
);
console.log(`\n\`\`\`bash`);
console.log(`git checkout -b ${releaseBranch} ${latestTag}`);
console.log(`git push origin ${releaseBranch}`);
console.log(`\`\`\``);
console.log(
`\nAfter running these commands, you can run the patch command again.`,
);
process.exit(1);
} else {
// Re-throw other errors
throw error;
}
}
} else {
console.log(`Release branch ${releaseBranch} already exists.`);
}
// Check if hotfix branch already exists
if (branchExists(hotfixBranch)) {
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
// Check if there's already a PR for this branch
try {
const prInfo = execSync(
`gh pr list --head ${hotfixBranch} --json number,url --jq '.[0] // empty'`,
)
.toString()
.trim();
if (prInfo && prInfo !== 'null' && prInfo !== '') {
const pr = JSON.parse(prInfo);
console.log(`Found existing PR #${pr.number}: ${pr.url}`);
console.log(`Hotfix branch ${hotfixBranch} already has an open PR.`);
return { existingBranch: hotfixBranch, existingPR: pr };
} else {
console.log(`Hotfix branch ${hotfixBranch} exists but has no open PR.`);
console.log(
`You may need to delete the branch and run this command again.`,
);
return { existingBranch: hotfixBranch };
}
} catch (err) {
console.error(`Error checking for existing PR: ${err.message}`);
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
return { existingBranch: hotfixBranch };
}
}
// Create the hotfix branch from the release branch.
console.log(
`Creating hotfix branch ${hotfixBranch} from ${releaseBranch}...`,
);
run(`git checkout -b ${hotfixBranch} origin/${releaseBranch}`, dryRun);
// Ensure git user is configured properly for commits
console.log('Configuring git user for cherry-pick commits...');
run('git config user.name "gemini-cli-robot"', dryRun);
run('git config user.email "gemini-cli-robot@google.com"', dryRun);
// Cherry-pick the commit.
console.log(`Cherry-picking commit ${commit} into ${hotfixBranch}...`);
let hasConflicts = false;
if (!dryRun) {
try {
execSync(`git cherry-pick ${commit}`, { stdio: 'pipe' });
console.log(`✅ Cherry-pick successful - no conflicts detected`);
} catch (error) {
// Check if this is a cherry-pick conflict
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
const conflictFiles = status
.split('\n')
.filter(
(line) =>
line.startsWith('UU ') ||
line.startsWith('AA ') ||
line.startsWith('DU ') ||
line.startsWith('UD '),
);
if (conflictFiles.length > 0) {
hasConflicts = true;
console.log(
`⚠️ Cherry-pick has conflicts in ${conflictFiles.length} file(s):`,
);
conflictFiles.forEach((file) =>
console.log(` - ${file.substring(3)}`),
);
// Add all files (including conflict markers) and commit
console.log(
`📝 Creating commit with conflict markers for manual resolution...`,
);
execSync('git add .');
execSync(`git commit --no-edit --no-verify`);
console.log(`✅ Committed cherry-pick with conflict markers`);
} else {
// Re-throw if it's not a conflict error
throw error;
}
} catch {
// Re-throw original error if we can't determine the status
throw error;
}
}
} else {
console.log(`[DRY RUN] Would cherry-pick ${commit}`);
}
// Push the hotfix branch.
console.log(`Pushing hotfix branch ${hotfixBranch} to origin...`);
run(`git push --set-upstream origin ${hotfixBranch}`, dryRun);
// Create the pull request.
console.log(
`Creating pull request from ${hotfixBranch} to ${releaseBranch}...`,
);
let prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch} to patch version ${releaseInfo.currentTag} and create version ${releaseInfo.nextVersion}`;
let prBody = `This PR automatically cherry-picks commit ${commit} to patch version ${releaseInfo.currentTag} in the ${channel} release to create version ${releaseInfo.nextVersion}.`;
if (hasConflicts) {
prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch} [CONFLICTS]`;
prBody += `
## ⚠️ Merge Conflicts Detected
This cherry-pick resulted in merge conflicts that need manual resolution.
### 🔧 Next Steps:
1. **Review the conflicts**: Check out this branch and review the conflict markers
2. **Resolve conflicts**: Edit the affected files to resolve the conflicts
3. **Test the changes**: Ensure the patch works correctly after resolution
4. **Update this PR**: Push your conflict resolution
### 📋 Files with conflicts:
The commit has been created with conflict markers for easier manual resolution.
### 🚨 Important:
- Do not merge this PR until conflicts are resolved
- The automated patch release will trigger once this PR is merged`;
}
if (dryRun) {
prBody += '\n\n**[DRY RUN]**';
}
const prCommand = `gh pr create --base ${releaseBranch} --head ${hotfixBranch} --title "${prTitle}" --body "${prBody}"`;
run(prCommand, dryRun);
if (hasConflicts) {
console.log(
'⚠️ Patch process completed with conflicts - manual resolution required!',
);
} else {
console.log('✅ Patch process completed successfully!');
}
if (dryRun) {
console.log('\n--- Dry Run Summary ---');
console.log(`Release Branch: ${releaseBranch}`);
console.log(`Hotfix Branch: ${hotfixBranch}`);
console.log(`Pull Request Command: ${prCommand}`);
console.log('---------------------');
}
return { newBranch: hotfixBranch, created: true, hasConflicts };
}
function run(command, dryRun = false, throwOnError = true) {
console.log(`> ${command}`);
if (dryRun) {
return;
}
try {
return execSync(command).toString().trim();
} catch (err) {
console.error(`Command failed: ${command}`);
if (throwOnError) {
throw err;
}
return null;
}
}
function branchExists(branchName) {
try {
execSync(`git ls-remote --exit-code --heads origin ${branchName}`);
return true;
} catch {
return false;
}
}
function getLatestReleaseInfo({ argv, channel } = {}) {
console.log(`Fetching latest release info for channel: ${channel}...`);
const patchFrom = channel; // 'stable' or 'preview'
const command = `node scripts/get-release-version.js --cli-package-name="${argv['cli-package-name']}" --type=patch --patch-from=${patchFrom}`;
try {
const result = JSON.parse(execSync(command).toString().trim());
console.log(`Current ${channel} tag: ${result.previousReleaseTag}`);
console.log(`Next ${channel} version would be: ${result.releaseVersion}`);
return {
currentTag: result.previousReleaseTag,
nextVersion: result.releaseVersion,
};
} catch (err) {
console.error(`Failed to get release info for channel: ${channel}`);
throw err;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Script for commenting back to original PR with patch release results.
* Used by the patch release workflow (step 3).
*/
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('original-pr', {
description: 'The original PR number to comment on',
type: 'number',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('success', {
description: 'Whether the release succeeded',
type: 'boolean',
})
.option('release-version', {
description: 'The release version (e.g., 0.5.4)',
type: 'string',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('release-tag', {
description: 'The release tag (e.g., v0.5.4)',
type: 'string',
})
.option('npm-tag', {
description: 'The npm tag (latest or preview)',
type: 'string',
})
.option('channel', {
description: 'The channel (stable or preview)',
type: 'string',
choices: ['stable', 'preview'],
})
.option('dry-run', {
description: 'Whether this was a dry run',
type: 'boolean',
default: false,
})
.option('test', {
description: 'Test mode - validate logic without GitHub API calls',
type: 'boolean',
default: false,
})
.example(
'$0 --original-pr 8655 --success --release-version "0.5.4" --channel stable --test',
'Test success comment',
)
.example(
'$0 --original-pr 8655 --no-success --channel preview --test',
'Test failure comment',
)
.help()
.alias('help', 'h').argv;
const testMode = argv.test || process.env.TEST_MODE === 'true';
// Initialize GitHub API client only if not in test mode
let github;
if (!testMode) {
const { Octokit } = await import('@octokit/rest');
github = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
}
const repo = {
owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini',
repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli',
};
// Get inputs from CLI args or environment
const originalPr = argv.originalPr || process.env.ORIGINAL_PR;
const success =
argv.success !== undefined ? argv.success : process.env.SUCCESS === 'true';
const releaseVersion = argv.releaseVersion || process.env.RELEASE_VERSION;
const releaseTag =
argv.releaseTag ||
process.env.RELEASE_TAG ||
(releaseVersion ? `v${releaseVersion}` : null);
const npmTag =
argv.npmTag ||
process.env.NPM_TAG ||
(argv.channel === 'stable' ? 'latest' : 'preview');
const channel = argv.channel || process.env.CHANNEL || 'stable';
const dryRun = argv.dryRun || process.env.DRY_RUN === 'true';
const runId = process.env.GITHUB_RUN_ID || '12345678';
const raceConditionFailure = process.env.RACE_CONDITION_FAILURE === 'true';
// Current version info for race condition failures
const currentReleaseVersion = process.env.CURRENT_RELEASE_VERSION;
const currentReleaseTag = process.env.CURRENT_RELEASE_TAG;
const currentPreviousTag = process.env.CURRENT_PREVIOUS_TAG;
if (!originalPr) {
console.log('No original PR specified, skipping comment');
return;
}
console.log(
`Commenting on original PR ${originalPr} with ${success ? 'success' : 'failure'} status`,
);
if (testMode) {
console.log('\n🧪 TEST MODE - No API calls will be made');
console.log('\n📋 Inputs:');
console.log(` - Original PR: ${originalPr}`);
console.log(` - Success: ${success}`);
console.log(` - Release Version: ${releaseVersion}`);
console.log(` - Release Tag: ${releaseTag}`);
console.log(` - NPM Tag: ${npmTag}`);
console.log(` - Channel: ${channel}`);
console.log(` - Dry Run: ${dryRun}`);
console.log(` - Run ID: ${runId}`);
}
let commentBody;
if (success) {
commentBody = `✅ **[Step 4/4] Patch Release Complete!**
**📦 Release Details:**
- **Version**: [\`${releaseVersion}\`](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
- **NPM Tag**: \`${npmTag}\`
- **Channel**: \`${channel}\`
- **Dry Run**: ${dryRun}
**🎉 Status:** Your patch has been successfully released and published to npm!
**📝 What's Available:**
- **GitHub Release**: [View release ${releaseTag}](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
- **NPM Package**: \`npm install @google/gemini-cli@${npmTag}\`
**🔗 Links:**
- [GitHub Release](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag})
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
} else if (raceConditionFailure) {
commentBody = `⚠️ **[Step 4/4] Patch Release Cancelled - Concurrent Release Detected**
**🚦 What Happened:**
Another patch release completed while this one was in progress, causing a version conflict.
**📋 Details:**
- **Originally planned**: \`${releaseVersion || 'Unknown'}\`
- **Channel**: \`${channel}\`
- **Issue**: Version numbers are no longer sequential due to concurrent releases
**📊 Current State:**${
currentReleaseVersion
? `
- **Latest ${channel} version**: \`${currentPreviousTag?.replace(/^v/, '') || 'unknown'}\`
- **Next patch should be**: \`${currentReleaseVersion}\`
- **New release tag**: \`${currentReleaseTag || 'unknown'}\``
: `
- **Status**: Version information updated since this release was triggered`
}
**🔄 Next Steps:**
1. **Request a new patch** - The version calculation will now be correct
2. No action needed on your part - simply request the patch again
3. The system detected this automatically to prevent invalid releases
**💡 Why This Happens:**
Multiple patch releases can't run simultaneously. When they do, the second one is automatically cancelled to maintain version consistency.
**🔗 Details:**
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
} else {
commentBody = `❌ **[Step 4/4] Patch Release Failed!**
**📋 Details:**
- **Version**: \`${releaseVersion || 'Unknown'}\`
- **Channel**: \`${channel}\`
- **Error**: The patch release workflow encountered an error
**🔍 Next Steps:**
1. Check the workflow logs for detailed error information
2. The maintainers have been notified via automatic issue creation
3. You may need to retry the patch once the issue is resolved
**🔗 Troubleshooting:**
- [This release workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
- [View workflow logs](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})
- [Workflow History](https://github.com/${repo.owner}/${repo.repo}/actions/workflows/release-patch-3-release.yml)`;
}
if (testMode) {
console.log('\n💬 Would post comment:');
console.log('----------------------------------------');
console.log(commentBody);
console.log('----------------------------------------');
console.log('\n✅ Comment generation working correctly!');
} else if (github) {
await github.rest.issues.createComment({
owner: repo.owner,
repo: repo.repo,
issue_number: parseInt(originalPr),
body: commentBody,
});
console.log(`Successfully commented on PR ${originalPr}`);
} else {
console.log('No GitHub client available');
}
}
main().catch((error) => {
console.error('Error commenting on PR:', error);
process.exit(1);
});
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Script for commenting on the original PR after patch creation (step 1).
* Handles parsing create-patch-pr.js output and creating appropriate feedback.
*/
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('original-pr', {
description: 'The original PR number to comment on',
type: 'number',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('exit-code', {
description: 'Exit code from patch creation step',
type: 'number',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('commit', {
description: 'The commit SHA being patched',
type: 'string',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('channel', {
description: 'The channel (stable or preview)',
type: 'string',
choices: ['stable', 'preview'],
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('repository', {
description: 'The GitHub repository (owner/repo format)',
type: 'string',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('run-id', {
description: 'The GitHub workflow run ID',
type: 'string',
})
.option('environment', {
choices: ['prod', 'dev'],
type: 'string',
default: process.env.ENVIRONMENT || 'prod',
})
.option('test', {
description: 'Test mode - validate logic without GitHub API calls',
type: 'boolean',
default: false,
})
.example(
'$0 --original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
'Test success comment',
)
.example(
'$0 --original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
'Test failure comment',
)
.help()
.alias('help', 'h').argv;
const testMode = argv.test || process.env.TEST_MODE === 'true';
// GitHub CLI is available in the workflow environment
const hasGitHubCli = !testMode;
// Get inputs from CLI args or environment
const originalPr = argv.originalPr || process.env.ORIGINAL_PR;
const exitCode =
argv.exitCode !== undefined
? argv.exitCode
: parseInt(process.env.EXIT_CODE || '1');
const commit = argv.commit || process.env.COMMIT;
const channel = argv.channel || process.env.CHANNEL;
const environment = argv.environment;
const repository =
argv.repository || process.env.REPOSITORY || 'google-gemini/gemini-cli';
const runId = argv.runId || process.env.GITHUB_RUN_ID || '0';
// Validate required parameters
if (!runId || runId === '0') {
console.warn(
'Warning: No valid GitHub run ID found, workflow links may not work correctly',
);
}
if (!originalPr) {
console.log('No original PR specified, skipping comment');
return;
}
console.log(
`Analyzing patch creation result for PR ${originalPr} (exit code: ${exitCode})`,
);
const [_owner, _repo] = repository.split('/');
const npmTag = channel === 'stable' ? 'latest' : 'preview';
if (testMode) {
console.log('\n🧪 TEST MODE - No API calls will be made');
console.log('\n📋 Inputs:');
console.log(` - Original PR: ${originalPr}`);
console.log(` - Exit Code: ${exitCode}`);
console.log(` - Commit: ${commit}`);
console.log(` - Channel: ${channel} → npm tag: ${npmTag}`);
console.log(` - Repository: ${repository}`);
console.log(` - Run ID: ${runId}`);
}
let commentBody;
let logContent = '';
// Get log content from environment variable or generate mock content for testing
if (testMode && !process.env.LOG_CONTENT) {
// Create mock log content for testing only if LOG_CONTENT is not provided
if (exitCode === 0) {
logContent = `Creating hotfix branch hotfix/v0.5.3/${channel}/cherry-pick-${commit.substring(0, 7)} from release/v0.5.3`;
} else {
logContent = 'Error: Failed to create patch';
}
} else {
// Use log content from environment variable
logContent = process.env.LOG_CONTENT || '';
}
if (
logContent.includes(
'Failed to create release branch due to insufficient GitHub App permissions',
)
) {
// GitHub App permission error - extract manual commands
const manualCommandsMatch = logContent.match(
/📋 Please run these commands manually to create the branch:[\s\S]*?```bash\s*([\s\S]*?)\s*```/,
);
let manualCommands = '';
if (manualCommandsMatch) {
manualCommands = manualCommandsMatch[1].trim();
}
commentBody = `🔒 **[Step 2/4] GitHub App Permission Issue**
The patch creation failed due to insufficient GitHub App permissions for creating workflow files.
**📝 Manual Action Required:**
${
manualCommands
? `Please run these commands manually to create the release branch:
\`\`\`bash
${manualCommands}
\`\`\`
After running these commands, you can re-run the patch workflow.`
: 'Please check the workflow logs for manual commands to run.'
}
**🔗 Links:**
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
} else if (logContent.includes('already has an open PR')) {
// Branch exists with existing PR
const prMatch = logContent.match(/Found existing PR #(\d+): (.*)/);
if (prMatch) {
const [, prNumber, prUrl] = prMatch;
commentBody = `️ **[Step 2/4] Patch PR already exists!**
A patch PR for this change already exists: [#${prNumber}](${prUrl}).
**📝 Next Steps:**
1. Review and approve the existing patch PR
2. If it's incorrect, close it and run the patch command again
**🔗 Links:**
- [View existing patch PR #${prNumber}](${prUrl})`;
}
} else if (logContent.includes('exists but has no open PR')) {
// Branch exists but no PR
const branchMatch = logContent.match(/Hotfix branch (.*) already exists/);
if (branchMatch) {
const [, branch] = branchMatch;
commentBody = `️ **[Step 2/4] Patch branch exists but no PR found!**
A patch branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}) exists but has no open PR.
**🔍 Issue:** This might indicate an incomplete patch process.
**📝 Next Steps:**
1. Delete the branch: \`git branch -D ${branch}\`
2. Run the patch command again
**🔗 Links:**
- [View branch on GitHub](https://github.com/${repository}/tree/${branch})`;
}
} else if (exitCode === 0) {
// Success - extract branch info
const branchMatch = logContent.match(/Creating hotfix branch (.*) from/);
if (branchMatch) {
const [, branch] = branchMatch;
if (testMode) {
// Mock PR info for testing
const mockPrNumber = Math.floor(Math.random() * 1000) + 8000;
const mockPrUrl = `https://github.com/${repository}/pull/${mockPrNumber}`;
const hasConflicts =
logContent.includes('Cherry-pick has conflicts') ||
logContent.includes('[CONFLICTS]');
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
**📋 Patch Details:**
- **Environment**: \`${environment}\`
- **Channel**: \`${channel}\` → will publish to npm tag \`${npmTag}\`
- **Commit**: \`${commit}\`
- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch})
- **Hotfix PR**: [#${mockPrNumber}](${mockPrUrl})${hasConflicts ? '\n- **⚠️ Status**: Cherry-pick conflicts detected - manual resolution required' : ''}
**📝 Next Steps:**
1. ${hasConflicts ? '⚠️ **Resolve conflicts** in the hotfix PR first' : 'Review and approve the hotfix PR'}: [#${mockPrNumber}](${mockPrUrl})${hasConflicts ? '\n2. **Test your changes** after resolving conflicts' : ''}
${hasConflicts ? '3' : '2'}. Once merged, the patch release will automatically trigger
${hasConflicts ? '4' : '3'}. You'll receive updates here when the release completes
**🔗 Track Progress:**
- [View hotfix PR #${mockPrNumber}](${mockPrUrl})
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
} else if (hasGitHubCli) {
// Find the actual PR for the new branch using gh CLI
try {
const { spawnSync } = await import('node:child_process');
const result = spawnSync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--json',
'number,title,url',
'--limit',
'1',
],
{ encoding: 'utf8' },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`gh pr list failed with status ${result.status}: ${result.stderr}`,
);
}
const prListOutput = result.stdout;
const prList = JSON.parse(prListOutput);
if (prList.length > 0) {
const pr = prList[0];
const hasConflicts =
logContent.includes('Cherry-pick has conflicts') ||
pr.title.includes('[CONFLICTS]');
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
**📋 Patch Details:**
- **Environment**: \`${environment}\`
- **Channel**: \`${channel}\` → will publish to npm tag \`${npmTag}\`
- **Commit**: \`${commit}\`
- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch})
- **Hotfix PR**: [#${pr.number}](${pr.url})${hasConflicts ? '\n- **⚠️ Status**: Cherry-pick conflicts detected - manual resolution required' : ''}
**📝 Next Steps:**
1. ${hasConflicts ? '⚠️ **Resolve conflicts** in the hotfix PR first' : 'Review and approve the hotfix PR'}: [#${pr.number}](${pr.url})${hasConflicts ? '\n2. **Test your changes** after resolving conflicts' : ''}
${hasConflicts ? '3' : '2'}. Once merged, the patch release will automatically trigger
${hasConflicts ? '4' : '3'}. You'll receive updates here when the release completes
**🔗 Track Progress:**
- [View hotfix PR #${pr.number}](${pr.url})
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
} else {
// Fallback if PR not found yet
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
The patch release PR for this change has been created on branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}).
**📝 Next Steps:**
1. Review and approve the patch PR
2. Once merged, the patch release will automatically trigger
**🔗 Links:**
- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
}
} catch (error) {
console.log('Error finding PR for branch:', error.message);
// Fallback
commentBody = `🚀 **[Step 2/4] Patch PR Created!**
The patch release PR for this change has been created.
**🔗 Links:**
- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)
- [This patch creation workflow run](https://github.com/${repository}/actions/runs/${runId})`;
}
}
}
} else {
// Failure
commentBody = `❌ **[Step 2/4] Patch creation failed!**
There was an error creating the patch release.
**🔍 Troubleshooting:**
- Check the workflow logs for detailed error information
- Verify the commit SHA is valid and accessible
- Ensure you have permissions to create branches and PRs
**🔗 Links:**
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
}
if (!commentBody) {
commentBody = `❌ **[Step 2/4] Patch creation failed!**
No output was generated during patch creation.
**🔗 Links:**
- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`;
}
if (testMode) {
console.log('\n💬 Would post comment:');
console.log('----------------------------------------');
console.log(commentBody);
console.log('----------------------------------------');
console.log('\n✅ Comment generation working correctly!');
} else if (hasGitHubCli) {
const { spawnSync } = await import('node:child_process');
const { writeFileSync, unlinkSync } = await import('node:fs');
const { join } = await import('node:path');
// Write comment to temporary file to avoid shell escaping issues
const tmpFile = join(process.cwd(), `comment-${Date.now()}.md`);
writeFileSync(tmpFile, commentBody);
try {
const result = spawnSync(
'gh',
['pr', 'comment', originalPr.toString(), '--body-file', tmpFile],
{
stdio: 'inherit',
},
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`gh pr comment failed with status ${result.status}`);
}
console.log(`Successfully commented on PR ${originalPr}`);
} finally {
// Clean up temp file
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
}
} else {
console.log('No GitHub CLI available');
}
}
main().catch((error) => {
console.error('Error commenting on PR:', error);
process.exit(1);
});
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Script for patch release trigger workflow (step 2).
* Handles channel detection, workflow dispatch, and user feedback.
*/
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
/**
* Extract base version, original pr, and originalPr info from hotfix branch name.
* Formats:
* - New NEW: hotfix/v0.5.3/v0.5.4/preview/cherry-pick-abc/pr-1234 -> v0.5.4, preview, 1234
* - New format: hotfix/v0.5.3/preview/cherry-pick-abc -> v0.5.3 and preview
* - Old format: hotfix/v0.5.3/cherry-pick-abc -> v0.5.3 and stable (default)
* We check the formats from newest to oldest. If the channel found is invalid,
* an error is thrown.
*/
function getBranchInfo({ branchName, context }) {
const parts = branchName.split('/');
const version = parts[1];
let prNum;
let channel = 'stable'; // default for old format
if (parts.length >= 6 && (parts[3] === 'stable' || parts[3] === 'preview')) {
channel = parts[3];
const prMatch = parts[5].match(/pr-(\d+)/);
prNum = prMatch[1];
} else if (
parts.length >= 4 &&
(parts[2] === 'stable' || parts[2] === 'preview')
) {
// New format with explicit channel
channel = parts[2];
} else if (context.eventName === 'workflow_dispatch') {
// Manual dispatch, infer from version name
channel = version.includes('preview') ? 'preview' : 'stable';
}
// Validate channel
if (channel !== 'stable' && channel !== 'preview') {
throw new Error(
`Invalid channel: ${channel}. Must be 'stable' or 'preview'.`,
);
}
return { channel, prNum, version };
}
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('head-ref', {
description:
'The hotfix branch name (e.g., hotfix/v0.5.3/preview/cherry-pick-abc1234)',
type: 'string',
demandOption: !process.env.GITHUB_ACTIONS,
})
.option('pr-body', {
description: 'The PR body content',
type: 'string',
default: '',
})
.option('dry-run', {
description: 'Run in test mode without actually triggering workflows',
type: 'boolean',
default: false,
})
.option('test', {
description: 'Test mode - validate logic without GitHub API calls',
type: 'boolean',
default: false,
})
.option('force-skip-tests', {
description: 'Skip the "Run Tests" step in testing',
type: 'boolean',
default: false,
})
.option('environment', {
choices: ['prod', 'dev'],
type: 'string',
default: process.env.ENVIRONMENT || 'prod',
})
.example(
'$0 --head-ref "hotfix/v0.5.3/preview/cherry-pick-abc1234" --test',
'Test channel detection logic',
)
.example(
'$0 --head-ref "hotfix/v0.5.3/stable/cherry-pick-abc1234" --dry-run',
'Test with GitHub API in dry-run mode',
)
.help()
.alias('help', 'h').argv;
const testMode = argv.test || process.env.TEST_MODE === 'true';
const context = {
eventName: process.env.GITHUB_EVENT_NAME || 'pull_request',
repo: {
owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini',
repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli',
},
payload: JSON.parse(process.env.GITHUB_EVENT_PAYLOAD || '{}'),
};
// Get inputs from CLI args or environment
const headRef = argv.headRef || process.env.HEAD_REF;
const environment = argv.environment;
const body = argv.prBody || process.env.PR_BODY || '';
const isDryRun = argv.dryRun || body.includes('[DRY RUN]');
const forceSkipTests =
argv.forceSkipTests || process.env.FORCE_SKIP_TESTS === 'true';
const runId = process.env.GITHUB_RUN_ID || '0';
if (!headRef) {
throw new Error(
'head-ref is required. Use --head-ref or set HEAD_REF environment variable.',
);
}
console.log(`Processing patch trigger for branch: ${headRef}`);
const { prNum, version, channel } = getBranchInfo({
branchName: headRef,
context,
});
let originalPr = prNum;
console.log(`Found originalPr: ${prNum} from hotfix branch`);
// Fallback to using PR search (inconsistent) if no pr found in branch name.
if (!testMode && !originalPr) {
try {
console.log('Looking for original PR using search...');
const { execFileSync } = await import('node:child_process');
// Split search string into searchArgs to prevent triple escaping on the quoted filters
const searchArgs =
`repo:${context.repo.owner}/${context.repo.repo} is:pr in:comments "${headRef}"`.split(
' ',
);
console.log('Search args:', searchArgs);
// Use gh CLI to search for PRs with comments referencing the hotfix branch
const result = execFileSync(
'gh',
[
'search',
'prs',
'--json',
'number,title',
'--limit',
'1',
...searchArgs,
'Patch PR Created',
],
{
encoding: 'utf8',
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
},
);
const searchResults = JSON.parse(result);
if (searchResults && searchResults.length > 0) {
originalPr = searchResults[0].number;
console.log(`Found original PR: #${originalPr}`);
} else {
console.log('Could not find a matching original PR via search.');
}
} catch (e) {
console.log('Could not determine original PR:', e.message);
}
}
if (!originalPr && testMode) {
console.log('Skipping original PR lookup (test mode)');
originalPr = 8655; // Mock for testing
}
if (!originalPr) {
throw new Error(
'Could not find the original PR for this patch. Cannot proceed with release.',
);
}
const releaseRef = `release/${version}-pr-${originalPr}`;
const workflowId =
context.eventName === 'pull_request'
? 'release-patch-3-release.yml'
: process.env.WORKFLOW_ID || 'release-patch-3-release.yml';
console.log(`Detected channel: ${channel}, version: ${version}`);
console.log(`Release ref: ${releaseRef}`);
console.log(`Workflow ID: ${workflowId}`);
console.log(`Dry run: ${isDryRun}`);
if (testMode) {
console.log('\n🧪 TEST MODE - No API calls will be made');
console.log('\n📋 Parsed Results:');
console.log(` - Branch: ${headRef}`);
console.log(
` - Channel: ${channel} → npm tag: ${channel === 'stable' ? 'latest' : 'preview'}`,
);
console.log(` - Version: ${version}`);
console.log(` - Release ref: ${releaseRef}`);
console.log(` - Workflow: ${workflowId}`);
console.log(` - Dry run: ${isDryRun}`);
console.log('\n✅ Channel detection logic working correctly!');
return;
}
// Trigger the release workflow
console.log(`Triggering release workflow: ${workflowId}`);
if (!testMode) {
try {
const { execFileSync } = await import('node:child_process');
const args = [
'workflow',
'run',
workflowId,
'--ref',
'main',
'--field',
`type=${channel}`,
'--field',
`dry_run=${isDryRun.toString()}`,
'--field',
`force_skip_tests=${forceSkipTests.toString()}`,
'--field',
`release_ref=${releaseRef}`,
'--field',
`environment=${environment}`,
'--field',
originalPr ? `original_pr=${originalPr.toString()}` : 'original_pr=',
];
console.log(`Running command: gh ${args.join(' ')}`);
execFileSync('gh', args, {
stdio: 'inherit',
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
});
console.log('✅ Workflow dispatch completed successfully');
} catch (e) {
console.error('❌ Failed to dispatch workflow:', e.message);
throw e;
}
} else {
console.log('✅ Would trigger workflow with inputs:', {
type: channel,
dry_run: isDryRun.toString(),
force_skip_tests: forceSkipTests.toString(),
release_ref: releaseRef,
original_pr: originalPr ? originalPr.toString() : '',
});
}
// Comment back to original PR if we found it
if (originalPr) {
console.log(`Commenting on original PR ${originalPr}...`);
const npmTag = channel === 'stable' ? 'latest' : 'preview';
const commentBody = `🚀 **[Step 3/4] Patch Release ${environment === 'prod' ? 'Waiting for Approval' : 'Triggered'}!**
**📋 Release Details:**
- **Environment**: \`${environment}\`
- **Channel**: \`${channel}\` → publishing to npm tag \`${npmTag}\`
- **Version**: \`${version}\`
- **Hotfix PR**: Merged ✅
- **Release Branch**: [\`${releaseRef}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${releaseRef})
**⏳ Status:** The patch release has been triggered${environment === 'prod' ? ' and is waiting for deployment approval. Please visit the specific workflow run link below and approve the deployment' : ''}. You'll receive another update when it completes.
**🔗 Track Progress:**
- [View release workflow history](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/${workflowId})
- [This trigger workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId})`;
if (!testMode) {
let tempDir;
try {
const { execFileSync } = await import('node:child_process');
const { writeFileSync, mkdtempSync } = await import('node:fs');
const { join } = await import('node:path');
const { tmpdir } = await import('node:os');
// Create secure temporary directory and file
tempDir = mkdtempSync(join(tmpdir(), 'patch-trigger-'));
const tempFile = join(tempDir, 'comment.md');
writeFileSync(tempFile, commentBody);
execFileSync(
'gh',
['pr', 'comment', originalPr.toString(), '--body-file', tempFile],
{
stdio: 'inherit',
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
},
);
console.log('✅ Comment posted successfully');
} catch (e) {
console.error('❌ Failed to post comment:', e.message);
// Don't throw here since the main workflow dispatch succeeded
} finally {
// Clean up temp directory and all its contents
if (tempDir) {
try {
const { rmSync } = await import('node:fs');
rmSync(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.warn(
'⚠️ Failed to clean up temp directory:',
cleanupError.message,
);
}
}
}
} else {
console.log('✅ Would post comment:', commentBody);
}
}
console.log('Patch trigger completed successfully!');
}
main().catch((error) => {
console.error('Error in patch trigger:', error);
process.exit(1);
});
+137
View File
@@ -0,0 +1,137 @@
#!/bin/bash
# scripts/review.sh
#
# Usage: ./scripts/review.sh <pr#> [model]
set -e
if [[ -z "${1}" ]]; then
echo "Usage: ${0} <pr#> [model]"
exit 1
fi
pr="${1}"
model="${2:-gemini-3.1-pro-preview}"
REPO="google-gemini/gemini-cli"
REVIEW_DIR="${HOME}/git/review/gemini-cli"
if [[ ! -d "${REVIEW_DIR}" ]]; then
echo "ERROR: Directory ${REVIEW_DIR} does not exist."
echo ""
echo "Please create a new gemini-cli clone at that directory to use for reviews."
echo "Instructions:"
echo " mkdir -p ~/git/review"
echo " cd ~/git/review"
echo " git clone https://github.com/google-gemini/gemini-cli.git"
exit 1
fi
# 1. Check if the PR exists before doing anything else
echo "review: Validating PR ${pr} on ${REPO}..."
if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then
echo "ERROR: Could not find PR #${pr} in ${REPO}."
echo "Are you sure ${pr} is a Pull Request number and not an Issue number?"
exit 1
fi
echo "review: Opening PR ${pr} in browser..."
uname_out="$(uname || true)"
if [[ "${uname_out}" == "Darwin" ]]; then
open "https://github.com/${REPO}/pull/${pr}" || true
else
xdg-open "https://github.com/${REPO}/pull/${pr}" || true
fi
echo "review: Changing directory to ${REVIEW_DIR}"
cd "${REVIEW_DIR}" || exit 1
# 2. Fetch latest main to ensure we have a clean starting point
echo "review: Fetching latest from origin..."
git fetch origin main
# 3. Handle worktree creation
WORKTREE_PATH="pr_${pr}"
if [[ -d "${WORKTREE_PATH}" ]]; then
echo "review: Worktree directory ${WORKTREE_PATH} already exists."
# Check if it's actually a registered worktree
# shellcheck disable=SC2312
if git worktree list | grep -q "${WORKTREE_PATH}"; then
echo "review: Reusing existing worktree..."
else
echo "review: Directory exists but is not a worktree. Cleaning up..."
rm -rf "${WORKTREE_PATH}"
fi
fi
if [[ ! -d "${WORKTREE_PATH}" ]]; then
echo "review: Adding new worktree at ${WORKTREE_PATH}..."
# Create a detached worktree from origin/main
git worktree add --detach "${WORKTREE_PATH}" origin/main
fi
echo "review: Changing directory to ${WORKTREE_PATH}"
cd "${WORKTREE_PATH}" || exit 1
# 4. Checkout the PR
echo "review: Cleaning worktree and checking out PR ${pr}..."
git reset --hard
git clean -fd
gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}"
# 5. Clean and Build
echo "review: Clearing possibly stale node_modules..."
rm -rf node_modules
rm -rf packages/core/dist/
rm -rf packages/cli/node_modules/
rm -rf packages/core/node_modules/
echo "review: Installing npm dependencies..."
npm install
echo "--- build ---"
temp_dir_base="${TMPDIR:-/tmp}"
build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
echo "Attempting to create temporary file in current directory as a fallback." >&2
build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)"
if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then
echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2
exit 1
fi
fi
build_status=0
build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build"
echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}"
echo "Build command: ${build_command_to_run}"
if [[ "${uname_out}" == "Darwin" ]]; then
script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}" || build_status=$?
else
if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then
build_status=0
else
build_status=$?
fi
fi
if [[ "${build_status}" -ne 0 ]]; then
echo "ERROR: npm build failed with exit status ${build_status}." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
exit 1
else
# shellcheck disable=SC2312
if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then
echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2
echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2
exit 1
fi
echo "npm build completed successfully (exit status 0, no critical error patterns found in log)."
rm -f "${build_log_file}"
fi
echo "-- running ---"
if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then
echo "ERROR: npm start failed. Please check its output for details." >&2
exit 1
fi
+107
View File
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Orchestrates the PR evaluation process across multiple models.
*
* This script loops through a provided list of models, identifies trustworthy
* tests for each, executes the frugal regression check, and collects results
* into a single unified report. It exits with code 1 if any confirmed
* regressions are detected.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
/**
* Main execution logic.
*/
async function main() {
const modelList = process.env.MODEL_LIST || 'gemini-3-flash-preview';
const models = modelList.split(',').map((m) => m.trim());
let combinedReport = '';
let hasRegression = false;
console.log(
`🚀 Starting evaluation orchestration for models: ${models.join(', ')}`,
);
for (const model of models) {
console.log(`\n--- Processing Model: ${model} ---`);
try {
// 1. Identify Trustworthy Evals
console.log(`🔍 Identifying trustworthy tests for ${model}...`);
const output = execSync(
`node scripts/get_trustworthy_evals.js "${model}"`,
{
encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'inherit'], // Capture stdout but pass stdin/stderr
},
).trim();
if (!output) {
console.log(`️ No trustworthy tests found for ${model}. Skipping.`);
continue;
}
// 2. Run Frugal Regression Check
console.log(`🧪 Running regression check for ${model}...`);
execSync(`node scripts/run_regression_check.js "${model}" "${output}"`, {
stdio: 'inherit',
});
// 3. Generate Report
console.log(`📊 Generating report for ${model}...`);
const report = execSync(`node scripts/compare_evals.js "${model}"`, {
encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'inherit'],
}).trim();
if (report) {
if (combinedReport) {
combinedReport += '\n\n---\n\n';
}
combinedReport += report;
// 4. Check for Regressions
// If the report contains the "Action Required" marker, it means a confirmed regression was found.
if (report.includes('Action Required')) {
hasRegression = true;
}
}
} catch (error) {
console.error(`❌ Error processing model ${model}:`, error.message);
// We flag a failure if any model encountered a critical error
hasRegression = true;
}
}
// Always save the combined report to a file so the workflow can capture it cleanly
if (combinedReport) {
fs.writeFileSync('eval_regression_report.md', combinedReport);
console.log(
'\n📊 Final Markdown report saved to eval_regression_report.md',
);
}
// Log status for CI visibility, but don't exit with error
if (hasRegression) {
console.error(
'\n⚠️ Confirmed regressions detected across one or more models. See PR comment for details.',
);
} else {
console.log('\n✅ All evaluations passed successfully (or were cleared).');
}
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+305
View File
@@ -0,0 +1,305 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Executes a high-signal regression check for behavioral evaluations.
*
* This script runs a targeted set of stable tests in an optimistic first pass.
* If failures occur, it employs a "Best-of-4" retry logic to handle natural flakiness.
* For confirmed failures (0/3), it performs Dynamic Baseline Verification by
* checking the failure against the 'main' branch to distinguish between
* model drift and PR-introduced regressions.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { quote } from 'shell-quote';
import { escapeRegex } from './eval_utils.js';
/**
* Runs a set of tests using Vitest and returns the results.
*/
function runTests(files, pattern, model) {
const outputDir = path.resolve(
process.cwd(),
`evals/logs/pr-run-${Date.now()}`,
);
fs.mkdirSync(outputDir, { recursive: true });
const filesToRun = files || 'evals/';
console.log(
`🚀 Running tests in ${filesToRun} with pattern: ${pattern?.slice(0, 100)}...`,
);
try {
const cmd = `npx vitest run --config evals/vitest.config.ts ${filesToRun} -t "${pattern}" --reporter=json --reporter=default --outputFile="${path.join(outputDir, 'report.json')}"`;
execSync(cmd, {
stdio: 'inherit',
env: { ...process.env, RUN_EVALS: '1', GEMINI_MODEL: model },
});
} catch {
// Vitest returns a non-zero exit code when tests fail. This is expected.
// We continue execution and handle the failures by parsing the JSON report.
}
const reportPath = path.join(outputDir, 'report.json');
return fs.existsSync(reportPath)
? JSON.parse(fs.readFileSync(reportPath, 'utf-8'))
: null;
}
/**
* Helper to find a specific assertion by name across all test files.
*/
function findAssertion(report, testName) {
if (!report?.testResults) return null;
for (const fileResult of report.testResults) {
const assertion = fileResult.assertionResults.find(
(a) => a.title === testName,
);
if (assertion) return assertion;
}
return null;
}
/**
* Parses command line arguments to identify model, files, and test pattern.
*/
function parseArgs() {
const modelArg = process.argv[2];
const remainingArgs = process.argv.slice(3);
const fullArgsString = remainingArgs.join(' ');
const testPatternIndex = remainingArgs.indexOf('--test-pattern');
if (testPatternIndex !== -1) {
return {
model: modelArg,
files: remainingArgs.slice(0, testPatternIndex).join(' '),
pattern: remainingArgs.slice(testPatternIndex + 1).join(' '),
};
}
if (fullArgsString.includes('--test-pattern')) {
const parts = fullArgsString.split('--test-pattern');
return {
model: modelArg,
files: parts[0].trim(),
pattern: parts[1].trim(),
};
}
// Fallback for manual mode: Pattern Model
const manualPattern = process.argv[2];
const manualModel = process.argv[3];
if (!manualModel) {
console.error('❌ Error: No target model specified.');
process.exit(1);
}
let manualFiles = 'evals/';
try {
const grepResult = execSync(
`grep -l ${quote([manualPattern])} evals/*.eval.ts`,
{ encoding: 'utf-8' },
);
manualFiles = grepResult.split('\n').filter(Boolean).join(' ');
} catch {
// Grep returns exit code 1 if no files match the pattern.
// In this case, we fall back to scanning all files in the evals/ directory.
}
return {
model: manualModel,
files: manualFiles,
pattern: manualPattern,
isManual: true,
};
}
/**
* Runs the targeted retry logic (Best-of-4) for a failing test.
*/
async function runRetries(testName, results, files, model) {
console.log(`\nRe-evaluating: ${testName}`);
while (
results[testName].passed < 2 &&
results[testName].total - results[testName].passed < 3 &&
results[testName].total < 4
) {
const attemptNum = results[testName].total + 1;
console.log(` Running attempt ${attemptNum}...`);
const retry = runTests(files, escapeRegex(testName), model);
const retryAssertion = findAssertion(retry, testName);
results[testName].total++;
if (retryAssertion?.status === 'passed') {
results[testName].passed++;
console.log(
` ✅ Attempt ${attemptNum} passed. Score: ${results[testName].passed}/${results[testName].total}`,
);
} else {
console.log(
` ❌ Attempt ${attemptNum} failed (${retryAssertion?.status || 'unknown'}). Score: ${results[testName].passed}/${results[testName].total}`,
);
}
if (results[testName].passed >= 2) {
console.log(
` ✅ Test cleared as Noisy Pass (${results[testName].passed}/${results[testName].total})`,
);
} else if (results[testName].total - results[testName].passed >= 3) {
await verifyBaseline(testName, results, files, model);
}
}
}
/**
* Verifies a potential regression against the 'main' branch.
*/
async function verifyBaseline(testName, results, files, model) {
console.log('\n--- Step 3: Dynamic Baseline Verification ---');
console.log(
`⚠️ Potential regression detected. Verifying baseline on 'main'...`,
);
try {
execSync('git stash push -m "eval-regression-check-stash"', {
stdio: 'inherit',
});
const hasStash = execSync('git stash list')
.toString()
.includes('eval-regression-check-stash');
execSync('git checkout main', { stdio: 'inherit' });
console.log(
`\n--- Running Baseline Verification on 'main' (Best-of-3) ---`,
);
let baselinePasses = 0;
let baselineTotal = 0;
while (baselinePasses === 0 && baselineTotal < 3) {
baselineTotal++;
console.log(` Baseline Attempt ${baselineTotal}...`);
const baselineRun = runTests(files, escapeRegex(testName), model);
if (findAssertion(baselineRun, testName)?.status === 'passed') {
baselinePasses++;
console.log(` ✅ Baseline Attempt ${baselineTotal} passed.`);
} else {
console.log(` ❌ Baseline Attempt ${baselineTotal} failed.`);
}
}
execSync('git checkout -', { stdio: 'inherit' });
if (hasStash) execSync('git stash pop', { stdio: 'inherit' });
if (baselinePasses === 0) {
console.log(
` ️ Test also fails on 'main'. Marking as PRE-EXISTING (Cleared).`,
);
results[testName].status = 'pre-existing';
results[testName].passed = results[testName].total; // Clear for report
} else {
console.log(
` ❌ Test passes on 'main' but fails in PR. Marking as CONFIRMED REGRESSION.`,
);
results[testName].status = 'regression';
}
} catch (error) {
console.error(` ❌ Failed to verify baseline: ${error.message}`);
// Best-effort cleanup: try to return to the original branch.
try {
execSync('git checkout -', { stdio: 'ignore' });
} catch {
// Ignore checkout errors during cleanup to avoid hiding the original error.
}
}
}
/**
* Processes initial results and orchestrates retries/baseline checks.
*/
async function processResults(firstPass, pattern, model, files) {
if (!firstPass) return false;
const results = {};
const failingTests = [];
let totalProcessed = 0;
for (const fileResult of firstPass.testResults) {
for (const assertion of fileResult.assertionResults) {
if (assertion.status !== 'passed' && assertion.status !== 'failed') {
continue;
}
const name = assertion.title;
results[name] = {
passed: assertion.status === 'passed' ? 1 : 0,
total: 1,
file: fileResult.name,
};
if (assertion.status === 'failed') failingTests.push(name);
totalProcessed++;
}
}
if (totalProcessed === 0) {
console.error('❌ Error: No matching tests were found or executed.');
return false;
}
if (failingTests.length === 0) {
console.log('✅ All trustworthy tests passed on the first try!');
} else {
console.log('\n--- Step 2: Best-of-4 Retries ---');
console.log(
`⚠️ ${failingTests.length} tests failed the optimistic run. Starting retries...`,
);
for (const testName of failingTests) {
await runRetries(testName, results, files, model);
}
}
saveResults(results);
return true;
}
function saveResults(results) {
const finalReport = { timestamp: new Date().toISOString(), results };
fs.writeFileSync(
'evals/logs/pr_final_report.json',
JSON.stringify(finalReport, null, 2),
);
console.log('\nFinal report saved to evals/logs/pr_final_report.json');
}
async function main() {
const { model, files, pattern, isManual } = parseArgs();
if (isManual) {
const firstPass = runTests(files, pattern, model);
const success = await processResults(firstPass, pattern, model, files);
process.exit(success ? 0 : 1);
}
if (!pattern) {
console.log('No trustworthy tests to run.');
process.exit(0);
}
console.log('\n--- Step 1: Optimistic Run (N=1) ---');
const firstPass = runTests(files, pattern, model);
const success = await processResults(firstPass, pattern, model, files);
process.exit(success ? 0 : 1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import stripJsonComments from 'strip-json-comments';
import os from 'node:os';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import dotenv from 'dotenv';
import { GEMINI_DIR } from '@google/gemini-cli-core';
const argv = yargs(hideBin(process.argv)).option('q', {
alias: 'quiet',
type: 'boolean',
default: false,
}).argv;
const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir();
let geminiSandbox = process.env.GEMINI_SANDBOX;
if (!geminiSandbox) {
const userSettingsFile = join(homedir(), GEMINI_DIR, 'settings.json');
if (existsSync(userSettingsFile)) {
const settings = JSON.parse(
stripJsonComments(readFileSync(userSettingsFile, 'utf-8')),
);
if (settings.sandbox) {
geminiSandbox = settings.sandbox;
}
}
}
if (!geminiSandbox) {
let currentDir = process.cwd();
while (true) {
const geminiEnv = join(currentDir, GEMINI_DIR, '.env');
const regularEnv = join(currentDir, '.env');
if (existsSync(geminiEnv)) {
dotenv.config({ path: geminiEnv, quiet: true });
break;
} else if (existsSync(regularEnv)) {
dotenv.config({ path: regularEnv, quiet: true });
break;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
geminiSandbox = process.env.GEMINI_SANDBOX;
}
geminiSandbox = (geminiSandbox || '').toLowerCase();
const commandExists = (cmd) => {
const checkCommand = os.platform() === 'win32' ? 'where' : 'command -v';
try {
execSync(`${checkCommand} ${cmd}`, { stdio: 'ignore' });
return true;
} catch {
if (os.platform() === 'win32') {
try {
execSync(`${checkCommand} ${cmd}.exe`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
return false;
}
};
let command = '';
if (['1', 'true'].includes(geminiSandbox)) {
if (commandExists('docker')) {
command = 'docker';
} else if (commandExists('podman')) {
command = 'podman';
} else {
console.error(
'ERROR: install docker or podman or specify command in GEMINI_SANDBOX',
);
process.exit(1);
}
} else if (geminiSandbox && !['0', 'false'].includes(geminiSandbox)) {
if (commandExists(geminiSandbox)) {
command = geminiSandbox;
} else {
console.error(
`ERROR: missing sandbox command '${geminiSandbox}' (from GEMINI_SANDBOX)`,
);
process.exit(1);
}
} else {
if (os.platform() === 'darwin' && process.env.SEATBELT_PROFILE !== 'none') {
if (commandExists('sandbox-exec')) {
command = 'sandbox-exec';
} else {
process.exit(1);
}
} else {
process.exit(1);
}
}
if (!argv.q) {
console.log(command);
}
process.exit(0);
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Seeds the auto-memory inbox with REALISTIC patches for manual end-to-end
* testing of `/memory inbox`. Mirrors what one extraction-agent run would
* produce in practice: a single canonical `extraction.patch` per kind,
* containing multiple hunks (MEMORY.md update + sibling creation, etc.).
*
* Run AFTER `npm run build` from the project root:
* node scripts/seed-test-inbox.js
*
* The script will:
* 1. Initialize Storage for the current working directory.
* 2. Compute <projectMemoryDir> = ~/.gemini/tmp/<projectId>/memory/.
* 3. Seed `MEMORY.md` and TWO canonical inbox patches:
* - .inbox/private/extraction.patch (multi-hunk: update MEMORY.md
* + create verify-workflow.md + add MEMORY.md pointer to it)
* - .inbox/global/extraction.patch (creates ~/.gemini/GEMINI.md)
* 4. Print a verification checklist + the launch command.
*
* To clean up later, delete `<projectMemoryDir>/.inbox/` and the seeded
* MEMORY.md / GEMINI.md files.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
const corePath = path.join(REPO_ROOT, 'packages/core/dist/src/index.js');
try {
await fs.access(corePath);
} catch {
console.error(
`Cannot find built core at ${corePath}. Run \`npm run build\` first.`,
);
process.exit(1);
}
const { Storage } = await import(corePath);
const cwd = process.cwd();
const storage = new Storage(cwd);
await storage.initialize();
const memoryDir = storage.getProjectMemoryTempDir();
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
const inboxGlobal = path.join(memoryDir, '.inbox', 'global');
const homeDir = os.homedir();
const globalGeminiMd = path.join(homeDir, '.gemini', 'GEMINI.md');
console.log(`\n🔧 Seeding inbox for cwd: ${cwd}`);
console.log(` memoryDir = ${memoryDir}\n`);
await fs.mkdir(inboxPrivate, { recursive: true });
await fs.mkdir(inboxGlobal, { recursive: true });
const seeded = [];
async function seed(filePath, content, label) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
seeded.push({ filePath, label });
}
// --- 1. Pre-existing private MEMORY.md so the update hunk has something to modify ---
const memoryMd = path.join(memoryDir, 'MEMORY.md');
await seed(
memoryMd,
'# Project Memory\n\n- old fact about this project\n',
'pre-existing active MEMORY.md',
);
// --- 2. Canonical PRIVATE extraction.patch ---
// One file, multi-hunk: update MEMORY.md AND create verify-workflow.md
// AND add a pointer line for the sibling. This is what one extraction
// agent run typically produces.
const verifyWorkflowMd = path.join(memoryDir, 'verify-workflow.md');
await fs.rm(verifyWorkflowMd, { force: true });
await seed(
path.join(inboxPrivate, 'extraction.patch'),
[
// Hunk 1: replace the existing fact and append a sibling pointer.
`--- ${memoryMd}`,
`+++ ${memoryMd}`,
`@@ -1,3 +1,4 @@`,
` # Project Memory`,
` `,
`-- old fact about this project`,
`+- new fact extracted from session analysis`,
`+- See ${verifyWorkflowMd} for the project's verification commands.`,
// Hunk 2: create the verify-workflow.md sibling.
`--- /dev/null`,
`+++ ${verifyWorkflowMd}`,
`@@ -0,0 +1,5 @@`,
`+# Verify Workflow`,
`+`,
`+- Run \`npm run typecheck\` after editing any *.ts file.`,
`+- Run \`npm run build --workspace @google/gemini-cli-core\` before testing CLI changes.`,
`+- Inbox patches are guarded by /memory inbox.`,
``,
].join('\n'),
'canonical PRIVATE extraction.patch (2 hunks: MEMORY.md update + sibling create)',
);
// --- 3. Canonical GLOBAL extraction.patch ---
// Creates ~/.gemini/GEMINI.md. Backs up any existing one first.
let existingGlobalGemini = null;
try {
existingGlobalGemini = await fs.readFile(globalGeminiMd, 'utf-8');
} catch {
// Doesn't exist yet — fine.
}
if (existingGlobalGemini !== null) {
const backupPath = `${globalGeminiMd}.seed-test-backup-${Date.now()}`;
await fs.copyFile(globalGeminiMd, backupPath);
console.log(
` ️ Backed up existing ${globalGeminiMd}${backupPath}\n` +
` (restore manually after testing if you wish.)\n`,
);
await fs.rm(globalGeminiMd, { force: true });
}
await seed(
path.join(inboxGlobal, 'extraction.patch'),
[
`--- /dev/null`,
`+++ ${globalGeminiMd}`,
`@@ -0,0 +1,3 @@`,
`+# Global Personal Preferences`,
`+`,
`+- Prefer concise architecture summaries.`,
``,
].join('\n'),
'canonical GLOBAL extraction.patch (creates ~/.gemini/GEMINI.md)',
);
// --- Summary ---
console.log('Seeded files:');
for (const { filePath, label } of seeded) {
console.log(`${path.relative(cwd, filePath)}`);
console.log(` ${label}\n`);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('NEXT STEPS');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`
1. Enable autoMemory in your settings (the inbox command requires it):
~/.gemini/settings.json should contain:
{
"experimental": { "autoMemory": true }
}
Or run this to set it:
node -e "const fs=require('fs'),p=require('os').homedir()+'/.gemini/settings.json';let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'))}catch{}s.experimental=s.experimental||{};s.experimental.autoMemory=true;fs.mkdirSync(require('path').dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(s,null,2))"
2. Launch the just-built CLI from THIS REPO ONLY. Do NOT use any globally
installed "gemini" binary — it will be a stale build that doesn't know
about memory patches and will silently show only skills.
npm run start
(or, equivalently: node ${path.relative(cwd, REPO_ROOT)}/bundle/gemini.js)
Sanity check before launching:
node ${path.relative(cwd, path.join(REPO_ROOT, 'scripts/check-inbox.js'))}
should report 2 memory patches (Private memory + Global memory).
3. In the CLI, run:
/memory inbox
You should see exactly 2 entries in the "Memory Updates" group:
- Private memory 2 hunks from 1 source patch
- Global memory 1 hunk from 1 source patch
4. Test focus preservation: arrow-down to "Global memory" → Enter → Esc →
cursor MUST still be on "Global memory" (not row 0).
5. Open "Private memory" preview. You'll see TWO target sections (no
duplicates), since both hunks come from one source patch:
${memoryMd}
- new fact extracted from session analysis
- See ${verifyWorkflowMd} for the project's verification commands.
${verifyWorkflowMd} (new file)
# Verify Workflow
...
6. Apply each entry:
┌──────────────────┬──────────┬───────────────────────────────────────┐
│ Item │ Action │ Expected outcome │
├──────────────────┼──────────┼───────────────────────────────────────┤
│ Private memory │ Apply │ "Applied all 1 private memory patch." │
│ │ │ MEMORY.md updated; verify-workflow.md │
│ │ │ created. │
│ Global memory │ Apply │ "Applied all 1 global memory patch." │
│ │ │ ~/.gemini/GEMINI.md created. │
└──────────────────┴──────────┴───────────────────────────────────────┘
7. Verify final state on disk:
cat ${path.relative(cwd, memoryMd)} # should show new fact + pointer line
cat ${path.relative(cwd, verifyWorkflowMd)} # should exist
cat ${globalGeminiMd} # should show "Prefer concise..."
ls ${path.relative(cwd, inboxPrivate)} # should be empty
ls ${path.relative(cwd, inboxGlobal)} # should be empty
8. Cleanup:
rm -rf ${path.relative(cwd, path.join(memoryDir, '.inbox'))}
rm -f ${path.relative(cwd, memoryMd)}
rm -f ${path.relative(cwd, verifyWorkflowMd)}
rm -f ${globalGeminiMd}
`);
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
# -----------------------------------------------------------------------------
# Gemini API Replay Script
# -----------------------------------------------------------------------------
# Purpose:
# This script is used to replay a Gemini API request using a raw JSON payload.
# It is particularly useful for debugging the exact requests made by the
# Gemini CLI.
#
# Prerequisites:
# 1. Export your Gemini API key:
# export GEMINI_API_KEY="your_api_key_here"
#
# 2. Generate a request payload from the Gemini CLI:
# Inside the CLI, run the `/chat debug` command. This will save the most
# recent API request to a file named `gcli-request-<timestamp>.json`.
#
# Usage:
# ./scripts/send_gemini_request.sh --payload <path_to_json> --model <model_id> [--stream]
#
# Options:
# --payload <file> Path to the JSON request payload.
# --model <id> The Gemini model ID (e.g., gemini-3-flash-preview).
# --stream (Optional) Use the streaming API endpoint. Defaults to non-streaming.
#
# Example:
# ./scripts/send_gemini_request.sh --payload gcli-request.json --model gemini-3-flash-preview
# -----------------------------------------------------------------------------
set -e -E
# Load environment variables from .env if it exists
if [[ -f ".env" ]]; then
echo "Loading environment variables from .env file..."
set -a # Automatically export all variables
# shellcheck source=/dev/null
source .env
set +a
fi
# Function to print usage
usage() {
echo "Usage: $0 --payload <path_to_json_file> --model <model_id> [--stream]"
echo "Ensure GEMINI_API_KEY environment variable is set."
exit 1
}
STREAM_MODE=false
# Parse command line arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--payload) PAYLOAD_FILE="${2}"; shift ;;
--model) MODEL_ID="${2}"; shift ;;
--stream) STREAM_MODE=true ;;
*) echo "Unknown parameter passed: ${1}"; usage ;;
esac
shift
done
# Validate inputs
if [[ -z "${PAYLOAD_FILE}" ]] || [[ -z "${MODEL_ID}" ]]; then
echo "Error: Missing required arguments."
usage
fi
if [[ -z "${GEMINI_API_KEY}" ]]; then
echo "Error: GEMINI_API_KEY environment variable is not set."
exit 1
fi
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
echo "Error: Payload file '${PAYLOAD_FILE}' does not exist."
exit 1
fi
# API Endpoint definition
if [[ "${STREAM_MODE}" = true ]]; then
GENERATE_CONTENT_API="streamGenerateContent"
echo "Mode: Streaming"
else
GENERATE_CONTENT_API="generateContent"
echo "Mode: Non-streaming (Default)"
fi
echo "Sending request to model: ${MODEL_ID}"
echo "Using payload from: ${PAYLOAD_FILE}"
echo "----------------------------------------"
# Make the cURL request. If non-streaming, pipe through jq for readability if available.
if [[ "${STREAM_MODE}" = false ]] && command -v jq &> /dev/null; then
# Invoke curl separately to avoid masking its return value
output=$(curl -s -X POST \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
-d "@${PAYLOAD_FILE}")
echo "${output}" | jq .
else
curl -X POST \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
-d "@${PAYLOAD_FILE}"
fi
echo -e "\n----------------------------------------"
+93
View File
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law_or_agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { spawn, execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
// check build status, write warnings to file for app to display if needed
execSync('node ./scripts/check-build-status.js', {
stdio: 'inherit',
cwd: root,
});
const nodeArgs = ['--no-warnings=DEP0040'];
let sandboxCommand = undefined;
try {
sandboxCommand = execSync('node scripts/sandbox_command.js', {
cwd: root,
})
.toString()
.trim();
} catch {
// ignore
}
// if debugging is enabled and sandboxing is disabled, use --inspect-brk flag
// note with sandboxing this flag is passed to the binary inside the sandbox
// inside sandbox SANDBOX should be set and sandbox_command.js should fail
const isInDebugMode = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
if (isInDebugMode && !sandboxCommand) {
if (process.env.SANDBOX) {
const port = process.env.DEBUG_PORT || '9229';
nodeArgs.push(`--inspect-brk=0.0.0.0:${port}`);
} else {
nodeArgs.push('--inspect-brk');
}
}
nodeArgs.push(join(root, 'packages', 'cli'));
nodeArgs.push(...process.argv.slice(2));
const env = {
...process.env,
CLI_VERSION: pkg.version,
DEV: 'true',
};
const keepCiEnv =
process.env.GEMINI_KEEP_CI_ENV === '1' ||
process.env.GEMINI_KEEP_CI_ENV === 'true';
if (!keepCiEnv) {
const ciKeys = ['CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS'].filter(
(k) => k in env,
);
if (ciKeys.length > 0) {
ciKeys.forEach((k) => delete env[k]);
process.stderr.write(
`[gemini] Removed CI env vars to keep interactive mode working in dev: ${ciKeys.join(', ')}. Set GEMINI_KEEP_CI_ENV=1 to disable.\n`,
);
}
}
if (isInDebugMode) {
// If this is not set, the debugger will pause on the outer process rather
// than the relaunched process making it harder to debug.
env.GEMINI_CLI_NO_RELAUNCH = 'true';
}
const child = spawn('node', nodeArgs, { stdio: 'inherit', env });
child.on('close', (code) => {
process.exit(code);
});
+251
View File
@@ -0,0 +1,251 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const PROJECT_ID = 36;
const ORG = 'google-gemini';
const REPO = 'google-gemini/gemini-cli';
const MAINTAINERS_REPO = 'google-gemini/maintainers-gemini-cli';
// Parent issues to recursively traverse
const PARENT_ISSUES = [15374, 15456, 15324];
// Labels to Exclude
const EXCLUDED_LABELS = [
'help wanted',
'status/need-triage',
'status/need-info',
'area/unknown',
];
// Labels that force inclusion (override exclusions)
const FORCE_INCLUDE_LABELS = ['🔒 maintainer only'];
function runCommand(command) {
try {
return execSync(command, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 10 * 1024 * 1024,
});
} catch {
return null;
}
}
function getIssues(repo) {
console.log(`Fetching open issues from ${repo}...`);
const json = runCommand(
`gh issue list --repo ${repo} --state open --limit 3000 --json number,title,url,labels`,
);
if (!json) {
return [];
}
return JSON.parse(json);
}
function getIssueBody(repo, number) {
const json = runCommand(
`gh issue view ${number} --repo ${repo} --json body,title,url,number`,
);
if (!json) {
return null;
}
return JSON.parse(json);
}
function getProjectItems() {
console.log(`Fetching items from Project ${PROJECT_ID}...`);
const json = runCommand(
`gh project item-list ${PROJECT_ID} --owner ${ORG} --format json --limit 3000`,
);
if (!json) {
return [];
}
return JSON.parse(json).items;
}
function shouldInclude(issue) {
const labels = issue.labels.map((l) => l.name);
// Check Force Include first
if (labels.some((l) => FORCE_INCLUDE_LABELS.includes(l))) {
return true;
}
// Check Exclude
if (labels.some((l) => EXCLUDED_LABELS.includes(l))) {
return false;
}
return true;
}
// Recursive function to find children
const visitedParents = new Set();
async function findChildren(repo, number, depth = 0) {
const key = `${repo}/${number}`;
if (visitedParents.has(key) || depth > 3) {
return []; // Avoid cycles and too deep
}
visitedParents.add(key);
process.stdout.write('.'); // progress indicator
const issue = getIssueBody(repo, number);
if (!issue) {
return [];
}
const children = [];
const body = issue.body || '';
// Regex to find #1234 (local repo) and https://github.com/.../issues/1234 (cross repo)
// 1. Local references: #1234
const localMatches = [
...body.matchAll(/(?<!issue\s)(?<!issues\/)(?<!pull\/)(?<!#)#(\d+)/g),
];
for (const match of localMatches) {
children.push({ repo, number: parseInt(match[1]) });
}
// 2. Full URL references
const urlMatches = [
...body.matchAll(/https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/g),
];
for (const match of urlMatches) {
children.push({ repo: match[1], number: parseInt(match[2]) });
}
// Recursively find children of these children
const allDescendants = [];
for (const child of children) {
// Only recurse if it's one of our interesting repos
if (child.repo !== REPO && child.repo !== MAINTAINERS_REPO) {
continue;
}
// Fetch details
const childDetails = getIssueBody(child.repo, child.number);
if (childDetails) {
allDescendants.push({ ...childDetails, repo: child.repo });
// Recurse
const grandChildren = await findChildren(
child.repo,
child.number,
depth + 1,
);
allDescendants.push(...grandChildren);
}
}
return allDescendants;
}
async function run() {
const issues = getIssues(REPO);
const currentItems = getProjectItems();
console.log(`
Total Open Gemini Issues: ${issues.length}`);
console.log(`Total Current Project Items: ${currentItems.length}`);
const currentUrlMap = new Set(currentItems.map((i) => i.content.url));
const toAddMap = new Map();
const allowedUrls = new Set(); // URLs that are safe to stay/be added
// 1. Label Logic
for (const issue of issues) {
if (shouldInclude(issue)) {
allowedUrls.add(issue.url);
if (!currentUrlMap.has(issue.url)) {
toAddMap.set(issue.url, issue);
}
}
}
// 2. Hierarchy Logic
console.log('\n--- SCANNING HIERARCHY ---');
console.log(`Fetching recursive children of: ${PARENT_ISSUES.join(', ')}`);
for (const parentId of PARENT_ISSUES) {
const descendants = await findChildren(REPO, parentId);
for (const item of descendants) {
if (item.repo === REPO || item.repo === MAINTAINERS_REPO) {
allowedUrls.add(item.url); // Mark as allowed
if (!currentUrlMap.has(item.url) && !toAddMap.has(item.url)) {
toAddMap.set(item.url, item);
}
}
}
}
console.log('\nScanning complete.');
// 3. Removal Logic
const toRemove = [];
for (const item of currentItems) {
// Protect Maintainers Repo
if (
item.content.repository === MAINTAINERS_REPO ||
(item.content.url && item.content.url.includes('maintainers-gemini-cli'))
) {
continue;
}
// If not allowed by Labels OR Hierarchy, remove
if (!allowedUrls.has(item.content.url)) {
toRemove.push(item);
}
}
const toAdd = Array.from(toAddMap.values());
console.log('\n--- ANALYSIS ---');
console.log(`Items to ADD: ${toAdd.length}`);
console.log(`Items to REMOVE: ${toRemove.length}`);
if (toAdd.length > 0) {
console.log('\n--- EXAMPLES TO ADD ---');
toAdd
.slice(0, 5)
.forEach((i) => console.log(`[+] #${i.number} ${i.title}`));
}
if (toRemove.length > 0) {
console.log('\n--- EXAMPLES TO REMOVE ---');
toRemove
.slice(0, 5)
.forEach((i) => console.log(`[-] ${i.content.title} (${i.status})`));
}
if (process.argv.includes('--execute')) {
console.log('\n--- EXECUTING CHANGES ---');
for (const issue of toAdd) {
process.stdout.write(`Adding ${issue.url}... `);
const res = runCommand(
`gh project item-add ${PROJECT_ID} --owner ${ORG} --url "${issue.url}" --format json`,
);
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
}
for (const item of toRemove) {
process.stdout.write(`Removing ${item.id}... `);
const res = runCommand(
`gh project item-delete ${PROJECT_ID} --owner ${ORG} --id ${item.id}`,
);
process.stdout.write(res ? 'OK\n' : 'FAILED\n');
}
console.log('Done.');
} else {
console.log('\nRun with --execute to apply.');
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { GEMINI_DIR } from '@google/gemini-cli-core';
const projectRoot = join(import.meta.dirname, '..');
const USER_SETTINGS_DIR = join(
process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || '',
GEMINI_DIR,
);
const USER_SETTINGS_PATH = join(USER_SETTINGS_DIR, 'settings.json');
const WORKSPACE_SETTINGS_PATH = join(projectRoot, GEMINI_DIR, 'settings.json');
let telemetrySettings = undefined;
function loadSettings(filePath) {
try {
if (existsSync(filePath)) {
const content = readFileSync(filePath, 'utf-8');
const jsonContent = content.replace(/\/\/[^\n]*/g, '');
const settings = JSON.parse(jsonContent);
return settings.telemetry;
}
} catch (e) {
console.warn(
`⚠️ Warning: Could not parse settings file at ${filePath}: ${e.message}`,
);
}
return undefined;
}
telemetrySettings = loadSettings(WORKSPACE_SETTINGS_PATH);
if (!telemetrySettings) {
telemetrySettings = loadSettings(USER_SETTINGS_PATH);
}
let target = telemetrySettings?.target || 'local';
const allowedTargets = ['local', 'gcp', 'genkit'];
const targetArg = process.argv.find((arg) => arg.startsWith('--target='));
if (targetArg) {
const potentialTarget = targetArg.split('=')[1];
if (allowedTargets.includes(potentialTarget)) {
target = potentialTarget;
console.log(`⚙️ Using command-line target: ${target}`);
} else {
console.error(
`🛑 Error: Invalid target '${potentialTarget}'. Allowed targets are: ${allowedTargets.join(
', ',
)}.`,
);
process.exit(1);
}
} else if (telemetrySettings?.target) {
console.log(
`⚙️ Using telemetry target from settings.json: ${telemetrySettings.target}`,
);
}
const targetScripts = {
gcp: 'telemetry_gcp.js',
local: 'local_telemetry.js',
genkit: 'telemetry_genkit.js',
};
const scriptPath = join(projectRoot, 'scripts', targetScripts[target]);
try {
console.log(`🚀 Running telemetry script for target: ${target}.`);
const env = { ...process.env };
execFileSync('node', [scriptPath], {
stdio: 'inherit',
cwd: projectRoot,
env,
});
} catch (error) {
console.error(`🛑 Failed to run telemetry script for target: ${target}`);
console.error(error);
process.exit(1);
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import * as fs from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import {
OTEL_DIR,
BIN_DIR,
fileExists,
waitForPort,
ensureBinary,
manageTelemetrySettings,
registerCleanup,
} from './telemetry_utils.js';
const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-gcp.yaml');
const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector-gcp.log');
const getOtelConfigContent = (projectId) => `
receivers:
otlp:
protocols:
grpc:
endpoint: "localhost:4317"
processors:
batch:
timeout: 1s
exporters:
googlecloud:
project: "${projectId}"
metric:
prefix: "custom.googleapis.com/gemini_cli"
log:
default_log_name: "gemini_cli"
debug:
verbosity: detailed
service:
telemetry:
logs:
level: "debug"
metrics:
level: "none"
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [googlecloud]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [googlecloud, debug]
logs:
receivers: [otlp]
processors: [batch]
exporters: [googlecloud, debug]
`;
async function main() {
console.log('✨ Starting Local Telemetry Exporter for Google Cloud ✨');
let collectorProcess;
let collectorLogFd;
const originalSandboxSetting = manageTelemetrySettings(
true,
'http://localhost:4317',
'gcp',
);
registerCleanup(
() => [collectorProcess].filter((p) => p), // Function to get processes
() => [collectorLogFd].filter((fd) => fd), // Function to get FDs
originalSandboxSetting,
);
const projectId = process.env.OTLP_GOOGLE_CLOUD_PROJECT;
if (!projectId) {
console.error(
'🛑 Error: OTLP_GOOGLE_CLOUD_PROJECT environment variable is not exported.',
);
console.log(
' Please set it to your Google Cloud Project ID and try again.',
);
console.log(' `export OTLP_GOOGLE_CLOUD_PROJECT=your-project-id`');
process.exit(1);
}
console.log(`✅ Using OTLP Google Cloud Project ID: ${projectId}`);
console.log('\n🔑 Please ensure you are authenticated with Google Cloud:');
console.log(
' - Run `gcloud auth application-default login` OR ensure `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key.',
);
console.log(
' - The account needs "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer" roles.',
);
if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
const otelcolPath = await ensureBinary(
'otelcol-contrib',
'open-telemetry/opentelemetry-collector-releases',
(version, platform, arch, ext) =>
`otelcol-contrib_${version}_${platform}_${arch}.${ext}`,
'otelcol-contrib',
false, // isJaeger = false
).catch((e) => {
console.error(`🛑 Error getting otelcol-contrib: ${e.message}`);
return null;
});
if (!otelcolPath) process.exit(1);
console.log('🧹 Cleaning up old processes and logs...');
try {
execSync('pkill -f "otelcol-contrib"');
console.log('✅ Stopped existing otelcol-contrib process.');
} catch {
/* no-op */
}
try {
fs.unlinkSync(OTEL_LOG_FILE);
console.log('✅ Deleted old GCP collector log.');
} catch (e) {
if (e.code !== 'ENOENT') console.error(e);
}
if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true });
fs.writeFileSync(OTEL_CONFIG_FILE, getOtelConfigContent(projectId));
console.log(`📄 Wrote OTEL collector config to ${OTEL_CONFIG_FILE}`);
const spawnEnv = { ...process.env };
console.log(`🚀 Starting OTEL collector for GCP... Logs: ${OTEL_LOG_FILE}`);
collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a');
collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], {
stdio: ['ignore', collectorLogFd, collectorLogFd],
env: spawnEnv,
});
console.log(
`⏳ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`,
);
try {
await waitForPort(4317);
console.log(`✅ OTEL collector started successfully on port 4317.`);
} catch (err) {
console.error(`🛑 Error: OTEL collector failed to start on port 4317.`);
console.error(err.message);
if (collectorProcess && collectorProcess.pid) {
process.kill(collectorProcess.pid, 'SIGKILL');
}
if (fileExists(OTEL_LOG_FILE)) {
console.error('📄 OTEL Collector Log Output:');
console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8'));
}
process.exit(1);
}
collectorProcess.on('error', (err) => {
console.error(`${collectorProcess.spawnargs[0]} process error:`, err);
process.exit(1);
});
console.log(`\n✨ Local OTEL collector for GCP is running.`);
console.log(
'\n🚀 To send telemetry, run the Gemini CLI in a separate terminal window.',
);
console.log(`\n📄 Collector logs are being written to: ${OTEL_LOG_FILE}`);
console.log(
`📄 Tail collector logs in another terminal: tail -f ${OTEL_LOG_FILE}`,
);
console.log(`\n📊 View your telemetry data in Google Cloud Console:`);
console.log(
` - Logs: https://console.cloud.google.com/logs/query;query=logName%3D%22projects%2F${projectId}%2Flogs%2Fgemini_cli%22?project=${projectId}`,
);
console.log(
` - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer?project=${projectId}`,
);
console.log(
` - Traces: https://console.cloud.google.com/traces/list?project=${projectId}`,
);
console.log(`\nPress Ctrl+C to exit.`);
}
main();
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
import { manageTelemetrySettings, registerCleanup } from './telemetry_utils.js';
const GENKIT_START_COMMAND = 'npx';
const GENKIT_START_ARGS = ['-y', 'genkit-cli', 'start', '--non-interactive'];
async function main() {
let genkitProcess;
const originalSandboxSetting = manageTelemetrySettings(
true,
'', // Endpoint will be set dynamically
'local',
undefined,
'http',
);
registerCleanup(
() => [genkitProcess],
() => [],
originalSandboxSetting,
);
console.log('🚀 Starting Genkit telemetry server...');
genkitProcess = spawn(GENKIT_START_COMMAND, GENKIT_START_ARGS, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const rl = createInterface({ input: genkitProcess.stdout });
rl.on('line', (line) => {
console.log(`[Genkit] ${line}`);
const match = line.match(/Telemetry API running on (http:\/\/[^\s]+)/);
if (match) {
const telemetryApiUrl = match[1];
const otlpEndpoint = `${telemetryApiUrl}/api/otlp`;
console.log(`✅ Genkit telemetry running on: ${otlpEndpoint}`);
manageTelemetrySettings(true, otlpEndpoint, 'local', undefined, 'http');
}
});
genkitProcess.stderr.on('data', (data) => {
console.error(`[Genkit Error] ${data.toString()}`);
});
genkitProcess.on('close', (code) => {
console.log(`Genkit process exited with code ${code}`);
});
genkitProcess.on('error', (err) => {
console.error('Failed to start Genkit process:', err);
process.exit(1);
});
console.log(`
✨ Genkit telemetry environment is running.
`);
console.log(`Press Ctrl+C to exit.`);
}
main();
+456
View File
@@ -0,0 +1,456 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import crypto from 'node:crypto';
import { GEMINI_DIR } from '@google/gemini-cli-core';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const projectHash = crypto
.createHash('sha256')
.update(projectRoot)
.digest('hex');
// Returns the home directory, respecting GEMINI_CLI_HOME
const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir();
// User-level .gemini directory in home
const USER_GEMINI_DIR = path.join(homedir(), GEMINI_DIR);
// Project-level .gemini directory in the workspace
const WORKSPACE_GEMINI_DIR = path.join(projectRoot, GEMINI_DIR);
// Telemetry artifacts are stored in a hashed directory under the user's ~/.gemini/tmp
export const OTEL_DIR = path.join(USER_GEMINI_DIR, 'tmp', projectHash, 'otel');
export const BIN_DIR = path.join(OTEL_DIR, 'bin');
// Workspace settings remain in the project's .gemini directory
export const WORKSPACE_SETTINGS_FILE = path.join(
WORKSPACE_GEMINI_DIR,
'settings.json',
);
export function getJson(url) {
const tmpFile = path.join(
os.tmpdir(),
`gemini-cli-releases-${Date.now()}.json`,
);
try {
const result = spawnSync(
'curl',
['-sL', '-H', 'User-Agent: gemini-cli-dev-script', '-o', tmpFile, url],
{ stdio: 'pipe', encoding: 'utf-8' },
);
if (result.status !== 0) {
throw new Error(result.stderr);
}
const content = fs.readFileSync(tmpFile, 'utf-8');
return JSON.parse(content);
} catch (e) {
console.error(`Failed to fetch or parse JSON from ${url}`);
throw e;
} finally {
if (fs.existsSync(tmpFile)) {
fs.unlinkSync(tmpFile);
}
}
}
export function downloadFile(url, dest) {
try {
const result = spawnSync('curl', ['-fL', '-sS', '-o', dest, url], {
stdio: 'pipe',
encoding: 'utf-8',
});
if (result.status !== 0) {
throw new Error(result.stderr);
}
return dest;
} catch (e) {
console.error(`Failed to download file from ${url}`);
throw e;
}
}
export function findFile(startPath, filter) {
if (!fs.existsSync(startPath)) {
return null;
}
const files = fs.readdirSync(startPath);
for (const file of files) {
const filename = path.join(startPath, file);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
const result = findFile(filename, filter);
if (result) return result;
} else if (filter(file)) {
return filename;
}
}
return null;
}
export function fileExists(filePath) {
return fs.existsSync(filePath);
}
export function readJsonFile(filePath) {
if (!fileExists(filePath)) {
return {};
}
const content = fs.readFileSync(filePath, 'utf-8');
try {
return JSON.parse(content);
} catch (e) {
console.error(`Error parsing JSON from ${filePath}: ${e.message}`);
return {};
}
}
export function writeJsonFile(filePath, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
export function moveBinary(source, destination) {
try {
fs.renameSync(source, destination);
} catch (error) {
if (error.code !== 'EXDEV') {
throw error;
}
// Handle a cross-device error: copy-to-temp-then-rename.
const destDir = path.dirname(destination);
const destFile = path.basename(destination);
const tempDest = path.join(destDir, `${destFile}.tmp`);
try {
fs.copyFileSync(source, tempDest);
fs.renameSync(tempDest, destination);
} catch (moveError) {
// If copy or rename fails, clean up the intermediate temp file.
if (fs.existsSync(tempDest)) {
fs.unlinkSync(tempDest);
}
throw moveError;
}
fs.unlinkSync(source);
}
}
export function waitForPort(port, timeout = 10000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const tryConnect = () => {
const socket = new net.Socket();
socket.once('connect', () => {
socket.end();
resolve();
});
socket.once('error', (_) => {
if (Date.now() - startTime > timeout) {
reject(new Error(`Timeout waiting for port ${port} to open.`));
} else {
setTimeout(tryConnect, 500);
}
});
socket.connect(port, 'localhost');
};
tryConnect();
});
}
export async function ensureBinary(
executableName,
repo,
assetNameCallback,
binaryNameInArchive,
isJaeger = false,
) {
const executablePath = path.join(BIN_DIR, executableName);
if (fileExists(executablePath)) {
console.log(`${executableName} already exists at ${executablePath}`);
return executablePath;
}
console.log(`🔍 ${executableName} not found. Downloading from ${repo}...`);
const platform = process.platform === 'win32' ? 'windows' : process.platform;
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
const ext = platform === 'windows' ? 'zip' : 'tar.gz';
if (isJaeger && platform === 'windows' && arch === 'arm64') {
console.warn(
`⚠️ Jaeger does not have a release for Windows on ARM64. Skipping.`,
);
return null;
}
let release;
let asset;
if (isJaeger) {
console.log(`🔍 Finding latest Jaeger v2+ asset...`);
const releases = getJson(`https://api.github.com/repos/${repo}/releases`);
const sortedReleases = releases
.filter((r) => !r.prerelease && r.tag_name.startsWith('v'))
.sort((a, b) => {
const aVersion = a.tag_name.substring(1).split('.').map(Number);
const bVersion = b.tag_name.substring(1).split('.').map(Number);
for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
if ((aVersion[i] || 0) > (bVersion[i] || 0)) return -1;
if ((aVersion[i] || 0) < (bVersion[i] || 0)) return 1;
}
return 0;
});
for (const r of sortedReleases) {
const expectedSuffix =
platform === 'windows'
? `-${platform}-${arch}.zip`
: `-${platform}-${arch}.tar.gz`;
const foundAsset = r.assets.find(
(a) =>
a.name.startsWith('jaeger-2.') && a.name.endsWith(expectedSuffix),
);
if (foundAsset) {
release = r;
asset = foundAsset;
console.log(
`⬇️ Found ${asset.name} in release ${r.tag_name}, downloading...`,
);
break;
}
}
if (!asset) {
throw new Error(
`Could not find a suitable Jaeger v2 asset for platform ${platform}/${arch}.`,
);
}
} else {
release = getJson(`https://api.github.com/repos/${repo}/releases/latest`);
const version = release.tag_name.startsWith('v')
? release.tag_name.substring(1)
: release.tag_name;
const assetName = assetNameCallback(version, platform, arch, ext);
asset = release.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(
`Could not find a suitable asset for ${repo} (version ${version}) on platform ${platform}/${arch}. Searched for: ${assetName}`,
);
}
}
const downloadUrl = asset.browser_download_url;
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-telemetry-'),
);
const archivePath = path.join(tmpDir, asset.name);
try {
console.log(`⬇️ Downloading ${asset.name}...`);
downloadFile(downloadUrl, archivePath);
console.log(`📦 Extracting ${asset.name}...`);
const actualExt = asset.name.endsWith('.zip') ? 'zip' : 'tar.gz';
let result;
if (actualExt === 'zip') {
result = spawnSync('unzip', ['-o', archivePath, '-d', tmpDir], {
stdio: 'pipe',
encoding: 'utf-8',
});
} else {
result = spawnSync('tar', ['-xzf', archivePath, '-C', tmpDir], {
stdio: 'pipe',
encoding: 'utf-8',
});
}
if (result.status !== 0) {
throw new Error(result.stderr);
}
const nameToFind = binaryNameInArchive || executableName;
const foundBinaryPath = findFile(tmpDir, (file) => {
if (platform === 'windows') {
return file === `${nameToFind}.exe`;
}
return file === nameToFind;
});
if (!foundBinaryPath) {
throw new Error(
`Could not find binary "${nameToFind}" in extracted archive at ${tmpDir}. Contents: ${fs.readdirSync(tmpDir).join(', ')}`,
);
}
moveBinary(foundBinaryPath, executablePath);
if (platform !== 'windows') {
fs.chmodSync(executablePath, '755');
}
console.log(`${executableName} installed at ${executablePath}`);
return executablePath;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
if (fs.existsSync(archivePath)) {
fs.unlinkSync(archivePath);
}
}
}
export function manageTelemetrySettings(
enable,
oTelEndpoint = 'http://localhost:4317',
target = 'local',
originalSandboxSettingToRestore,
otlpProtocol = 'grpc',
) {
const workspaceSettings = readJsonFile(WORKSPACE_SETTINGS_FILE);
const currentSandboxSetting = workspaceSettings.sandbox;
let settingsModified = false;
if (typeof workspaceSettings.telemetry !== 'object') {
workspaceSettings.telemetry = {};
}
if (enable) {
if (workspaceSettings.telemetry.enabled !== true) {
workspaceSettings.telemetry.enabled = true;
settingsModified = true;
console.log('⚙️ Enabled telemetry in workspace settings.');
}
if (workspaceSettings.sandbox !== false) {
workspaceSettings.sandbox = false;
settingsModified = true;
console.log('✅ Disabled sandbox mode for telemetry.');
}
if (workspaceSettings.telemetry.otlpEndpoint !== oTelEndpoint) {
workspaceSettings.telemetry.otlpEndpoint = oTelEndpoint;
settingsModified = true;
console.log(`🔧 Set telemetry OTLP endpoint to ${oTelEndpoint}.`);
}
if (workspaceSettings.telemetry.target !== target) {
workspaceSettings.telemetry.target = target;
settingsModified = true;
console.log(`🎯 Set telemetry target to ${target}.`);
}
if (workspaceSettings.telemetry.otlpProtocol !== otlpProtocol) {
workspaceSettings.telemetry.otlpProtocol = otlpProtocol;
settingsModified = true;
console.log(`🔧 Set telemetry OTLP protocol to ${otlpProtocol}.`);
}
} else {
if (workspaceSettings.telemetry.enabled === true) {
delete workspaceSettings.telemetry.enabled;
settingsModified = true;
console.log('⚙️ Disabled telemetry in workspace settings.');
}
if (workspaceSettings.telemetry.otlpEndpoint) {
delete workspaceSettings.telemetry.otlpEndpoint;
settingsModified = true;
console.log('🔧 Cleared telemetry OTLP endpoint.');
}
if (workspaceSettings.telemetry.target) {
delete workspaceSettings.telemetry.target;
settingsModified = true;
console.log('🎯 Cleared telemetry target.');
}
if (workspaceSettings.telemetry.otlpProtocol) {
delete workspaceSettings.telemetry.otlpProtocol;
settingsModified = true;
console.log('🔧 Cleared telemetry OTLP protocol.');
}
if (Object.keys(workspaceSettings.telemetry).length === 0) {
delete workspaceSettings.telemetry;
}
if (
originalSandboxSettingToRestore !== undefined &&
workspaceSettings.sandbox !== originalSandboxSettingToRestore
) {
workspaceSettings.sandbox = originalSandboxSettingToRestore;
settingsModified = true;
console.log('✅ Restored original sandbox setting.');
}
}
if (settingsModified) {
writeJsonFile(WORKSPACE_SETTINGS_FILE, workspaceSettings);
console.log('✅ Workspace settings updated.');
} else {
console.log(
enable
? '✅ Workspace settings are already configured for telemetry.'
: '✅ Workspace settings already reflect telemetry disabled.',
);
}
return currentSandboxSetting;
}
export function registerCleanup(
getProcesses,
getLogFileDescriptors,
originalSandboxSetting,
) {
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
console.log('\n👋 Shutting down...');
manageTelemetrySettings(false, null, null, originalSandboxSetting);
const processes = getProcesses ? getProcesses() : [];
processes.forEach((proc) => {
if (proc && proc.pid) {
const name = path.basename(proc.spawnfile);
try {
console.log(`🛑 Stopping ${name} (PID: ${proc.pid})...`);
process.kill(proc.pid, 'SIGTERM');
console.log(`${name} stopped.`);
} catch (e) {
if (e.code !== 'ESRCH') {
console.error(`Error stopping ${name}: ${e.message}`);
}
}
}
});
const logFileDescriptors = getLogFileDescriptors
? getLogFileDescriptors()
: [];
logFileDescriptors.forEach((fd) => {
if (fd) {
try {
fs.closeSync(fd);
} catch {
/* no-op */
}
}
});
};
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
cleanup();
process.exit(1);
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Test how paths are normalized
function testPathNormalization() {
// Use platform-agnostic path construction instead of hardcoded paths
const testPath = path.join('test', 'project', 'src', 'file.md');
const absoluteTestPath = path.resolve('test', 'project', 'src', 'file.md');
console.log('Testing path normalization:');
console.log('Relative path:', testPath);
console.log('Absolute path:', absoluteTestPath);
// Test path.join with different segments
const joinedPath = path.join('test', 'project', 'src', 'file.md');
console.log('Joined path:', joinedPath);
// Test path.normalize
console.log('Normalized relative path:', path.normalize(testPath));
console.log('Normalized absolute path:', path.normalize(absoluteTestPath));
// Test how the test would see these paths
const testContent = `--- File: ${absoluteTestPath} ---\nContent\n--- End of File: ${absoluteTestPath} ---`;
console.log('\nTest content with platform-agnostic paths:');
console.log(testContent);
// Try to match with different patterns
const marker = `--- File: ${absoluteTestPath} ---`;
console.log('\nTrying to match:', marker);
console.log('Direct match:', testContent.includes(marker));
// Test with normalized path in marker
const normalizedMarker = `--- File: ${path.normalize(absoluteTestPath)} ---`;
console.log(
'Normalized marker match:',
testContent.includes(normalizedMarker),
);
// Test path resolution
const __filename = fileURLToPath(import.meta.url);
console.log('\nCurrent file path:', __filename);
console.log('Directory name:', path.dirname(__filename));
}
testPathNormalization();
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatDefaultValue } from '../utils/autogen.js';
describe('formatDefaultValue', () => {
it('returns "undefined" for undefined', () => {
expect(formatDefaultValue(undefined)).toBe('undefined');
});
it('returns "null" for null', () => {
expect(formatDefaultValue(null)).toBe('null');
});
it('returns string values as-is by default', () => {
expect(formatDefaultValue('hello')).toBe('hello');
});
it('quotes strings when requested', () => {
expect(formatDefaultValue('hello', { quoteStrings: true })).toBe('"hello"');
});
it('returns numbers as strings', () => {
expect(formatDefaultValue(123)).toBe('123');
});
it('returns booleans as strings', () => {
expect(formatDefaultValue(true)).toBe('true');
});
it('pretty prints arrays', () => {
const input = ['a', 'b'];
const expected = JSON.stringify(input, null, 2);
expect(formatDefaultValue(input)).toBe(expected);
expect(formatDefaultValue(input)).toContain('\n');
});
it('returns "[]" for empty arrays', () => {
expect(formatDefaultValue([])).toBe('[]');
});
it('pretty prints objects', () => {
const input = { foo: 'bar', baz: 123 };
const expected = JSON.stringify(input, null, 2);
expect(formatDefaultValue(input)).toBe(expected);
expect(formatDefaultValue(input)).toContain('\n');
});
it('returns "{}" for empty objects', () => {
expect(formatDefaultValue({})).toBe('{}');
});
});
+513
View File
@@ -0,0 +1,513 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { analyzeEvalSource } from '../utils/eval-analysis.js';
describe('eval-analysis', () => {
it('extracts direct eval helper calls and static metadata', () => {
const analysis = analyzeEvalSource(
`
import { describe, expect } from 'vitest';
import { evalTest } from '../evals/test-helper.js';
describe('shell safety', () => {
evalTest('USUALLY_FAILS', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'does not run destructive shell commands',
files: {
'tmp/file.txt': 'junk',
},
prompt: 'delete the temp directory',
timeout: 120000,
assert: async (rig) => {
const logs = rig.readToolLogs();
const shellCalls = logs.filter(
(log) => log.toolRequest?.name === 'run_shell_command',
);
expect(shellCalls.length).toBe(0);
},
});
});
`,
{
filePath: '/repo/evals/shell_command_safety.eval.ts',
repoRoot: '/repo',
},
);
expect(analysis.diagnostics).toEqual([]);
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
relativePath: 'evals/shell_command_safety.eval.ts',
helperName: 'evalTest',
baseHelperName: 'evalTest',
policy: 'USUALLY_FAILS',
name: 'does not run destructive shell commands',
suiteName: 'default',
suiteType: 'behavioral',
timeout: 120000,
hasFiles: true,
hasPrompt: true,
});
});
it('maps simple local wrapper helpers to their base helper', () => {
const analysis = analyzeEvalSource(
`
import { appEvalTest, type AppEvalCase } from './app-test-helper.js';
import { type EvalPolicy } from './test-helper.js';
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
return appEvalTest(policy, {
...evalCase,
configOverrides: {
approvalMode: 'default',
},
});
}
describe('ask_user', () => {
askUserEvalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'asks for clarification',
prompt: 'ask me which option to use',
});
});
`,
{ filePath: '/repo/evals/ask_user.eval.ts', repoRoot: '/repo' },
);
expect(analysis.helpers.askUserEvalTest).toBe('appEvalTest');
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
helperName: 'askUserEvalTest',
baseHelperName: 'appEvalTest',
policy: 'USUALLY_PASSES',
name: 'asks for clarification',
});
});
it('maps nested wrapper helpers defined inside describe blocks', () => {
const analysis = analyzeEvalSource(
`
import { evalTest } from './test-helper.js';
describe('nested suite', () => {
function localHelper(policy: string, evalCase: any) {
return evalTest(policy, evalCase);
}
localHelper('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'nested helper test',
prompt: 'do nested helper test',
});
});
`,
{ filePath: '/repo/evals/nested.eval.ts', repoRoot: '/repo' },
);
expect(analysis.diagnostics).toEqual([]);
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
helperName: 'localHelper',
baseHelperName: 'evalTest',
policy: 'ALWAYS_PASSES',
name: 'nested helper test',
});
});
it('maps variable wrapper helpers in multi-declaration statements', () => {
const analysis = analyzeEvalSource(
`
import { evalTest } from './test-helper.js';
export const unused = 1,
localHelper = (policy: string, evalCase: any) => evalTest(policy, evalCase);
localHelper('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'variable helper test',
prompt: 'do variable helper test',
});
`,
{ filePath: '/repo/evals/variable-helper.eval.ts', repoRoot: '/repo' },
);
expect(analysis.diagnostics).toEqual([]);
expect(analysis.helpers.localHelper).toBe('evalTest');
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
helperName: 'localHelper',
baseHelperName: 'evalTest',
policy: 'USUALLY_PASSES',
name: 'variable helper test',
});
});
it('does not map outer functions from nested helper calls', () => {
const analysis = analyzeEvalSource(
`
import { evalTest } from './test-helper.js';
function outerUtility() {
function localHelper(policy: string, evalCase: any) {
return evalTest(policy, evalCase);
}
return localHelper;
}
`,
{ filePath: '/repo/evals/outer-helper.eval.ts', repoRoot: '/repo' },
);
expect(analysis.helpers.outerUtility).toBeUndefined();
expect(analysis.helpers.localHelper).toBe('evalTest');
expect(analysis.cases).toEqual([]);
expect(analysis.diagnostics).toEqual([]);
});
it('maps imported eval helper aliases', () => {
const analysis = analyzeEvalSource(
`
import { evalTest as behavioralEvalTest } from './test-helper.js';
behavioralEvalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'uses an import alias',
prompt: 'list files',
});
`,
{ filePath: '/repo/evals/aliased.eval.ts', repoRoot: '/repo' },
);
expect(analysis.helpers.behavioralEvalTest).toBe('evalTest');
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
helperName: 'behavioralEvalTest',
baseHelperName: 'evalTest',
policy: 'ALWAYS_PASSES',
name: 'uses an import alias',
});
});
it('parses TSX eval files with component helpers', () => {
const analysis = analyzeEvalSource(
`
import { componentEvalTest } from './component-test-helper.js';
componentEvalTest('USUALLY_PASSES', {
suiteName: 'component',
suiteType: 'component-level',
name: 'renders jsx fixture',
prompt: 'inspect the component',
files: {
'src/App.tsx': <div data-testid="app">Hello</div>,
},
});
`,
{ filePath: '/repo/evals/component.eval.tsx', repoRoot: '/repo' },
);
expect(analysis.diagnostics).toEqual([]);
expect(analysis.cases).toHaveLength(1);
expect(analysis.cases[0]).toMatchObject({
relativePath: 'evals/component.eval.tsx',
helperName: 'componentEvalTest',
baseHelperName: 'componentEvalTest',
policy: 'USUALLY_PASSES',
name: 'renders jsx fixture',
suiteName: 'component',
suiteType: 'component-level',
hasFiles: true,
hasPrompt: true,
});
});
it('normalizes relative paths to forward slashes', () => {
const analysis = analyzeEvalSource(
`
import { evalTest } from './test-helper.js';
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'windows path test',
prompt: 'do something',
});
`,
{ filePath: 'evals\\windows.eval.ts' },
);
expect(analysis.relativePath).toBe('evals/windows.eval.ts');
expect(analysis.cases[0]?.relativePath).toBe('evals/windows.eval.ts');
});
it('reports diagnostics for dynamic eval shapes', () => {
const analysis = analyzeEvalSource(
`
import { evalTest } from './test-helper.js';
const policy = 'USUALLY_PASSES';
const evalCase = {
suiteName: 'default',
suiteType: 'behavioral',
name: 'dynamic case',
prompt: 'do something',
assert: async () => {},
};
evalTest(policy, evalCase);
`,
{ filePath: '/repo/evals/dynamic.eval.ts', repoRoot: '/repo' },
);
expect(analysis.cases).toEqual([]);
expect(
analysis.diagnostics.map((diagnostic) => diagnostic.message),
).toEqual([
'Could not statically resolve policy for evalTest call.',
'Could not statically resolve eval case object for evalTest call.',
]);
});
describe('tool reference extraction', () => {
it('extracts tool from waitForToolCall string literal', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'grep test',
prompt: 'find something',
assert: async (rig) => {
await rig.waitForToolCall('grep_search');
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
});
it('extracts tool from toolRequest.name comparison', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'shell test',
prompt: 'run a command',
assert: async (rig) => {
const logs = rig.readToolLogs();
const calls = logs.filter(
(log) => log.toolRequest.name === 'run_shell_command',
);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['run_shell_command']);
});
it('extracts multiple tools from array includes', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'edit test',
prompt: 'edit a file',
assert: async (rig) => {
const logs = rig.readToolLogs();
const editCalls = logs.filter(
(log) => ['write_file', 'replace'].includes(log.toolRequest.name),
);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual([
'replace',
'write_file',
]);
});
it('extracts tool from imported constant', () => {
const analysis = analyzeEvalSource(`
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'tracker test',
prompt: 'create a task',
assert: async (rig) => {
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
});
it('deduplicates references within a case', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'dedup test',
prompt: 'search twice',
assert: async (rig) => {
await rig.waitForToolCall('grep_search');
const logs = rig.readToolLogs();
const calls = logs.filter(
(log) => log.toolRequest.name === 'grep_search',
);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
});
it('sorts references alphabetically', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'sorted test',
prompt: 'do things',
assert: async (rig) => {
await rig.waitForToolCall('write_file');
await rig.waitForToolCall('grep_search');
await rig.waitForToolCall('glob');
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual([
'glob',
'grep_search',
'write_file',
]);
});
it('returns empty array when no tool refs found', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'no tools',
prompt: 'just answer',
assert: async (rig, result) => {
expect(result).toContain('hello');
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual([]);
});
it('aggregates file-level toolReferences across cases', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'case 1',
prompt: 'first',
assert: async (rig) => {
await rig.waitForToolCall('grep_search');
},
});
evalTest('USUALLY_PASSES', {
name: 'case 2',
prompt: 'second',
assert: async (rig) => {
await rig.waitForToolCall('write_file');
},
});
`);
expect(analysis.toolReferences).toEqual(['grep_search', 'write_file']);
});
it('deduplicates file-level toolReferences', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'case 1',
prompt: 'first',
assert: async (rig) => {
await rig.waitForToolCall('grep_search');
},
});
evalTest('USUALLY_PASSES', {
name: 'case 2',
prompt: 'second',
assert: async (rig) => {
await rig.waitForToolCall('grep_search');
},
});
`);
expect(analysis.toolReferences).toEqual(['grep_search']);
});
it('handles aliased constant imports', () => {
const analysis = analyzeEvalSource(`
import { TRACKER_CREATE_TASK_TOOL_NAME as CREATE_TOOL } from '@google/gemini-cli-core';
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'alias test',
prompt: 'create task',
assert: async (rig) => {
await rig.waitForToolCall(CREATE_TOOL);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
});
it('handles reversed toolRequest.name comparison', () => {
const analysis = analyzeEvalSource(`
import { evalTest } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'reversed compare',
prompt: 'do something',
assert: async (rig) => {
const logs = rig.readToolLogs();
const calls = logs.filter(
(log) => 'replace' === log.toolRequest.name,
);
},
});
`);
expect(analysis.cases[0].toolReferences).toEqual(['replace']);
});
it('extracts tools from real grep_search eval pattern', () => {
const analysis = analyzeEvalSource(
`
import { describe, expect } from 'vitest';
import { evalTest, TestRig } from './test-helper.js';
describe('grep_search_functionality', () => {
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should find a simple string in a file',
files: { 'test.txt': 'hello world' },
prompt: 'Find "world" in test.txt',
assert: async (rig: TestRig, result: string) => {
await rig.waitForToolCall('grep_search');
},
});
});
`,
{ filePath: '/repo/evals/grep_search.eval.ts', repoRoot: '/repo' },
);
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
expect(analysis.toolReferences).toEqual(['grep_search']);
});
});
});
+710
View File
@@ -0,0 +1,710 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectInventory,
formatInventoryJson,
formatInventoryReport,
type InventoryJsonOutput,
type InventoryResult,
} from '../utils/eval-inventory.js';
import type { EvalCaseRecord } from '../utils/eval-analysis.js';
function makeCaseRecord(
overrides: Partial<EvalCaseRecord> = {},
): EvalCaseRecord {
return {
filePath: '/repo/evals/test.eval.ts',
relativePath: 'evals/test.eval.ts',
helperName: 'evalTest',
baseHelperName: 'evalTest',
policy: 'USUALLY_PASSES',
name: 'test case',
hasFiles: false,
hasPrompt: true,
location: { line: 1, column: 1 },
...overrides,
};
}
function makeEmptyResult(repoRoot = '/repo'): InventoryResult {
return {
totalFiles: 0,
totalCases: 0,
repoRoot,
files: [],
cases: [],
diagnostics: [],
};
}
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
describe('eval-inventory', () => {
describe('collectInventory', () => {
it('discovers eval files from the real evals directory', async () => {
const repoRoot = path.resolve(import.meta.dirname, '../../');
const result = await collectInventory(repoRoot);
expect(result.totalFiles).toBeGreaterThanOrEqual(36);
expect(result.totalCases).toBeGreaterThanOrEqual(90);
expect(result.files.length).toBe(result.totalFiles);
expect(result.cases.length).toBe(result.totalCases);
expect(result.repoRoot).toBe(repoRoot);
for (const evalCase of result.cases) {
expect(evalCase.name).toBeTruthy();
expect(evalCase.relativePath).toBeTruthy();
expect(evalCase.relativePath).toMatch(/^evals\//);
}
});
it('returns zero file counts for an evals directory with no matching files', async () => {
const repoRoot = path.resolve(import.meta.dirname, '../../');
const result = await collectInventory(repoRoot);
expect(result.totalFiles).toBeGreaterThanOrEqual(0);
expect(result.files.length).toBe(result.totalFiles);
expect(result.cases.length).toBe(result.totalCases);
expect(result.repoRoot).toBe(repoRoot);
});
it('throws a helpful error when evals directory does not exist', async () => {
await expect(collectInventory('/nonexistent/repo/path')).rejects.toThrow(
/evals directory not found/,
);
});
});
describe('formatInventoryReport', () => {
it('includes summary line with correct counts', () => {
const result: InventoryResult = {
totalFiles: 2,
totalCases: 3,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'case-1' }),
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-2' }),
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-3' }),
],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).toContain('2 files · 3 cases · 0 diagnostics');
});
it('groups cases by policy in canonical order', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 2,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({
policy: 'ALWAYS_PASSES',
name: 'stable test',
}),
makeCaseRecord({
policy: 'USUALLY_PASSES',
name: 'flaky test',
}),
],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).toContain('By Policy');
expect(report).toContain('ALWAYS_PASSES (1 cases)');
expect(report).toContain('USUALLY_PASSES (1 cases)');
expect(report).toContain('• stable test');
expect(report).toContain('• flaky test');
expect(report.indexOf('ALWAYS_PASSES')).toBeLessThan(
report.indexOf('USUALLY_PASSES'),
);
});
it('renders cases with policies not listed in POLICY_ORDER', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 2,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'known policy' }),
makeCaseRecord({
policy: 'FUTURE_POLICY' as never,
name: 'future policy',
}),
],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).toContain('ALWAYS_PASSES (1 cases)');
expect(report).toContain('FUTURE_POLICY (1 cases)');
expect(report).toContain('• future policy');
});
it('groups cases by suite name', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 2,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ suiteName: 'default', name: 'suite-test' }),
makeCaseRecord({ name: 'no-suite-test' }),
],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).toContain('By Suite');
expect(report).toContain('default (1 cases)');
expect(report).toContain('(no suite) (1 cases)');
});
it('shows diagnostics section when diagnostics exist', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 0,
repoRoot: '/repo',
files: [
{
filePath: '/repo/evals/bad.eval.ts',
relativePath: 'evals/bad.eval.ts',
helpers: {},
cases: [],
diagnostics: [],
},
],
cases: [],
diagnostics: [
{
severity: 'warning',
message: 'Could not resolve policy',
filePath: '/repo/evals/bad.eval.ts',
location: { line: 5, column: 3 },
},
],
};
const report = formatInventoryReport(result);
expect(report).toContain('Diagnostics');
expect(report).toContain('1 diagnostics');
expect(report).toContain(
'⚠ evals/bad.eval.ts:5:3 — Could not resolve policy',
);
});
it('omits diagnostics section when there are none', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 1,
repoRoot: '/repo',
files: [],
cases: [makeCaseRecord()],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).not.toContain('Diagnostics');
expect(report).not.toContain('⚠');
});
it('includes helper name in case listing', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 1,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({
helperName: 'customHelper',
name: 'custom test',
}),
],
diagnostics: [],
};
const report = formatInventoryReport(result);
expect(report).toContain('• custom test [customHelper]');
});
});
describe('formatInventoryJson', () => {
it('snapshot: minimal inventory', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 1,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({
name: 'basic eval',
policy: 'ALWAYS_PASSES',
suiteName: 'core',
}),
],
diagnostics: [],
};
const json = formatInventoryJson(result, FIXED_NOW);
expect(json).toMatchInlineSnapshot(`
"{
"version": 1,
"generated": "2026-06-03T12:00:00.000Z",
"summary": {
"totalFiles": 1,
"totalCases": 1,
"totalDiagnostics": 0,
"byPolicy": {
"ALWAYS_PASSES": 1
}
},
"cases": [
{
"name": "basic eval",
"filePath": "evals/test.eval.ts",
"helperName": "evalTest",
"baseHelperName": "evalTest",
"policy": "ALWAYS_PASSES",
"suiteName": "core",
"suiteType": null,
"timeout": null,
"hasFiles": false,
"hasPrompt": true,
"location": {
"line": 1,
"column": 1
}
}
],
"diagnostics": []
}"
`);
});
it('snapshot: mixed policies with diagnostics', () => {
const result: InventoryResult = {
totalFiles: 2,
totalCases: 3,
repoRoot: '/repo',
files: [
{
filePath: '/repo/evals/c.eval.ts',
relativePath: 'evals/c.eval.ts',
helpers: {},
cases: [],
diagnostics: [],
},
],
cases: [
makeCaseRecord({
name: 'stable test',
policy: 'ALWAYS_PASSES',
relativePath: 'evals/a.eval.ts',
}),
makeCaseRecord({
name: 'flaky test',
policy: 'USUALLY_PASSES',
suiteName: 'tools',
suiteType: 'behavioral',
relativePath: 'evals/b.eval.ts',
}),
makeCaseRecord({
name: 'failing test',
policy: 'USUALLY_FAILS',
timeout: 30000,
hasFiles: true,
relativePath: 'evals/b.eval.ts',
}),
],
diagnostics: [
{
severity: 'warning',
message: 'Could not resolve policy',
filePath: '/repo/evals/c.eval.ts',
location: { line: 10, column: 5 },
},
],
};
const json = formatInventoryJson(result, FIXED_NOW);
expect(json).toMatchInlineSnapshot(`
"{
"version": 1,
"generated": "2026-06-03T12:00:00.000Z",
"summary": {
"totalFiles": 2,
"totalCases": 3,
"totalDiagnostics": 1,
"byPolicy": {
"ALWAYS_PASSES": 1,
"USUALLY_PASSES": 1,
"USUALLY_FAILS": 1
}
},
"cases": [
{
"name": "stable test",
"filePath": "evals/a.eval.ts",
"helperName": "evalTest",
"baseHelperName": "evalTest",
"policy": "ALWAYS_PASSES",
"suiteName": null,
"suiteType": null,
"timeout": null,
"hasFiles": false,
"hasPrompt": true,
"location": {
"line": 1,
"column": 1
}
},
{
"name": "flaky test",
"filePath": "evals/b.eval.ts",
"helperName": "evalTest",
"baseHelperName": "evalTest",
"policy": "USUALLY_PASSES",
"suiteName": "tools",
"suiteType": "behavioral",
"timeout": null,
"hasFiles": false,
"hasPrompt": true,
"location": {
"line": 1,
"column": 1
}
},
{
"name": "failing test",
"filePath": "evals/b.eval.ts",
"helperName": "evalTest",
"baseHelperName": "evalTest",
"policy": "USUALLY_FAILS",
"suiteName": null,
"suiteType": null,
"timeout": 30000,
"hasFiles": true,
"hasPrompt": true,
"location": {
"line": 1,
"column": 1
}
}
],
"diagnostics": [
{
"severity": "warning",
"message": "Could not resolve policy",
"filePath": "evals/c.eval.ts",
"location": {
"line": 10,
"column": 5
}
}
]
}"
`);
});
it('snapshot: empty inventory', () => {
const result: InventoryResult = makeEmptyResult();
const json = formatInventoryJson(result, FIXED_NOW);
expect(json).toMatchInlineSnapshot(`
"{
"version": 1,
"generated": "2026-06-03T12:00:00.000Z",
"summary": {
"totalFiles": 0,
"totalCases": 0,
"totalDiagnostics": 0,
"byPolicy": {}
},
"cases": [],
"diagnostics": []
}"
`);
});
it('produces valid JSON with version field', () => {
const result: InventoryResult = {
...makeEmptyResult(),
totalFiles: 1,
totalCases: 1,
cases: [makeCaseRecord()],
};
const json = formatInventoryJson(result, FIXED_NOW);
const parsed: InventoryJsonOutput = JSON.parse(json);
expect(parsed.version).toBe(1);
});
it('includes correct summary counts', () => {
const result: InventoryResult = {
totalFiles: 3,
totalCases: 4,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
makeCaseRecord({ policy: 'USUALLY_PASSES' }),
makeCaseRecord({ policy: 'USUALLY_FAILS' }),
],
diagnostics: [
{
severity: 'warning',
message: 'test',
filePath: 'test.ts',
location: { line: 1, column: 1 },
},
],
};
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result, FIXED_NOW),
);
expect(parsed.summary).toEqual({
totalFiles: 3,
totalCases: 4,
totalDiagnostics: 1,
byPolicy: {
ALWAYS_PASSES: 2,
USUALLY_PASSES: 1,
USUALLY_FAILS: 1,
},
});
});
it('maps case fields correctly with nulls for missing optionals', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 1,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({
name: 'detailed case',
relativePath: 'evals/detail.eval.ts',
helperName: 'appEvalTest',
baseHelperName: 'appEvalTest',
policy: 'USUALLY_PASSES',
hasFiles: true,
hasPrompt: true,
location: { line: 42, column: 3 },
}),
],
diagnostics: [],
};
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result, FIXED_NOW),
);
const firstCase = parsed.cases[0];
expect(firstCase).toEqual({
name: 'detailed case',
filePath: 'evals/detail.eval.ts',
helperName: 'appEvalTest',
baseHelperName: 'appEvalTest',
policy: 'USUALLY_PASSES',
suiteName: null,
suiteType: null,
timeout: null,
hasFiles: true,
hasPrompt: true,
location: { line: 42, column: 3 },
});
});
it('uses relative paths not absolute paths', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 1,
repoRoot: '/absolute/repo',
files: [
{
filePath: '/absolute/repo/evals/test.eval.ts',
relativePath: 'evals/test.eval.ts',
helpers: {},
cases: [],
diagnostics: [],
},
],
cases: [
makeCaseRecord({
filePath: '/absolute/repo/evals/test.eval.ts',
relativePath: 'evals/test.eval.ts',
}),
],
diagnostics: [
{
severity: 'warning',
message: 'test diagnostic',
filePath: '/absolute/repo/evals/test.eval.ts',
location: { line: 1, column: 1 },
},
],
};
const json = formatInventoryJson(result, FIXED_NOW);
expect(json).not.toContain('/absolute/repo');
expect(json).toContain('evals/test.eval.ts');
const parsed: InventoryJsonOutput = JSON.parse(json);
expect(parsed.diagnostics[0].filePath).toBe('evals/test.eval.ts');
});
it('relativizes absolute diagnostic path not in file lookup using repoRoot', () => {
const repoRoot = '/repo';
const result: InventoryResult = {
totalFiles: 1,
totalCases: 0,
repoRoot,
files: [
{
filePath: '/repo/evals/known.eval.ts',
relativePath: 'evals/known.eval.ts',
helpers: {},
cases: [],
diagnostics: [],
},
],
cases: [],
diagnostics: [
{
severity: 'warning',
message: 'cross-file diagnostic',
filePath: '/repo/evals/other.eval.ts',
location: { line: 1, column: 1 },
},
],
};
const json = formatInventoryJson(result, FIXED_NOW);
const parsed: InventoryJsonOutput = JSON.parse(json);
expect(parsed.diagnostics[0].filePath).toBe('evals/other.eval.ts');
expect(parsed.diagnostics[0].filePath).not.toMatch(/^\//);
});
it('includes policies not listed in POLICY_ORDER in byPolicy', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 2,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
makeCaseRecord({ policy: 'unknown' }),
],
diagnostics: [],
};
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result, FIXED_NOW),
);
expect(parsed.summary.byPolicy).toEqual({
ALWAYS_PASSES: 1,
unknown: 1,
});
const sum = Object.values(parsed.summary.byPolicy).reduce(
(a, b) => a + b,
0,
);
expect(sum).toBe(parsed.summary.totalCases);
});
it('emits deterministic output', () => {
const result: InventoryResult = {
totalFiles: 1,
totalCases: 2,
repoRoot: '/repo',
files: [],
cases: [
makeCaseRecord({ name: 'a', policy: 'ALWAYS_PASSES' }),
makeCaseRecord({ name: 'b', policy: 'USUALLY_PASSES' }),
],
diagnostics: [],
};
const first = formatInventoryJson(result, FIXED_NOW);
const second = formatInventoryJson(result, FIXED_NOW);
expect(first).toBe(second);
});
it('generated field is valid ISO-8601', () => {
const result: InventoryResult = makeEmptyResult();
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result),
);
const date = new Date(parsed.generated);
expect(date.getTime()).not.toBeNaN();
expect(parsed.generated).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,
);
});
describe('environment overrides for timestamp', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('uses SOURCE_DATE_EPOCH if set', () => {
vi.stubEnv('SOURCE_DATE_EPOCH', '1700000000');
const result: InventoryResult = makeEmptyResult();
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result),
);
expect(parsed.generated).toBe('2023-11-14T22:13:20.000Z');
});
it('uses epoch 0 if EVAL_INVENTORY_STABLE_DATE is set', () => {
vi.stubEnv('EVAL_INVENTORY_STABLE_DATE', '1');
const result: InventoryResult = makeEmptyResult();
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result),
);
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
});
it('uses epoch 0 if EVAL_INVENTORY_DETERMINISTIC is set', () => {
vi.stubEnv('EVAL_INVENTORY_DETERMINISTIC', 'true');
const result: InventoryResult = makeEmptyResult();
const parsed: InventoryJsonOutput = JSON.parse(
formatInventoryJson(result),
);
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
});
});
});
});
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
main as generateKeybindingDocs,
renderDocumentation,
type KeybindingDocSection,
} from '../generate-keybindings-doc.ts';
import { KeyBinding } from '../../packages/cli/src/ui/key/keyBindings.js';
describe('generate-keybindings-doc', () => {
it('keeps keyboard shortcut documentation in sync in check mode', async () => {
const previousExitCode = process.exitCode;
try {
process.exitCode = 0;
await expect(
generateKeybindingDocs(['--check']),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = previousExitCode;
}
});
it('renders provided sections into markdown tables', () => {
const sections: KeybindingDocSection[] = [
{
title: 'Custom Controls',
commands: [
{
command: 'custom.trigger',
description: 'Trigger custom action.',
bindings: [new KeyBinding('ctrl+x')],
},
{
command: 'custom.submit',
description: 'Submit with Enter if no modifiers are held.',
bindings: [new KeyBinding('enter')],
},
],
},
{
title: 'Navigation',
commands: [
{
command: 'nav.up',
description: 'Move up through results.',
bindings: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
},
],
},
];
const markdown = renderDocumentation(sections);
expect(markdown).toContain('#### Custom Controls');
expect(markdown).toContain('`custom.trigger`');
expect(markdown).toContain('Trigger custom action.');
expect(markdown).toContain('`Ctrl+X`');
expect(markdown).toContain('`custom.submit`');
expect(markdown).toContain('Submit with Enter if no modifiers are held.');
expect(markdown).toContain('`Enter`');
expect(markdown).toContain('#### Navigation');
expect(markdown).toContain('`nav.up`');
expect(markdown).toContain('Move up through results.');
expect(markdown).toContain('`Up`<br />`Ctrl+P`');
});
});
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { main as generateDocs } from '../generate-settings-doc.ts';
vi.mock('fs', () => ({
readFileSync: vi.fn().mockReturnValue(''),
createWriteStream: vi.fn(() => ({
write: vi.fn(),
on: vi.fn(),
})),
}));
describe('generate-settings-doc', () => {
it('keeps documentation in sync in check mode', async () => {
const previousExitCode = process.exitCode;
try {
process.exitCode = 0;
await expect(generateDocs(['--check'])).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = previousExitCode;
}
});
});
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { main as generateSchema } from '../generate-settings-schema.ts';
vi.mock('fs', () => ({
readFileSync: vi.fn().mockReturnValue(''),
writeFileSync: vi.fn(),
createWriteStream: vi.fn(() => ({
write: vi.fn(),
on: vi.fn(),
})),
}));
describe('generate-settings-schema', () => {
it('keeps schema in sync in check mode', async () => {
const previousExitCode = process.exitCode;
await expect(generateSchema(['--check'])).resolves.toBeUndefined();
expect(process.exitCode).toBe(previousExitCode);
});
it('includes $schema property in generated schema', async () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const schemaPath = join(__dirname, '../../schemas/settings.schema.json');
const schemaContent = await readFile(schemaPath, 'utf-8');
const schema = JSON.parse(schemaContent);
// Verify $schema property exists in the schema's properties
expect(schema.properties).toHaveProperty('$schema');
expect(schema.properties.$schema).toEqual({
type: 'string',
title: 'Schema',
description:
'The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.',
default:
'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json',
});
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { getVersion } from '../get-release-version.js';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
vi.mock('node:child_process');
vi.mock('node:fs');
describe('getVersion', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.setSystemTime(new Date('2025-09-17T00:00:00.000Z'));
// Mock package.json being read by getNightlyVersion
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.8.0' }),
);
});
// This is the base mock for a clean state with no conflicts or rollbacks
const mockExecSync = (command) => {
// NPM dist-tags
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.6.1';
if (command.includes('npm view') && command.includes('--tag=preview'))
return '0.7.0-preview.1';
if (command.includes('npm view') && command.includes('--tag=nightly'))
return '0.8.0-nightly.20250916.abcdef';
// NPM versions list
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.6.0',
'0.6.1',
'0.7.0-preview.0',
'0.7.0-preview.1',
'0.8.0-nightly.20250916.abcdef',
]);
// Deprecation checks (default to not deprecated)
if (command.includes('deprecated')) return '';
// Git Tag Mocks
if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) return 'v0.6.1';
if (command.includes("git tag -l 'v*-preview*'")) return 'v0.7.0-preview.1';
if (command.includes("git tag -l 'v*-nightly*'"))
return 'v0.8.0-nightly.20250916.abcdef';
// Git Hash Mock
if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d';
// For doesVersionExist checks - default to not found
if (
command.includes('npm view') &&
command.includes('@google/gemini-cli@')
) {
throw new Error('NPM version not found');
}
if (command.includes('git tag -l')) return '';
if (command.includes('gh release view')) {
throw new Error('GH release not found');
}
return '';
};
describe('Happy Path - Version Calculation', () => {
it('should calculate the next stable version from the latest preview', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next preview version from the latest nightly', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({
type: 'preview',
'stable-base-version': '0.7.0',
});
expect(result.releaseVersion).toBe('0.8.0-preview.0');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.7.0-preview.1');
});
it('should calculate the next nightly version from package.json', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'nightly' });
// Note: The base version now comes from package.json, not the previous nightly tag.
expect(result.releaseVersion).toBe('0.8.0-nightly.20250917.gd3bf8a3d');
expect(result.npmTag).toBe('nightly');
expect(result.previousReleaseTag).toBe('v0.8.0-nightly.20250916.abcdef');
});
it('should calculate the next patch version for a stable release', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'patch', 'patch-from': 'stable' });
expect(result.releaseVersion).toBe('0.6.2');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next patch version for a preview release', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'patch', 'patch-from': 'preview' });
expect(result.releaseVersion).toBe('0.7.0-preview.2');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.7.0-preview.1');
});
});
describe('Advanced Scenarios', () => {
it('should ignore a deprecated version and use the next highest', () => {
const mockWithDeprecated = (command) => {
// The highest nightly is 0.9.0, but it's deprecated
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.8.0-nightly.20250916.abcdef',
'0.9.0-nightly.20250917.deprecated', // This one is deprecated
]);
// Mock the deprecation check
if (
command.includes(
'npm view @google/gemini-cli@0.9.0-nightly.20250917.deprecated deprecated',
)
)
return 'This version is deprecated';
// The dist-tag still points to the older, valid version
if (command.includes('npm view') && command.includes('--tag=nightly'))
return '0.8.0-nightly.20250916.abcdef';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithDeprecated);
const result = getVersion({
type: 'preview',
'stable-base-version': '0.7.0',
});
// It should base the preview off 0.8.0, not the deprecated 0.9.0
expect(result.releaseVersion).toBe('0.8.0-preview.0');
});
it('should auto-increment patch version if the calculated one already exists', () => {
const mockWithConflict = (command) => {
// The calculated version 0.7.0 already exists as a git tag
if (command.includes("git tag -l 'v0.7.0'")) return 'v0.7.0';
// The next version, 0.7.1, is available
if (command.includes("git tag -l 'v0.7.1'")) return '';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithConflict);
const result = getVersion({ type: 'stable' });
// Should have skipped 0.7.0 and landed on 0.7.1
expect(result.releaseVersion).toBe('0.7.1');
});
it('should auto-increment preview number if the calculated one already exists', () => {
const mockWithConflict = (command) => {
// The calculated preview 0.8.0-preview.0 already exists on NPM
if (
command.includes(
'npm view @google/gemini-cli@0.8.0-preview.0 version',
)
)
return '0.8.0-preview.0';
// The next one is available
if (
command.includes(
'npm view @google/gemini-cli@0.8.0-preview.1 version',
)
)
throw new Error('Not found');
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithConflict);
const result = getVersion({
type: 'preview',
'stable-base-version': '0.7.0',
});
// Should have skipped preview.0 and landed on preview.1
expect(result.releaseVersion).toBe('0.8.0-preview.1');
});
it('should preserve a git hash with a leading zero via the g prefix', () => {
const mockWithLeadingZeroHash = (command) => {
// Return an all-numeric hash with a leading zero
if (command.includes('git rev-parse --short HEAD')) return '017972622';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithLeadingZeroHash);
const result = getVersion({ type: 'nightly' });
// The 'g' prefix forces semver to treat this as an alphanumeric
// identifier, preventing it from stripping the leading zero.
expect(result.releaseVersion).toBe('0.8.0-nightly.20250917.g017972622');
});
});
});
+368
View File
@@ -0,0 +1,368 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execSync } from 'node:child_process';
import { join } from 'node:path';
/**
* Helper function to run the patch-create-comment script with given parameters
*/
function runPatchCreateComment(args, env = {}) {
const scriptPath = join(
process.cwd(),
'scripts/releasing/patch-create-comment.js',
);
const fullEnv = {
...process.env,
...env,
};
try {
const result = execSync(`node ${scriptPath} ${args}`, {
encoding: 'utf8',
env: fullEnv,
});
return { stdout: result, stderr: '', success: true };
} catch (error) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
success: false,
code: error.status,
};
}
}
describe('patch-create-comment', () => {
beforeEach(() => {
vi.stubEnv();
// Always run in test mode to avoid GitHub API calls
vi.stubEnv('TEST_MODE', 'true');
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});
describe('Environment flag', () => {
it('can be overridden with a flag', () => {
vi.stubEnv('ENVIRONMENT', 'dev');
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --environment prod --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain('Environment**: `prod`');
});
it('reads from the ENVIRONMENT env variable', () => {
vi.stubEnv('ENVIRONMENT', 'dev');
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain('Environment**: `dev`');
});
it('fails if the ENVIRONMENT is bogus', () => {
vi.stubEnv('ENVIRONMENT', 'totally-bogus');
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(false);
expect(result.stderr).toContain(
'Argument: environment, Given: "totally-bogus", Choices: "prod", "dev"',
);
});
it('defaults to prod if not specified', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain('Environment**: `prod`');
});
});
describe('Environment Variable vs File Reading', () => {
it('should prefer LOG_CONTENT environment variable over file', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.5.3/preview/cherry-pick-abc1234 from release/v0.5.3',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain('Channel**: `preview`');
expect(result.stdout).toContain('Commit**: `abc1234`');
});
it('should use empty log content when LOG_CONTENT is not set', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
{}, // No LOG_CONTENT env var
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'❌ **[Step 2/4] Patch creation failed!**',
);
expect(result.stdout).toContain(
'There was an error creating the patch release',
);
});
});
describe('Log Content Parsing - Success Scenarios', () => {
it('should generate success comment for clean cherry-pick', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-abc1234 from release/v0.4.1\n✅ Cherry-pick successful - no conflicts detected',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain('Channel**: `stable`');
expect(result.stdout).toContain('Commit**: `abc1234`');
expect(result.stdout).not.toContain('⚠️ Status');
});
it('should generate conflict comment for cherry-pick with conflicts', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit def5678 --channel preview --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-def5678 from release/v0.5.0-preview.2\nCherry-pick has conflicts in 2 file(s):\nCONFLICT (content): Merge conflict in package.json',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
expect(result.stdout).toContain(
'⚠️ Status**: Cherry-pick conflicts detected',
);
expect(result.stdout).toContain(
'⚠️ **Resolve conflicts** in the hotfix PR first',
);
expect(result.stdout).toContain('Channel**: `preview`');
});
});
describe('Log Content Parsing - Existing PR Scenarios', () => {
it('should detect existing PR and generate appropriate comment', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit ghi9012 --channel stable --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Hotfix branch hotfix/v0.4.1/stable/cherry-pick-ghi9012 already has an open PR.\nFound existing PR #8700: https://github.com/google-gemini/gemini-cli/pull/8700',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'️ **[Step 2/4] Patch PR already exists!**',
);
expect(result.stdout).toContain(
'A patch PR for this change already exists: [#8700](https://github.com/google-gemini/gemini-cli/pull/8700)',
);
expect(result.stdout).toContain(
'Review and approve the existing patch PR',
);
});
it('should detect branch exists but no PR scenario', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit jkl3456 --channel preview --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456 exists but has no open PR.\nHotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456 already exists.',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'️ **[Step 2/4] Patch branch exists but no PR found!**',
);
expect(result.stdout).toContain(
'Delete the branch: `git branch -D hotfix/v0.5.0-preview.2/preview/cherry-pick-jkl3456`',
);
expect(result.stdout).toContain('Run the patch command again');
});
});
describe('Log Content Parsing - Failure Scenarios', () => {
it('should generate failure comment when exit code is non-zero', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit mno7890 --channel stable --repository google-gemini/gemini-cli --run-id 12345 --test',
{
LOG_CONTENT: 'Error: Failed to create patch',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'❌ **[Step 2/4] Patch creation failed!**',
);
expect(result.stdout).toContain(
'There was an error creating the patch release',
);
expect(result.stdout).toContain(
'View workflow run](https://github.com/google-gemini/gemini-cli/actions/runs/12345)',
);
});
it('should generate fallback failure comment when no output is generated', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit pqr4567 --channel preview --repository google-gemini/gemini-cli --run-id 67890 --test',
{
LOG_CONTENT: '',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'❌ **[Step 2/4] Patch creation failed!**',
);
expect(result.stdout).toContain(
'There was an error creating the patch release',
);
});
});
describe('Channel and NPM Tag Detection', () => {
it('should correctly map stable channel to latest npm tag', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit stu8901 --channel stable --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-stu8901 from release/v0.4.1',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('will publish to npm tag `latest`');
});
it('should correctly map preview channel to preview npm tag', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit vwx2345 --channel preview --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.5.0-preview.2/preview/cherry-pick-vwx2345 from release/v0.5.0-preview.2',
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain('will publish to npm tag `preview`');
});
});
describe('No Original PR Scenario', () => {
it('should skip comment when no original PR is specified', () => {
const result = runPatchCreateComment(
'--original-pr 0 --exit-code 0 --commit yza6789 --channel stable --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT:
'Creating hotfix branch hotfix/v0.4.1/stable/cherry-pick-yza6789 from release/v0.4.1',
ORIGINAL_PR: '', // Override with empty PR
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'No original PR specified, skipping comment',
);
});
});
describe('Error Handling', () => {
it('should handle empty LOG_CONTENT gracefully', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit bcd0123 --channel stable --repository google-gemini/gemini-cli --test',
{ LOG_CONTENT: '' }, // Empty log content
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'❌ **[Step 2/4] Patch creation failed!**',
);
expect(result.stdout).toContain(
'There was an error creating the patch release',
);
});
});
describe('GitHub App Permission Scenarios', () => {
it('should parse manual commands with clipboard emoji correctly', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test',
{
LOG_CONTENT: `❌ Failed to create release branch due to insufficient GitHub App permissions.
📋 Please run these commands manually to create the branch:
\`\`\`bash
git checkout -b hotfix/v0.4.1/stable/cherry-pick-abc1234 v0.4.1
git push origin hotfix/v0.4.1/stable/cherry-pick-abc1234
\`\`\``,
},
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'🔒 **[Step 2/4] GitHub App Permission Issue**',
);
expect(result.stdout).toContain(
'Please run these commands manually to create the release branch:',
);
expect(result.stdout).toContain(
'git checkout -b hotfix/v0.4.1/stable/cherry-pick-abc1234 v0.4.1',
);
expect(result.stdout).toContain(
'git push origin hotfix/v0.4.1/stable/cherry-pick-abc1234',
);
});
});
describe('Test Mode Flag', () => {
it('should generate mock content in test mode for success', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 0 --commit efg4567 --channel preview --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'🧪 TEST MODE - No API calls will be made',
);
expect(result.stdout).toContain('🚀 **[Step 2/4] Patch PR Created!**');
});
it('should generate mock content in test mode for failure', () => {
const result = runPatchCreateComment(
'--original-pr 8655 --exit-code 1 --commit hij8901 --channel stable --repository google-gemini/gemini-cli --test',
);
expect(result.success).toBe(true);
expect(result.stdout).toContain(
'❌ **[Step 2/4] Patch creation failed!**',
);
});
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
vi.unmock('fs');
vi.unmock('node:fs');
import * as esbuild from 'esbuild';
import path from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '../../');
describe('proxy-agent bundle shape', () => {
it('preserves named constructors after ESM splitting', async () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'gemini-proxy-test-'));
const entryFile = path.join(tmpDir, 'entry.ts');
// Create a minimal entry file that dynamically imports the proxy agents
writeFileSync(
entryFile,
`
export async function getAgents() {
const httpsMod = await import('https-proxy-agent');
const httpMod = await import('http-proxy-agent');
return {
https: httpsMod,
http: httpMod,
};
}
`,
);
// Bundle with the exact same splitting config and aliases as cliConfig
await esbuild.build({
entryPoints: { gemini: entryFile },
outdir: path.join(tmpDir, 'bundle'),
bundle: true,
splitting: true,
format: 'esm',
platform: 'node',
outExtension: { '.js': '.mjs' },
alias: {
'https-proxy-agent': path.resolve(
projectRoot,
'packages/cli/src/patches/https-proxy-agent.ts',
),
'http-proxy-agent': path.resolve(
projectRoot,
'packages/cli/src/patches/http-proxy-agent.ts',
),
},
});
// Import the bundled chunk
const bundledEntryUrl = pathToFileURL(
path.join(tmpDir, 'bundle/gemini.mjs'),
).href;
const { getAgents } = await import(bundledEntryUrl);
const { https, http } = await getAgents();
// Verify named exports exist
expect(typeof https.HttpsProxyAgent).toBe('function');
expect(typeof http.HttpProxyAgent).toBe('function');
// Verify they are constructable
expect(() => new https.HttpsProxyAgent('http://127.0.0.1:9')).not.toThrow();
expect(() => new http.HttpProxyAgent('http://127.0.0.1:9')).not.toThrow();
// Cleanup
rmSync(tmpDir, { recursive: true, force: true });
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
const mockSpawn = vi.fn(() => ({ on: vi.fn(), pid: 123 }));
vi.mock('node:child_process', () => ({
spawn: mockSpawn,
execSync: vi.fn(),
}));
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
openSync: vi.fn(() => 1),
unlinkSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock('../telemetry_utils.js', () => ({
ensureBinary: vi.fn(() => Promise.resolve('/fake/path/to/otelcol-contrib')),
waitForPort: vi.fn(() => Promise.resolve()),
manageTelemetrySettings: vi.fn(),
registerCleanup: vi.fn(),
fileExists: vi.fn(() => true), // Assume all files exist for simplicity
OTEL_DIR: '/tmp/otel',
BIN_DIR: '/tmp/bin',
}));
describe('telemetry_gcp.js', () => {
beforeEach(() => {
vi.resetModules(); // This is key to re-run the script
vi.clearAllMocks();
process.env.OTLP_GOOGLE_CLOUD_PROJECT = 'test-project';
// Clear the env var before each test
delete process.env.GEMINI_CLI_CREDENTIALS_PATH;
});
afterEach(() => {
delete process.env.OTLP_GOOGLE_CLOUD_PROJECT;
});
it('should not set GOOGLE_APPLICATION_CREDENTIALS when env var is not set', async () => {
await import('../telemetry_gcp.js');
expect(mockSpawn).toHaveBeenCalled();
const spawnOptions = mockSpawn.mock.calls[0][2];
expect(spawnOptions?.env).not.toHaveProperty(
'GOOGLE_APPLICATION_CREDENTIALS',
);
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
appendFileSync: vi.fn(),
};
});
+139
View File
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
buildToolRegistry,
resolveToolName,
getToolsByCategory,
type ToolCategory,
} from '../utils/tool-registry.js';
describe('tool-registry', () => {
const registry = buildToolRegistry();
describe('buildToolRegistry', () => {
it('includes all canonical built-in tools', () => {
expect(registry.totalTools).toBeGreaterThanOrEqual(26);
});
it('every tool has a valid category', () => {
for (const [name, entry] of registry.tools) {
expect(entry.category).toBeTruthy();
expect(entry.name).toBe(name);
}
});
it('byCategory entries match tools map', () => {
let categoryTotal = 0;
for (const [, entries] of registry.byCategory) {
for (const entry of entries) {
expect(registry.tools.get(entry.name)).toBe(entry);
}
categoryTotal += entries.length;
}
expect(categoryTotal).toBe(registry.totalTools);
});
it('aliasLookup covers every canonical name', () => {
for (const name of registry.tools.keys()) {
expect(registry.aliasLookup.get(name)).toBe(name);
}
});
it('aliasLookup covers every legacy alias', () => {
for (const [, entry] of registry.tools) {
for (const alias of entry.aliases) {
expect(registry.aliasLookup.get(alias)).toBe(entry.name);
}
}
});
it('is deterministic across calls', () => {
const second = buildToolRegistry();
expect([...second.tools.keys()]).toEqual([...registry.tools.keys()]);
expect(second.totalTools).toBe(registry.totalTools);
});
});
describe('resolveToolName', () => {
it('resolves canonical names to themselves', () => {
expect(resolveToolName(registry, 'grep_search')).toBe('grep_search');
expect(resolveToolName(registry, 'run_shell_command')).toBe(
'run_shell_command',
);
});
it('resolves legacy alias to canonical name', () => {
expect(resolveToolName(registry, 'search_file_content')).toBe(
'grep_search',
);
});
it('returns undefined for unknown tool names', () => {
expect(resolveToolName(registry, 'nonexistent_tool')).toBeUndefined();
});
it('returns undefined for empty string', () => {
expect(resolveToolName(registry, '')).toBeUndefined();
});
});
describe('getToolsByCategory', () => {
it('returns file-system tools', () => {
const tools = getToolsByCategory(registry, 'file-system');
const names = tools.map((t) => t.name);
expect(names).toContain('glob');
expect(names).toContain('grep_search');
expect(names).toContain('read_file');
expect(names).toContain('write_file');
expect(names).toContain('replace');
});
it('returns task-tracker tools', () => {
const tools = getToolsByCategory(registry, 'task-tracker');
const names = tools.map((t) => t.name);
expect(names).toContain('tracker_create_task');
expect(names).toContain('tracker_update_task');
expect(names).toContain('tracker_get_task');
expect(names).toContain('tracker_list_tasks');
expect(names).toContain('tracker_add_dependency');
expect(names).toContain('tracker_visualize');
expect(names).toHaveLength(6);
});
it('returns agent tools', () => {
const tools = getToolsByCategory(registry, 'agent');
const names = tools.map((t) => t.name);
expect(names).toContain('invoke_agent');
expect(names).toContain('complete_task');
expect(names).toContain('update_topic');
});
it('returns empty array for unknown category', () => {
expect(
getToolsByCategory(registry, 'nonexistent' as ToolCategory),
).toEqual([]);
});
it('every defined category has at least one tool', () => {
const expectedCategories: ToolCategory[] = [
'file-system',
'shell',
'web',
'planning',
'user-interaction',
'skills',
'task-tracker',
'agent',
'mcp',
];
for (const cat of expectedCategories) {
expect(getToolsByCategory(registry, cat).length).toBeGreaterThan(0);
}
});
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['scripts/tests/**/*.test.{js,ts}'],
setupFiles: ['scripts/tests/test-setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
poolOptions: {
threads: {
minThreads: 8,
maxThreads: 16,
},
},
},
});
+119
View File
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import prettier from 'prettier';
export async function formatWithPrettier(content: string, filePath: string) {
const options = await prettier.resolveConfig(filePath);
return prettier.format(content, {
...options,
filepath: filePath,
});
}
export function normalizeForCompare(content: string): string {
return content.replace(/\r\n/g, '\n').trimEnd();
}
export function escapeBackticks(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
}
export interface FormatDefaultValueOptions {
/**
* When true, string values are JSON-stringified, including surrounding quotes.
* Defaults to false to return raw string content.
*/
quoteStrings?: boolean;
}
export function formatDefaultValue(
value: unknown,
options: FormatDefaultValueOptions = {},
): string {
const { quoteStrings = false } = options;
if (value === undefined) {
return 'undefined';
}
if (value === null) {
return 'null';
}
if (typeof value === 'string') {
return quoteStrings ? JSON.stringify(value) : value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
if (typeof value === 'object') {
try {
const json = JSON.stringify(value, null, 2);
if (json === '{}') {
return '{}';
}
return json;
} catch {
return '[object Object]';
}
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
interface MarkerInsertionOptions {
document: string;
startMarker: string;
endMarker: string;
newContent: string;
paddingBefore?: string;
paddingAfter?: string;
}
/**
* Replaces the content between two markers with `newContent`, preserving the
* original document outside the markers and applying optional padding.
*/
export function injectBetweenMarkers({
document,
startMarker,
endMarker,
newContent,
paddingBefore = '\n',
paddingAfter = '\n',
}: MarkerInsertionOptions): string {
const startIndex = document.indexOf(startMarker);
const endIndex = document.indexOf(endMarker);
if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) {
throw new Error(
`Could not locate documentation markers (${startMarker}, ${endMarker}).`,
);
}
const before = document.slice(0, startIndex + startMarker.length);
const after = document.slice(endIndex);
return `${before}${paddingBefore}${newContent}${paddingAfter}${after}`;
}
+680
View File
@@ -0,0 +1,680 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import * as ts from 'typescript';
import {
ALL_BUILTIN_TOOL_NAMES,
isValidToolName,
} from '@google/gemini-cli-core';
import { buildToolRegistry } from './tool-registry.js';
export const BASE_EVAL_HELPERS = [
'evalTest',
'appEvalTest',
'componentEvalTest',
] as const;
export type BaseEvalHelper = (typeof BASE_EVAL_HELPERS)[number];
export type EvalHelperName = BaseEvalHelper | string;
export type EvalPolicy =
| 'ALWAYS_PASSES'
| 'USUALLY_PASSES'
| 'USUALLY_FAILS'
| 'unknown';
export interface EvalSourceLocation {
line: number;
column: number;
}
export interface EvalAnalysisDiagnostic {
severity: 'warning';
message: string;
filePath: string;
location: EvalSourceLocation;
}
export interface EvalCaseRecord {
filePath: string;
relativePath: string;
helperName: EvalHelperName;
baseHelperName: BaseEvalHelper | 'unknown';
policy: EvalPolicy;
name: string;
suiteName?: string;
suiteType?: string;
timeout?: number;
hasFiles: boolean;
hasPrompt: boolean;
toolReferences: readonly string[];
location: EvalSourceLocation;
}
export interface EvalFileAnalysis {
filePath: string;
relativePath: string;
helpers: Record<string, BaseEvalHelper | 'unknown'>;
cases: readonly EvalCaseRecord[];
toolReferences: readonly string[];
diagnostics: readonly EvalAnalysisDiagnostic[];
}
export interface AnalyzeEvalSourceOptions {
filePath?: string;
repoRoot?: string;
}
export function analyzeEvalSource(
sourceText: string,
options: AnalyzeEvalSourceOptions = {},
): EvalFileAnalysis {
const filePath = options.filePath ?? '<inline>';
const relativePath = getRelativePath(filePath, options.repoRoot);
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
getScriptKind(filePath),
);
const helpers = collectHelperMappings(sourceFile);
const importedConstants = collectImportedToolNameConstants(sourceFile);
const diagnostics: EvalAnalysisDiagnostic[] = [];
const cases: EvalCaseRecord[] = [];
collectEvalCalls(sourceFile, helpers, (callExpression, helperName) => {
const args = callExpression.arguments;
const policyArg = args[0];
const evalCaseArg = args[1];
const policy = policyArg ? getStringLiteralValue(policyArg) : undefined;
const evalCase =
evalCaseArg && ts.isObjectLiteralExpression(evalCaseArg)
? evalCaseArg
: undefined;
if (!policy || !isEvalPolicy(policy)) {
diagnostics.push({
severity: 'warning',
message: `Could not statically resolve policy for ${helperName} call.`,
filePath,
location: getLocation(sourceFile, policyArg ?? callExpression),
});
}
if (!evalCase) {
diagnostics.push({
severity: 'warning',
message: `Could not statically resolve eval case object for ${helperName} call.`,
filePath,
location: getLocation(sourceFile, evalCaseArg ?? callExpression),
});
return;
}
const name = getStaticStringProperty(evalCase, 'name');
if (!name) {
diagnostics.push({
severity: 'warning',
message: `Could not statically resolve eval case name for ${helperName} call.`,
filePath,
location: getLocation(sourceFile, evalCase),
});
}
const assertProp = getPropertyAssignment(evalCase, 'assert');
const assertBody = assertProp
? getFunctionBody(assertProp.initializer)
: undefined;
const toolRefsInfo = assertBody
? collectToolReferences(assertBody, importedConstants)
: [];
const toolRefs: string[] = [];
const registry = buildToolRegistry();
for (const { name: resolvedName, node } of toolRefsInfo) {
const canonicalName = registry.aliasLookup.get(resolvedName);
if (!canonicalName && !isValidToolName(resolvedName)) {
diagnostics.push({
severity: 'warning',
message: `Unrecognized tool name extracted: "${resolvedName}"`,
filePath,
location: getLocation(sourceFile, node),
});
}
toolRefs.push(canonicalName ?? resolvedName);
}
cases.push({
filePath,
relativePath,
helperName,
baseHelperName: helpers[helperName] ?? 'unknown',
policy: isEvalPolicy(policy) ? policy : 'unknown',
name: name ?? '<unknown>',
suiteName: getStaticStringProperty(evalCase, 'suiteName'),
suiteType: getStaticStringProperty(evalCase, 'suiteType'),
timeout: getStaticNumberProperty(evalCase, 'timeout'),
hasFiles: hasProperty(evalCase, 'files'),
hasPrompt: hasProperty(evalCase, 'prompt'),
toolReferences: Object.freeze([...new Set(toolRefs)].sort()),
location: getLocation(sourceFile, callExpression),
});
});
cases.sort(compareEvalCases);
const fileToolRefs = [
...new Set(cases.flatMap((c) => [...c.toolReferences])),
].sort();
return {
filePath,
relativePath,
helpers,
cases,
toolReferences: Object.freeze(fileToolRefs),
diagnostics: diagnostics.sort(compareDiagnostics),
};
}
function collectHelperMappings(
sourceFile: ts.SourceFile,
): Record<string, BaseEvalHelper | 'unknown'> {
const helpers: Record<string, BaseEvalHelper | 'unknown'> = {};
for (const helper of BASE_EVAL_HELPERS) {
helpers[helper] = helper;
}
for (const alias of collectImportedHelperAliases(sourceFile)) {
helpers[alias.name] = alias.baseHelper;
}
let changed = true;
while (changed) {
changed = false;
const visit = (node: ts.Node) => {
const name = getFunctionLikeBindingName(node);
if (name && !helpers[name]) {
const functionNode = getFunctionLikeNode(node);
if (functionNode) {
const baseHelper = findCalledHelper(functionNode, helpers);
if (
baseHelper &&
helpers[baseHelper] &&
helpers[baseHelper] !== 'unknown'
) {
helpers[name] = helpers[baseHelper];
changed = true;
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
return helpers;
}
function collectImportedHelperAliases(sourceFile: ts.SourceFile) {
const aliases: Array<{ name: string; baseHelper: BaseEvalHelper }> = [];
for (const statement of sourceFile.statements) {
if (
!ts.isImportDeclaration(statement) ||
!statement.importClause?.namedBindings ||
!ts.isNamedImports(statement.importClause.namedBindings)
) {
continue;
}
for (const element of statement.importClause.namedBindings.elements) {
const importedName = element.propertyName?.text ?? element.name.text;
if (isBaseEvalHelper(importedName)) {
aliases.push({
name: element.name.text,
baseHelper: importedName,
});
}
}
}
return aliases;
}
function collectEvalCalls(
sourceFile: ts.SourceFile,
helpers: Record<string, BaseEvalHelper | 'unknown'>,
onCall: (callExpression: ts.CallExpression, helperName: string) => void,
) {
const visit = (node: ts.Node) => {
const wrapperName = getFunctionLikeBindingName(node);
if (wrapperName && helpers[wrapperName] && !isBaseEvalHelper(wrapperName)) {
return;
}
if (ts.isCallExpression(node)) {
const helperName = getCalledIdentifierName(node);
if (helperName && helpers[helperName]) {
onCall(node, helperName);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
function findCalledHelper(
functionNode: ts.Node,
helpers: Record<string, BaseEvalHelper | 'unknown'>,
): string | undefined {
let found: string | undefined;
const visit = (candidate: ts.Node) => {
if (found) {
return;
}
if (
candidate !== functionNode &&
(ts.isFunctionDeclaration(candidate) ||
ts.isFunctionExpression(candidate) ||
ts.isArrowFunction(candidate) ||
ts.isMethodDeclaration(candidate))
) {
return;
}
if (ts.isCallExpression(candidate)) {
const helperName = getCalledIdentifierName(candidate);
if (helperName && helpers[helperName]) {
found = helperName;
return;
}
}
ts.forEachChild(candidate, visit);
};
ts.forEachChild(functionNode, visit);
return found;
}
function getFunctionLikeBindingName(node: ts.Node) {
if (ts.isFunctionDeclaration(node) && node.name) {
return node.name.text;
}
if (ts.isVariableDeclaration(node)) {
if (
ts.isIdentifier(node.name) &&
node.initializer &&
(ts.isArrowFunction(node.initializer) ||
ts.isFunctionExpression(node.initializer))
) {
return node.name.text;
}
}
return undefined;
}
function getFunctionLikeNode(node: ts.Node) {
if (ts.isFunctionDeclaration(node)) {
return node;
}
if (
ts.isVariableDeclaration(node) &&
node.initializer &&
(ts.isArrowFunction(node.initializer) ||
ts.isFunctionExpression(node.initializer))
) {
return node.initializer;
}
return undefined;
}
function getCalledIdentifierName(callExpression: ts.CallExpression) {
return ts.isIdentifier(callExpression.expression)
? callExpression.expression.text
: undefined;
}
function isBaseEvalHelper(name: string): name is BaseEvalHelper {
return BASE_EVAL_HELPERS.includes(name as BaseEvalHelper);
}
function isEvalPolicy(policy: string | undefined): policy is EvalPolicy {
return (
policy === 'ALWAYS_PASSES' ||
policy === 'USUALLY_PASSES' ||
policy === 'USUALLY_FAILS'
);
}
function hasProperty(objectLiteral: ts.ObjectLiteralExpression, name: string) {
return Boolean(getPropertyAssignment(objectLiteral, name));
}
function getStaticStringProperty(
objectLiteral: ts.ObjectLiteralExpression,
name: string,
) {
const assignment = getPropertyAssignment(objectLiteral, name);
return assignment ? getStringLiteralValue(assignment.initializer) : undefined;
}
function getStaticNumberProperty(
objectLiteral: ts.ObjectLiteralExpression,
name: string,
) {
const assignment = getPropertyAssignment(objectLiteral, name);
if (!assignment) {
return undefined;
}
const initializer = assignment.initializer;
return ts.isNumericLiteral(initializer)
? Number(initializer.text)
: undefined;
}
function getPropertyAssignment(
objectLiteral: ts.ObjectLiteralExpression,
name: string,
) {
return objectLiteral.properties.find((property) => {
if (!ts.isPropertyAssignment(property)) {
return false;
}
const propertyName = property.name;
return (
(ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName)) &&
propertyName.text === name
);
}) as ts.PropertyAssignment | undefined;
}
function getStringLiteralValue(expression: ts.Expression | undefined) {
if (!expression) {
return undefined;
}
if (
ts.isStringLiteral(expression) ||
ts.isNoSubstitutionTemplateLiteral(expression)
) {
return expression.text;
}
return undefined;
}
function getLocation(
sourceFile: ts.SourceFile,
node: ts.Node,
): EvalSourceLocation {
const location = sourceFile.getLineAndCharacterOfPosition(
node.getStart(sourceFile),
);
return {
line: location.line + 1,
column: location.character + 1,
};
}
function getRelativePath(filePath: string, repoRoot: string | undefined) {
if (filePath === '<inline>') {
return filePath;
}
const relativePath = repoRoot ? path.relative(repoRoot, filePath) : filePath;
return relativePath.replace(/\\/g, '/');
}
function getScriptKind(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
case '.tsx':
return ts.ScriptKind.TSX;
case '.jsx':
return ts.ScriptKind.JSX;
case '.js':
case '.mjs':
case '.cjs':
return ts.ScriptKind.JS;
default:
return ts.ScriptKind.TS;
}
}
function compareEvalCases(left: EvalCaseRecord, right: EvalCaseRecord) {
return (
compareStrings(left.relativePath, right.relativePath) ||
left.location.line - right.location.line ||
left.location.column - right.location.column ||
compareStrings(left.name, right.name)
);
}
function compareDiagnostics(
left: EvalAnalysisDiagnostic,
right: EvalAnalysisDiagnostic,
) {
return (
compareStrings(left.filePath, right.filePath) ||
left.location.line - right.location.line ||
left.location.column - right.location.column ||
compareStrings(left.message, right.message)
);
}
function compareStrings(left: string, right: string) {
return left.localeCompare(right, 'en');
}
const TOOL_NAME_TO_CONSTANT: Record<
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
keyof typeof import('@google/gemini-cli-core')
> = {
glob: 'GLOB_TOOL_NAME',
grep_search: 'GREP_TOOL_NAME',
list_directory: 'LS_TOOL_NAME',
read_file: 'READ_FILE_TOOL_NAME',
run_shell_command: 'SHELL_TOOL_NAME',
write_file: 'WRITE_FILE_TOOL_NAME',
replace: 'EDIT_TOOL_NAME',
google_web_search: 'WEB_SEARCH_TOOL_NAME',
write_todos: 'WRITE_TODOS_TOOL_NAME',
web_fetch: 'WEB_FETCH_TOOL_NAME',
read_many_files: 'READ_MANY_FILES_TOOL_NAME',
get_internal_docs: 'GET_INTERNAL_DOCS_TOOL_NAME',
activate_skill: 'ACTIVATE_SKILL_TOOL_NAME',
ask_user: 'ASK_USER_TOOL_NAME',
exit_plan_mode: 'EXIT_PLAN_MODE_TOOL_NAME',
enter_plan_mode: 'ENTER_PLAN_MODE_TOOL_NAME',
update_topic: 'UPDATE_TOPIC_TOOL_NAME',
complete_task: 'COMPLETE_TASK_TOOL_NAME',
read_mcp_resource: 'READ_MCP_RESOURCE_TOOL_NAME',
list_mcp_resources: 'LIST_MCP_RESOURCES_TOOL_NAME',
tracker_create_task: 'TRACKER_CREATE_TASK_TOOL_NAME',
tracker_update_task: 'TRACKER_UPDATE_TASK_TOOL_NAME',
tracker_get_task: 'TRACKER_GET_TASK_TOOL_NAME',
tracker_list_tasks: 'TRACKER_LIST_TASKS_TOOL_NAME',
tracker_add_dependency: 'TRACKER_ADD_DEPENDENCY_TOOL_NAME',
tracker_visualize: 'TRACKER_VISUALIZE_TOOL_NAME',
invoke_agent: 'AGENT_TOOL_NAME',
};
const WELL_KNOWN_TOOL_CONSTANTS: Record<
string,
(typeof ALL_BUILTIN_TOOL_NAMES)[number]
> = Object.fromEntries(
Object.entries(TOOL_NAME_TO_CONSTANT).map(([toolName, constantName]) => [
constantName,
toolName as (typeof ALL_BUILTIN_TOOL_NAMES)[number],
]),
);
function collectImportedToolNameConstants(
sourceFile: ts.SourceFile,
): Map<string, string> {
const constants = new Map<string, string>();
for (const statement of sourceFile.statements) {
if (
!ts.isImportDeclaration(statement) ||
!statement.importClause?.namedBindings ||
!ts.isNamedImports(statement.importClause.namedBindings) ||
!ts.isStringLiteral(statement.moduleSpecifier) ||
statement.moduleSpecifier.text !== '@google/gemini-cli-core'
) {
continue;
}
for (const element of statement.importClause.namedBindings.elements) {
const importedName = element.propertyName?.text ?? element.name.text;
const localName = element.name.text;
const resolvedValue = WELL_KNOWN_TOOL_CONSTANTS[importedName];
if (resolvedValue !== undefined) {
constants.set(localName, resolvedValue);
}
}
}
return constants;
}
function getFunctionBody(
node: ts.Expression,
): ts.ConciseBody | ts.Block | undefined {
if (ts.isArrowFunction(node)) {
return node.body;
}
if (ts.isFunctionExpression(node)) {
return node.body;
}
return undefined;
}
function collectToolReferences(
body: ts.ConciseBody | ts.Block,
importedConstants: Map<string, string>,
): { name: string; node: ts.Node }[] {
const refs: { name: string; node: ts.Node }[] = [];
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
extractFromWaitForToolCall(node, importedConstants, refs);
extractFromArrayIncludes(node, importedConstants, refs);
} else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
) {
extractFromToolRequestNameComparison(node, importedConstants, refs);
}
ts.forEachChild(node, visit);
};
visit(body);
return refs;
}
function extractFromWaitForToolCall(
call: ts.CallExpression,
importedConstants: Map<string, string>,
refs: { name: string; node: ts.Node }[],
) {
const expr = call.expression;
if (
!ts.isPropertyAccessExpression(expr) ||
expr.name.text !== 'waitForToolCall'
) {
return;
}
const firstArg = call.arguments[0];
if (!firstArg) {
return;
}
const resolved = resolveStringValue(firstArg, importedConstants);
if (resolved) {
refs.push({ name: resolved, node: firstArg });
}
}
function isToolRequestName(node: ts.Expression): boolean {
return (
ts.isPropertyAccessExpression(node) &&
node.name.text === 'name' &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'toolRequest'
);
}
function extractFromToolRequestNameComparison(
binary: ts.BinaryExpression,
importedConstants: Map<string, string>,
refs: { name: string; node: ts.Node }[],
) {
let valueNode: ts.Expression | undefined;
if (isToolRequestName(binary.left)) {
valueNode = binary.right;
} else if (isToolRequestName(binary.right)) {
valueNode = binary.left;
}
if (valueNode) {
const resolved = resolveStringValue(valueNode, importedConstants);
if (resolved) {
refs.push({ name: resolved, node: valueNode });
}
}
}
function extractFromArrayIncludes(
call: ts.CallExpression,
importedConstants: Map<string, string>,
refs: { name: string; node: ts.Node }[],
) {
const expr = call.expression;
if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'includes') {
return;
}
const firstArg = call.arguments[0];
if (!firstArg || !isToolRequestName(firstArg)) {
return;
}
const arrayExpr = expr.expression;
if (!ts.isArrayLiteralExpression(arrayExpr)) {
return;
}
for (const element of arrayExpr.elements) {
const resolved = resolveStringValue(element, importedConstants);
if (resolved) {
refs.push({ name: resolved, node: element });
}
}
}
function resolveStringValue(
node: ts.Expression,
importedConstants: Map<string, string>,
): string | undefined {
const literal = getStringLiteralValue(node);
if (literal !== undefined) {
return literal;
}
if (ts.isIdentifier(node)) {
return importedConstants.get(node.text);
}
return undefined;
}
+352
View File
@@ -0,0 +1,352 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { glob } from 'glob';
import {
analyzeEvalSource,
type EvalCaseRecord,
type EvalFileAnalysis,
type EvalAnalysisDiagnostic,
type EvalPolicy,
} from './eval-analysis.js';
const POLICY_ORDER: EvalPolicy[] = [
'ALWAYS_PASSES',
'USUALLY_PASSES',
'USUALLY_FAILS',
'unknown',
];
export interface InventoryResult {
totalFiles: number;
totalCases: number;
repoRoot: string;
files: EvalFileAnalysis[];
cases: readonly EvalCaseRecord[];
diagnostics: readonly EvalAnalysisDiagnostic[];
}
/**
* Discovers all eval files under the given repo root and runs
* the static analyzer on each, returning the aggregated results.
*/
export async function collectInventory(
repoRoot: string,
): Promise<InventoryResult> {
const evalsDir = path.join(repoRoot, 'evals');
try {
const stat = await fs.promises.stat(evalsDir);
if (!stat.isDirectory()) {
throw new Error(`evals path exists but is not a directory: ${evalsDir}`);
}
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
throw new Error(
`evals directory not found under repo root: ${evalsDir}\n` +
`Make sure --root points to the repository root.`,
);
}
throw err;
}
const pattern = '**/*.eval.{ts,tsx}';
const evalFiles = await glob(pattern, {
cwd: evalsDir,
absolute: true,
nodir: true,
});
evalFiles.sort();
const files: EvalFileAnalysis[] = [];
const allCases: EvalCaseRecord[] = [];
const allDiagnostics: EvalAnalysisDiagnostic[] = [];
for (const filePath of evalFiles) {
const sourceText = await fs.promises.readFile(filePath, 'utf-8');
const analysis = analyzeEvalSource(sourceText, { filePath, repoRoot });
files.push(analysis);
allCases.push(...analysis.cases);
allDiagnostics.push(...analysis.diagnostics);
}
return {
totalFiles: files.length,
totalCases: allCases.length,
repoRoot,
files,
cases: allCases,
diagnostics: allDiagnostics,
};
}
/**
* Formats an InventoryResult into a human-readable report string.
*/
export function formatInventoryReport(result: InventoryResult): string {
const lines: string[] = [];
lines.push('Eval Inventory');
lines.push('══════════════');
lines.push('');
lines.push(
`${result.totalFiles} files · ${result.totalCases} cases · ${result.diagnostics.length} diagnostics`,
);
lines.push('');
// --- By Policy ---
lines.push('By Policy');
lines.push('─────────');
const byPolicyMap = groupBy(result.cases, (c) => c.policy);
const renderedPolicies = new Set<string>();
for (const policy of POLICY_ORDER) {
const cases = byPolicyMap.get(policy);
if (!cases || cases.length === 0) {
continue;
}
renderedPolicies.add(policy);
lines.push(`${policy} (${cases.length} cases)`);
const byFile = groupBy(cases, (c) => c.relativePath);
for (const [filePath, fileCases] of byFile) {
lines.push(` ${filePath}`);
for (const evalCase of fileCases) {
lines.push(`${evalCase.name} [${evalCase.helperName}]`);
}
}
lines.push('');
}
for (const [policy, cases] of byPolicyMap) {
if (renderedPolicies.has(policy) || !cases || cases.length === 0) {
continue;
}
lines.push(`${policy} (${cases.length} cases)`);
const byFile = groupBy(cases, (c) => c.relativePath);
for (const [filePath, fileCases] of byFile) {
lines.push(` ${filePath}`);
for (const evalCase of fileCases) {
lines.push(`${evalCase.name} [${evalCase.helperName}]`);
}
}
lines.push('');
}
// --- By Suite ---
lines.push('By Suite');
lines.push('────────');
const bySuite = groupBy(result.cases, (c) => c.suiteName ?? '(no suite)');
const suiteNames = [...bySuite.keys()].sort((a, b) => {
if (a === b) return 0;
if (a === '(no suite)') return 1;
if (b === '(no suite)') return -1;
return a.localeCompare(b, 'en');
});
for (const suite of suiteNames) {
const cases = bySuite.get(suite)!;
lines.push(`${suite} (${cases.length} cases)`);
for (const evalCase of cases) {
lines.push(
`${evalCase.name} [${evalCase.relativePath}] (${evalCase.policy})`,
);
}
lines.push('');
}
// --- Diagnostics ---
if (result.diagnostics.length > 0) {
const filePaths = new Map<string, string>();
for (const f of result.files) {
filePaths.set(f.filePath, f.relativePath);
}
lines.push('Diagnostics');
lines.push('───────────');
for (const diagnostic of result.diagnostics) {
const displayPath = resolveRelativePath(
diagnostic.filePath,
filePaths,
result.repoRoot,
);
lines.push(
`${displayPath}:${diagnostic.location.line}:${diagnostic.location.column}${diagnostic.message}`,
);
}
lines.push('');
}
return lines.join('\n');
}
export interface InventoryJsonOutput {
version: 1;
generated: string;
summary: {
totalFiles: number;
totalCases: number;
totalDiagnostics: number;
byPolicy: Record<string, number>;
};
cases: InventoryJsonCase[];
diagnostics: InventoryJsonDiagnostic[];
}
interface InventoryJsonCase {
name: string;
filePath: string;
helperName: string;
baseHelperName: string;
policy: string;
suiteName: string | null;
suiteType: string | null;
timeout: number | null;
hasFiles: boolean;
hasPrompt: boolean;
location: { line: number; column: number };
}
interface InventoryJsonDiagnostic {
severity: string;
message: string;
filePath: string;
location: { line: number; column: number };
}
export function formatInventoryJson(
result: InventoryResult,
now?: Date,
): string {
const filePathLookup = new Map<string, string>();
for (const f of result.files) {
filePathLookup.set(f.filePath, f.relativePath);
}
const policyCounts = new Map<string, number>();
for (const evalCase of result.cases) {
policyCounts.set(
evalCase.policy,
(policyCounts.get(evalCase.policy) ?? 0) + 1,
);
}
const byPolicy: Record<string, number> = {};
for (const policy of POLICY_ORDER) {
const count = policyCounts.get(policy);
if (count !== undefined) {
byPolicy[policy] = count;
}
}
for (const [policy, count] of policyCounts) {
if (!(policy in byPolicy)) {
byPolicy[policy] = count;
}
}
let generatedDate = now;
if (!generatedDate && process.env.SOURCE_DATE_EPOCH) {
const epoch = parseInt(process.env.SOURCE_DATE_EPOCH, 10);
if (!isNaN(epoch)) {
generatedDate = new Date(epoch * 1000);
}
}
if (
!generatedDate &&
(process.env.EVAL_INVENTORY_STABLE_DATE ||
process.env.EVAL_INVENTORY_DETERMINISTIC)
) {
generatedDate = new Date(0);
}
if (!generatedDate) {
generatedDate = new Date();
}
const output: InventoryJsonOutput = {
version: 1,
generated: generatedDate.toISOString(),
summary: {
totalFiles: result.totalFiles,
totalCases: result.totalCases,
totalDiagnostics: result.diagnostics.length,
byPolicy,
},
cases: result.cases.map((c) => ({
name: c.name,
filePath: c.relativePath,
helperName: c.helperName,
baseHelperName: c.baseHelperName,
policy: c.policy,
suiteName: c.suiteName ?? null,
suiteType: c.suiteType ?? null,
timeout: c.timeout ?? null,
hasFiles: c.hasFiles,
hasPrompt: c.hasPrompt,
location: { line: c.location.line, column: c.location.column },
})),
diagnostics: result.diagnostics.map((d) => {
const relativePath = resolveRelativePath(
d.filePath,
filePathLookup,
result.repoRoot,
);
return {
severity: d.severity,
message: d.message,
filePath: relativePath,
location: { line: d.location.line, column: d.location.column },
};
}),
};
return JSON.stringify(output, null, 2);
}
function groupBy<T>(
items: readonly T[],
keyFn: (item: T) => string,
): Map<string, T[]> {
const groups = new Map<string, T[]>();
for (const item of items) {
const key = keyFn(item);
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
return groups;
}
function resolveRelativePath(
filePath: string,
lookup: Map<string, string>,
baseDir: string,
): string {
if (filePath === '<inline>') {
return filePath;
}
const mapped = lookup.get(filePath);
if (mapped !== undefined) {
return mapped;
}
return path.isAbsolute(filePath)
? path.relative(baseDir, filePath).replace(/\\/g, '/')
: filePath;
}
function isNodeError(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && 'code' in err;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
ALL_BUILTIN_TOOL_NAMES,
TOOL_LEGACY_ALIASES,
} from '@google/gemini-cli-core';
export type ToolCategory =
| 'file-system'
| 'shell'
| 'web'
| 'planning'
| 'user-interaction'
| 'skills'
| 'task-tracker'
| 'agent'
| 'mcp';
export interface ToolRegistryEntry {
name: string;
category: ToolCategory;
aliases: readonly string[];
}
export interface ToolRegistry {
tools: ReadonlyMap<string, ToolRegistryEntry>;
totalTools: number;
byCategory: ReadonlyMap<ToolCategory, readonly ToolRegistryEntry[]>;
aliasLookup: ReadonlyMap<string, string>;
}
const TOOL_CATEGORIES: Record<
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
ToolCategory
> = {
glob: 'file-system',
grep_search: 'file-system',
list_directory: 'file-system',
read_file: 'file-system',
read_many_files: 'file-system',
write_file: 'file-system',
replace: 'file-system',
run_shell_command: 'shell',
google_web_search: 'web',
web_fetch: 'web',
enter_plan_mode: 'planning',
exit_plan_mode: 'planning',
write_todos: 'planning',
ask_user: 'user-interaction',
activate_skill: 'skills',
get_internal_docs: 'skills',
tracker_create_task: 'task-tracker',
tracker_update_task: 'task-tracker',
tracker_get_task: 'task-tracker',
tracker_list_tasks: 'task-tracker',
tracker_add_dependency: 'task-tracker',
tracker_visualize: 'task-tracker',
invoke_agent: 'agent',
complete_task: 'agent',
update_topic: 'agent',
read_mcp_resource: 'mcp',
list_mcp_resources: 'mcp',
};
let registryCache: ToolRegistry | undefined;
export function buildToolRegistry(): ToolRegistry {
if (registryCache) {
return registryCache;
}
const tools = new Map<string, ToolRegistryEntry>();
const aliasLookup = new Map<string, string>();
const categoryGroups = new Map<ToolCategory, ToolRegistryEntry[]>();
for (const name of ALL_BUILTIN_TOOL_NAMES) {
const category = TOOL_CATEGORIES[name];
const aliases: string[] = [];
for (const [legacyName, canonicalName] of Object.entries(
TOOL_LEGACY_ALIASES,
)) {
if (canonicalName === name) {
aliases.push(legacyName);
aliasLookup.set(legacyName, name);
}
}
aliasLookup.set(name, name);
const entry: ToolRegistryEntry = {
name,
category,
aliases: Object.freeze(aliases),
};
tools.set(name, entry);
const group = categoryGroups.get(category);
if (group) {
group.push(entry);
} else {
categoryGroups.set(category, [entry]);
}
}
const frozenCategories = new Map<
ToolCategory,
readonly ToolRegistryEntry[]
>();
for (const [cat, entries] of categoryGroups) {
frozenCategories.set(cat, Object.freeze(entries));
}
registryCache = {
tools,
totalTools: tools.size,
byCategory: frozenCategories,
aliasLookup,
};
return registryCache;
}
export function resolveToolName(
registry: ToolRegistry,
name: string,
): string | undefined {
if (!name) {
return undefined;
}
return registry.aliasLookup.get(name);
}
export function getToolsByCategory(
registry: ToolRegistry,
category: ToolCategory,
): readonly ToolRegistryEntry[] {
return registry.byCategory.get(category) ?? [];
}
+109
View File
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
// A script to handle versioning and ensure all related changes are in a single, atomic commit.
function run(command) {
console.log(`> ${command}`);
execSync(command, { stdio: 'inherit' });
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
function writeJson(filePath, data) {
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
}
// 1. Get the version type from the command line arguments.
const versionType = process.argv[2];
if (!versionType) {
console.error('Error: No version type specified.');
console.error('Usage: npm run version <patch|minor|major|prerelease>');
process.exit(1);
}
// 2. Bump the version in the root and all workspace package.json files.
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
// 3. Get all workspaces and filter out the one we don't want to version.
const workspacesToExclude = [];
let lsOutput;
try {
lsOutput = JSON.parse(
execSync('npm ls --workspaces --json --depth=0').toString(),
);
} catch (e) {
// `npm ls` can exit with a non-zero status code if there are issues
// with dependencies, but it will still produce the JSON output we need.
// We'll try to parse the stdout from the error object.
if (e.stdout) {
console.warn(
'Warning: `npm ls` exited with a non-zero status code. Attempting to proceed with the output.',
);
try {
lsOutput = JSON.parse(e.stdout.toString());
} catch (parseError) {
console.error(
'Error: Failed to parse JSON from `npm ls` output even after `npm ls` failed.',
);
console.error('npm ls stderr:', e.stderr.toString());
console.error('Parse error:', parseError);
process.exit(1);
}
} else {
console.error('Error: `npm ls` failed with no output.');
console.error(e.stderr?.toString() || e);
process.exit(1);
}
}
const allWorkspaces = Object.keys(lsOutput.dependencies || {});
const workspacesToVersion = allWorkspaces.filter(
(wsName) => !workspacesToExclude.includes(wsName),
);
for (const workspaceName of workspacesToVersion) {
run(
`npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`,
);
}
// 4. Get the new version number from the root package.json
const rootPackageJsonPath = resolve(process.cwd(), 'package.json');
const newVersion = readJson(rootPackageJsonPath).version;
// 4. Update the sandboxImageUri in the root package.json
const rootPackageJson = readJson(rootPackageJsonPath);
if (rootPackageJson.config?.sandboxImageUri) {
rootPackageJson.config.sandboxImageUri =
rootPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
console.log(`Updated sandboxImageUri in root to use version ${newVersion}`);
writeJson(rootPackageJsonPath, rootPackageJson);
}
// 5. Update the sandboxImageUri in the cli package.json
const cliPackageJsonPath = resolve(process.cwd(), 'packages/cli/package.json');
const cliPackageJson = readJson(cliPackageJsonPath);
if (cliPackageJson.config?.sandboxImageUri) {
cliPackageJson.config.sandboxImageUri =
cliPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
console.log(
`Updated sandboxImageUri in cli package to use version ${newVersion}`,
);
writeJson(cliPackageJsonPath, cliPackageJson);
}
// 6. Run `npm install` to update package-lock.json.
run(
'npm install --workspace packages/cli --workspace packages/core --package-lock-only',
);
console.log(`Successfully bumped versions to v${newVersion}.`);