From 89213699bfe01e535a3523f5c9229ebb9aad9baf Mon Sep 17 00:00:00 2001 From: matt korwel Date: Tue, 9 Sep 2025 13:55:27 -0700 Subject: [PATCH] Final Changes for stable release (#8105) Co-authored-by: gemini-cli-robot Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> Co-authored-by: christine betts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Bryan Morgan Co-authored-by: anthony bushong Co-authored-by: Shreya Keshive Co-authored-by: Taylor Mullen Co-authored-by: Arya Gummadi Co-authored-by: Sandy Tao Co-authored-by: Pascal Birchler Co-authored-by: Victor May Co-authored-by: silvio junior --- esbuild.config.js | 1 - package-lock.json | 14 +- package.json | 4 +- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 +- .../src/commands/extensions/install.test.ts | 26 +-- .../cli/src/commands/extensions/install.ts | 28 +-- packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/extension.test.ts | 130 +++++++------- packages/cli/src/config/extension.ts | 169 +++++++++++------- packages/cli/src/config/settings.test.ts | 6 +- packages/cli/src/config/settings.ts | 16 +- packages/cli/src/config/settingsSchema.ts | 2 +- .../cli/src/ui/hooks/useWorkspaceMigration.ts | 3 +- packages/core/index.ts | 2 + packages/core/package.json | 2 +- packages/core/src/config/config.ts | 6 +- packages/core/src/core/geminiChat.test.ts | 113 ++++++++++-- packages/core/src/core/geminiChat.ts | 56 +++--- packages/core/src/ide/process-utils.ts | 72 ++++---- .../clearcut-logger/clearcut-logger.test.ts | 2 +- .../clearcut-logger/clearcut-logger.ts | 28 +++ .../clearcut-logger/event-metadata-key.ts | 16 ++ packages/core/src/telemetry/types.ts | 26 ++- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 26 files changed, 443 insertions(+), 291 deletions(-) diff --git a/esbuild.config.js b/esbuild.config.js index e7f2ea5a04..b62d8de75a 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -23,7 +23,6 @@ const external = [ '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', - 'node-fetch', ]; esbuild diff --git a/package-lock.json b/package-lock.json index 99a4a39c10..03fd15526c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "workspaces": [ "packages/*" ], @@ -16107,7 +16107,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -16378,7 +16378,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.16.0", @@ -16569,7 +16569,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "dependencies": { "@google/genai": "1.16.0", "@lvce-editor/ripgrep": "^1.6.0", @@ -16709,7 +16709,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -16720,7 +16720,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index abb431405e..c236597bdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview.2" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 84c1617e3d..ddf5c1edb8 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "private": true, "description": "Gemini CLI A2A Server", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d9a215e4d..3d6cc10173 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.4.0-preview.2" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 707536bd3a..441855da2c 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -5,9 +5,8 @@ */ import { describe, it, expect } from 'vitest'; -import { installCommand, handleInstall } from './install.js'; +import { installCommand } from './install.js'; import yargs from 'yargs'; -import * as extension from '../../config/extension.js'; vi.mock('../../config/extension.js', () => ({ installExtension: vi.fn(), @@ -17,33 +16,14 @@ describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]).command(installCommand).fail(false); expect(() => validationParser.parse('install')).toThrow( - 'Either --source or --path must be provided.', + 'Either source or --path must be provided.', ); }); it('should fail if both git source and local path are provided', () => { const validationParser = yargs([]).command(installCommand).fail(false); expect(() => - validationParser.parse('install --source some-url --path /some/path'), + validationParser.parse('install some-url --path /some/path'), ).toThrow('Arguments source and path are mutually exclusive'); }); }); - -describe('extensions install with org/repo', () => { - it('should call installExtension with the correct git URL', async () => { - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const installExtensionSpy = vi - .spyOn(extension, 'installExtension') - .mockResolvedValue('test-extension'); - - await handleInstall({ source: 'test-org/test-repo' }); - - expect(installExtensionSpy).toHaveBeenCalledWith({ - source: 'https://github.com/test-org/test-repo.git', - type: 'git', - }); - expect(consoleLogSpy).toHaveBeenCalledWith( - 'Extension "test-extension" installed successfully and enabled.', - ); - }); -}); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 4823f5848e..e33783f2ce 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -17,8 +17,6 @@ interface InstallArgs { path?: string; } -const ORG_REPO_REGEX = /^[a-zA-Z0-9-]+\/[\w.-]+$/; - export async function handleInstall(args: InstallArgs) { try { let installMetadata: ExtensionInstallMetadata; @@ -34,15 +32,8 @@ export async function handleInstall(args: InstallArgs) { source, type: 'git', }; - } else if (ORG_REPO_REGEX.test(source)) { - installMetadata = { - source: `https://github.com/${source}.git`, - type: 'git', - }; } else { - throw new Error( - `The source "${source}" is not a valid URL or "org/repo" format.`, - ); + throw new Error(`The source "${source}" is not a valid URL format.`); } } else if (args.path) { installMetadata = { @@ -54,10 +45,8 @@ export async function handleInstall(args: InstallArgs) { throw new Error('Either --source or --path must be provided.'); } - const extensionName = await installExtension(installMetadata); - console.log( - `Extension "${extensionName}" installed successfully and enabled.`, - ); + const name = await installExtension(installMetadata); + console.log(`Extension "${name}" installed successfully and enabled.`); } catch (error) { console.error(getErrorMessage(error)); process.exit(1); @@ -65,13 +54,12 @@ export async function handleInstall(args: InstallArgs) { } export const installCommand: CommandModule = { - command: 'install [--source | --path ]', - describe: - 'Installs an extension from a git repository (URL or "org/repo") or a local path.', + command: 'install [source]', + describe: 'Installs an extension from a git repository URL or a local path.', builder: (yargs) => yargs - .option('source', { - describe: 'The git URL or "org/repo" of the extension to install.', + .positional('source', { + describe: 'The github URL of the extension to install.', type: 'string', }) .option('path', { @@ -81,7 +69,7 @@ export const installCommand: CommandModule = { .conflicts('source', 'path') .check((argv) => { if (!argv.source && !argv.path) { - throw new Error('Either --source or --path must be provided.'); + throw new Error('Either source or --path must be provided.'); } return true; }), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c127ebc98b..3b187f3f56 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -305,7 +305,7 @@ export async function parseArguments(settings: Settings): Promise { // Register MCP subcommands .command(mcpCommand); - if (settings?.experimental?.extensionManagement ?? false) { + if (settings?.experimental?.extensionManagement ?? true) { yargsInstance.command(extensionsCommand); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 144fa1cd32..d647bec4e3 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -26,6 +26,8 @@ import { GEMINI_DIR, type GeminiCLIExtension, type MCPServerConfig, + ClearcutLogger, + type Config, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { SettingScope, loadSettings } from './settings.js'; @@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const mockLogExtensionInstallEvent = vi.fn(); + return { + ...actual, + ClearcutLogger: { + getInstance: vi.fn(() => ({ + logExtensionInstallEvent: mockLogExtensionInstallEvent, + })), + }, + Config: vi.fn(), + ExtensionInstallEvent: vi.fn(), + }; +}); + vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -63,59 +81,36 @@ vi.mock('child_process', async (importOriginal) => { const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); describe('loadExtensions', () => { - let tempWorkspaceDir: string; let tempHomeDir: string; - let workspaceExtensionsDir: string; + let userExtensionsDir: string; beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(isWorkspaceTrusted).mockReturnValue(true); - workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(userExtensionsDir, { recursive: true }); }); afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); fs.rmSync(tempHomeDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); - it('ignores extensions in untrusted workspaces', () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); - - const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); - fs.mkdirSync(extensionDir, { recursive: true }); - createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - addContextFile: true, - }); - const extensions = loadExtensions(tempWorkspaceDir); - expect(extensions.length).toBe(0); - }); - it('should include extension path in loaded extension', () => { - const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); + const extensionDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extensionDir, { recursive: true }); createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', }); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].config.name).toBe('test-extension'); @@ -123,70 +118,70 @@ describe('loadExtensions', () => { it('should load context file path when GEMINI.md is present', () => { createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', addContextFile: true, }); createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'ext2', version: '2.0.0', }); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(2); const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext2 = extensions.find((e) => e.config.name === 'ext2'); expect(ext1?.contextFiles).toEqual([ - path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'), + path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), ]); expect(ext2?.contextFiles).toEqual([]); }); it('should load context file path from the extension config', () => { createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', addContextFile: false, contextFileName: 'my-context-file.md', }); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const ext1 = extensions.find((e) => e.config.name === 'ext1'); expect(ext1?.contextFiles).toEqual([ - path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'), + path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), ]); }); it('should filter out disabled extensions', () => { createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'ext2', version: '2.0.0', }); - const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR); + const settingsDir = path.join(tempHomeDir, GEMINI_DIR); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync( path.join(settingsDir, 'settings.json'), JSON.stringify({ extensions: { disabled: ['ext1'] } }), ); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); const activeExtensions = annotateActiveExtensions( extensions, [], - tempWorkspaceDir, + tempHomeDir, ).filter((e) => e.isActive); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext2'); @@ -194,7 +189,7 @@ describe('loadExtensions', () => { it('should hydrate variables', () => { createExtension({ - extensionsDir: workspaceExtensionsDir, + extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', addContextFile: false, @@ -206,11 +201,11 @@ describe('loadExtensions', () => { }, }); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const loadedConfig = extensions[0].config; const expectedCwd = path.join( - workspaceExtensionsDir, + userExtensionsDir, 'test-extension', 'server', ); @@ -218,6 +213,9 @@ describe('loadExtensions', () => { }); it('should load a linked extension correctly', async () => { + const tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); const sourceExtDir = createExtension({ extensionsDir: tempWorkspaceDir, name: 'my-linked-extension', @@ -231,7 +229,7 @@ describe('loadExtensions', () => { type: 'link', }); expect(extensionName).toEqual('my-linked-extension'); - const extensions = loadExtensions(tempHomeDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const linkedExt = extensions[0]; @@ -252,13 +250,13 @@ describe('loadExtensions', () => { process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; try { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, + const userExtensionsDir = path.join( + tempHomeDir, EXTENSIONS_DIRECTORY_NAME, ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); - const extDir = path.join(workspaceExtensionsDir, 'test-extension'); + const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); // Write config to a separate file for clarity and good practices @@ -280,7 +278,7 @@ describe('loadExtensions', () => { }; fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -302,13 +300,10 @@ describe('loadExtensions', () => { }); it('should handle missing environment variables gracefully', () => { - const workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(userExtensionsDir, { recursive: true }); - const extDir = path.join(workspaceExtensionsDir, 'test-extension'); + const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); const extensionConfig = { @@ -331,7 +326,7 @@ describe('loadExtensions', () => { JSON.stringify(extensionConfig), ); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -542,6 +537,19 @@ describe('installExtension', () => { }); fs.rmSync(targetExtDir, { recursive: true, force: true }); }); + + it('should log to clearcut on successful install', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await installExtension({ source: sourceExtDir, type: 'local' }); + + const logger = ClearcutLogger.getInstance({} as Config); + expect(logger?.logExtensionInstallEvent).toHaveBeenCalled(); + }); }); describe('uninstallExtension', () => { @@ -592,7 +600,7 @@ describe('uninstallExtension', () => { await uninstallExtension('my-local-extension'); expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(loadExtensions(tempHomeDir)).toHaveLength(1); + expect(loadExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); @@ -675,7 +683,7 @@ describe('performWorkspaceExtensionMigration', () => { }); await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toEqual([]); }); @@ -703,7 +711,7 @@ describe('performWorkspaceExtensionMigration', () => { const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); const userExt1Path = path.join(userExtensionsDir, 'ext1'); - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); expect(extensions).toHaveLength(2); const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); @@ -912,7 +920,7 @@ describe('enableExtension', () => { }); const getActiveExtensions = (): GeminiCLIExtension[] => { - const extensions = loadExtensions(tempWorkspaceDir); + const extensions = loadExtensions(); const activeExtensions = annotateActiveExtensions( extensions, [], diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index e92bd65699..2c245b7fc6 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -8,7 +8,13 @@ import type { MCPServerConfig, GeminiCLIExtension, } from '@google/gemini-cli-core'; -import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + ClearcutLogger, + Config, + ExtensionInstallEvent, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js'; import { recursivelyHydrateStrings } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { randomUUID } from 'node:crypto'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -119,7 +126,8 @@ export function loadExtensions( if ( (isWorkspaceTrusted(settings) ?? true) && - !settings.experimental?.extensionManagement + // Default management setting to true + !(settings.experimental?.extensionManagement ?? true) ) { allExtensions.push(...getWorkspaceExtensions(workspaceDir)); } @@ -345,83 +353,120 @@ export async function installExtension( installMetadata: ExtensionInstallMetadata, cwd: string = process.cwd(), ): Promise { - const settings = loadSettings(cwd).merged; - if (!isWorkspaceTrusted(settings)) { - throw new Error( - `Could not install extension from untrusted folder at ${installMetadata.source}`, - ); - } - - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - await fs.promises.mkdir(extensionsDir, { recursive: true }); - - // Convert relative paths to absolute paths for the metadata file. - if ( - !path.isAbsolute(installMetadata.source) && - (installMetadata.type === 'local' || installMetadata.type === 'link') - ) { - installMetadata.source = path.resolve(cwd, installMetadata.source); - } - - let localSourcePath: string; - let tempDir: string | undefined; - let newExtensionName: string | undefined; - - if (installMetadata.type === 'git') { - tempDir = await ExtensionStorage.createTmpDir(); - await cloneFromGit(installMetadata.source, tempDir); - localSourcePath = tempDir; - } else if ( - installMetadata.type === 'local' || - installMetadata.type === 'link' - ) { - localSourcePath = installMetadata.source; - } else { - throw new Error(`Unsupported install type: ${installMetadata.type}`); - } + const config = new Config({ + sessionId: randomUUID(), + targetDir: process.cwd(), + cwd: process.cwd(), + model: '', + debugMode: false, + }); + const logger = ClearcutLogger.getInstance(config); + let newExtensionConfig: ExtensionConfig | null = null; + let localSourcePath: string | undefined; try { - const newExtensionConfig = await loadExtensionConfig(localSourcePath); - if (!newExtensionConfig) { + const settings = loadSettings(cwd).merged; + if (!isWorkspaceTrusted(settings)) { throw new Error( - `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, + `Could not install extension from untrusted folder at ${installMetadata.source}`, ); } - newExtensionName = newExtensionConfig.name; - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + await fs.promises.mkdir(extensionsDir, { recursive: true }); - const installedExtensions = loadUserExtensions(); if ( - installedExtensions.some( - (installed) => installed.config.name === newExtensionName, - ) + !path.isAbsolute(installMetadata.source) && + (installMetadata.type === 'local' || installMetadata.type === 'link') ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + installMetadata.source = path.resolve(cwd, installMetadata.source); + } + + let tempDir: string | undefined; + + if (installMetadata.type === 'git') { + tempDir = await ExtensionStorage.createTmpDir(); + await cloneFromGit(installMetadata.source, tempDir); + localSourcePath = tempDir; + } else if ( + installMetadata.type === 'local' || + installMetadata.type === 'link' + ) { + localSourcePath = installMetadata.source; + } else { + throw new Error(`Unsupported install type: ${installMetadata.type}`); + } + + try { + newExtensionConfig = await loadExtensionConfig(localSourcePath); + if (!newExtensionConfig) { + throw new Error( + `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, + ); + } + + const newExtensionName = newExtensionConfig.name; + const extensionStorage = new ExtensionStorage(newExtensionName); + const destinationPath = extensionStorage.getExtensionDir(); + + const installedExtensions = loadUserExtensions(); + if ( + installedExtensions.some( + (installed) => installed.config.name === newExtensionName, + ) + ) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } + + await fs.promises.mkdir(destinationPath, { recursive: true }); + + if (installMetadata.type === 'local' || installMetadata.type === 'git') { + await copyExtension(localSourcePath, destinationPath); + } + + const metadataString = JSON.stringify(installMetadata, null, 2); + const metadataPath = path.join( + destinationPath, + INSTALL_METADATA_FILENAME, ); + await fs.promises.writeFile(metadataPath, metadataString); + } finally { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } } - await fs.promises.mkdir(destinationPath, { recursive: true }); + logger?.logExtensionInstallEvent( + new ExtensionInstallEvent( + newExtensionConfig!.name, + newExtensionConfig!.version, + installMetadata.source, + 'success', + ), + ); - if (installMetadata.type === 'local' || installMetadata.type === 'git') { - await copyExtension(localSourcePath, destinationPath); - } - - const metadataString = JSON.stringify(installMetadata, null, 2); - const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME); - await fs.promises.writeFile(metadataPath, metadataString); - } finally { - if (tempDir) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + return newExtensionConfig!.name; + } catch (error) { + // Attempt to load config from the source path even if installation fails + // to get the name and version for logging. + if (!newExtensionConfig && localSourcePath) { + newExtensionConfig = await loadExtensionConfig(localSourcePath); } + logger?.logExtensionInstallEvent( + new ExtensionInstallEvent( + newExtensionConfig?.name ?? '', + newExtensionConfig?.version ?? '', + installMetadata.source, + 'error', + ), + ); + throw error; } - - return newExtensionName; } -async function loadExtensionConfig( +export async function loadExtensionConfig( extensionDir: string, ): Promise { const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 942c3996f8..ac8882922c 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -521,7 +521,7 @@ describe('Settings Loading and Merging', () => { }); }); - it('should ignore folderTrust from workspace settings', () => { + it('should use folderTrust from workspace settings when trusted', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { security: { @@ -533,7 +533,7 @@ describe('Settings Loading and Merging', () => { const workspaceSettingsContent = { security: { folderTrust: { - enabled: false, // This should be ignored + enabled: false, // This should be used }, }, }; @@ -554,7 +554,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used + expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used }); it('should use system folderTrust over user setting', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 2194d2240c..ab59d9b7f4 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -76,7 +76,7 @@ const MIGRATION_MAP: Record = { excludeTools: 'tools.exclude', excludeMCPServers: 'mcp.excluded', excludedProjectEnvVars: 'advanced.excludedEnvVars', - extensionManagement: 'advanced.extensionManagement', + extensionManagement: 'experimental.extensionManagement', extensions: 'extensions', fileFiltering: 'context.fileFiltering', folderTrustFeature: 'security.folderTrust.featureEnabled', @@ -329,18 +329,6 @@ function mergeSettings( ): Settings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); - // folderTrust is not supported at workspace level. - const { security, ...restOfWorkspace } = safeWorkspace; - const safeWorkspaceWithoutFolderTrust = security - ? { - ...restOfWorkspace, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - security: (({ folderTrust, ...rest }) => rest)(security), - } - : { - ...restOfWorkspace, - }; - // Settings are merged with the following precedence (last one wins for // single values): // 1. System Defaults @@ -352,7 +340,7 @@ function mergeSettings( {}, // Start with an empty object systemDefaults, user, - safeWorkspaceWithoutFolderTrust, + safeWorkspace, system, ) as Settings; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7f607acc69..6795065f59 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -838,7 +838,7 @@ export const SETTINGS_SCHEMA = { label: 'Extension Management', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable extension management features.', showInDialog: false, }, diff --git a/packages/cli/src/ui/hooks/useWorkspaceMigration.ts b/packages/cli/src/ui/hooks/useWorkspaceMigration.ts index 63f4f8f045..8f9a3addaf 100644 --- a/packages/cli/src/ui/hooks/useWorkspaceMigration.ts +++ b/packages/cli/src/ui/hooks/useWorkspaceMigration.ts @@ -20,7 +20,8 @@ export function useWorkspaceMigration(settings: LoadedSettings) { ); useEffect(() => { - if (!settings.merged.experimental?.extensionManagement) { + // Default to true if not set. + if (!(settings.merged.experimental?.extensionManagement ?? true)) { return; } const cwd = process.cwd(); diff --git a/packages/core/index.ts b/packages/core/index.ts index 8a05dc5778..8974925176 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -16,7 +16,9 @@ export { logIdeConnection } from './src/telemetry/loggers.js'; export { IdeConnectionEvent, IdeConnectionType, + ExtensionInstallEvent, } from './src/telemetry/types.js'; export { getIdeTrust } from './src/utils/ide-trust.js'; export { makeFakeConfig } from './src/test-utils/config.js'; export * from './src/utils/pathReader.js'; +export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; diff --git a/packages/core/package.json b/packages/core/package.json index 9d8cc97e4b..b4a8b78473 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7adf182f64..5136dbf39d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -282,7 +282,7 @@ export class Config { private readonly useRipgrep: boolean; private readonly shouldUseNodePtyShell: boolean; private readonly skipNextSpeakerCheck: boolean; - private readonly extensionManagement: boolean; + private readonly extensionManagement: boolean = true; private readonly enablePromptCompletion: boolean = false; private initialized: boolean = false; readonly storage: Storage; @@ -359,8 +359,8 @@ export class Config { this.useRipgrep = params.useRipgrep ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; - this.useSmartEdit = params.useSmartEdit ?? true; - this.extensionManagement = params.extensionManagement ?? false; + this.useSmartEdit = params.useSmartEdit ?? false; + this.extensionManagement = params.extensionManagement ?? true; this.storage = new Storage(this.targetDir); this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.fileExclusions = new FileExclusions(this); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 8592c56091..c7660441fd 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -416,9 +416,9 @@ describe('GeminiChat', () => { expect(modelTurn?.parts![0]!.functionCall).toBeDefined(); }); - it('should succeed if the stream ends with an empty part but has a valid finishReason', async () => { - // 1. Mock a stream that ends with an invalid part but has a 'STOP' finish reason. - const streamWithValidFinish = (async function* () { + it('should fail if the stream ends with an empty part and has no finishReason', async () => { + // 1. Mock a stream that ends with an invalid part and has no finish reason. + const streamWithNoFinish = (async function* () { yield { candidates: [ { @@ -429,7 +429,7 @@ describe('GeminiChat', () => { }, ], } as unknown as GenerateContentResponse; - // This second chunk is invalid, but the finishReason should save it from retrying. + // This second chunk is invalid and has no finishReason, so it should fail. yield { candidates: [ { @@ -437,6 +437,50 @@ describe('GeminiChat', () => { role: 'model', parts: [{ text: '' }], }, + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( + streamWithNoFinish, + ); + + // 2. Action & Assert: The stream should fail because there's no finish reason. + const stream = await chat.sendMessageStream( + { message: 'test message' }, + 'prompt-id-no-finish-empty-end', + ); + await expect( + (async () => { + for await (const _ of stream) { + /* consume stream */ + } + })(), + ).rejects.toThrow(EmptyStreamError); + }); + + it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => { + // 1. Mock a stream that sends a valid chunk, then an invalid one, but has a finish reason. + const streamWithInvalidEnd = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'Initial valid content...' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + // This second chunk is invalid, but the response has a finishReason. + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '' }], // Invalid part + }, finishReason: 'STOP', }, ], @@ -444,14 +488,13 @@ describe('GeminiChat', () => { })(); vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue( - streamWithValidFinish, + streamWithInvalidEnd, ); - // 2. Action & Assert: The stream should complete successfully because the valid - // finishReason overrides the invalid final chunk. + // 2. Action & Assert: The stream should complete without throwing an error. const stream = await chat.sendMessageStream( { message: 'test message' }, - 'prompt-id-valid-finish-empty-end', + 'prompt-id-valid-then-invalid-end', ); await expect( (async () => { @@ -461,12 +504,12 @@ describe('GeminiChat', () => { })(), ).resolves.not.toThrow(); - // 3. Verify history was recorded correctly + // 3. Verify history was recorded correctly with only the valid part. const history = chat.getHistory(); - expect(history.length).toBe(2); + expect(history.length).toBe(2); // user turn + model turn const modelTurn = history[1]!; - expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded - expect(modelTurn?.parts![0]!.text).toBe('Initial content...'); + expect(modelTurn?.parts?.length).toBe(1); + expect(modelTurn?.parts![0]!.text).toBe('Initial valid content...'); }); it('should not consolidate text into a part that also contains a functionCall', async () => { // 1. Mock the API to stream a malformed part followed by a valid text part. @@ -542,7 +585,10 @@ describe('GeminiChat', () => { // as the important part is consolidating what comes after. yield { candidates: [ - { content: { role: 'model', parts: [{ text: ' World!' }] } }, + { + content: { role: 'model', parts: [{ text: ' World!' }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(); @@ -645,6 +691,7 @@ describe('GeminiChat', () => { { text: 'This is the visible text that should not be lost.' }, ], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; @@ -705,7 +752,10 @@ describe('GeminiChat', () => { const emptyStreamResponse = (async function* () { yield { candidates: [ - { content: { role: 'model', parts: [{ thought: true }] } }, + { + content: { role: 'model', parts: [{ thought: true }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(); @@ -975,7 +1025,12 @@ describe('GeminiChat', () => { // Second attempt (the retry): A minimal valid stream. (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'Success' }] } }], + candidates: [ + { + content: { parts: [{ text: 'Success' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(), ); @@ -1012,7 +1067,10 @@ describe('GeminiChat', () => { (async function* () { yield { candidates: [ - { content: { parts: [{ text: 'Successful response' }] } }, + { + content: { parts: [{ text: 'Successful response' }] }, + finishReason: 'STOP', + }, ], } as unknown as GenerateContentResponse; })(), @@ -1123,7 +1181,12 @@ describe('GeminiChat', () => { // Second attempt succeeds (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'Second answer' }] } }], + candidates: [ + { + content: { parts: [{ text: 'Second answer' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(), ); @@ -1272,6 +1335,7 @@ describe('GeminiChat', () => { content: { parts: [{ text: 'Successful response after empty' }], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; @@ -1333,13 +1397,23 @@ describe('GeminiChat', () => { } as unknown as GenerateContentResponse; await firstStreamContinuePromise; // Pause the stream yield { - candidates: [{ content: { parts: [{ text: ' part 2' }] } }], + candidates: [ + { + content: { parts: [{ text: ' part 2' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(); const secondStreamGenerator = (async function* () { yield { - candidates: [{ content: { parts: [{ text: 'second response' }] } }], + candidates: [ + { + content: { parts: [{ text: 'second response' }] }, + finishReason: 'STOP', + }, + ], } as unknown as GenerateContentResponse; })(); @@ -1424,6 +1498,7 @@ describe('GeminiChat', () => { content: { parts: [{ text: 'Successful final response' }], }, + finishReason: 'STOP', }, ], } as unknown as GenerateContentResponse; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 17da50078f..a8f3b441b9 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -612,23 +612,18 @@ export class GeminiChat { ): AsyncGenerator { const modelResponseParts: Part[] = []; let hasReceivedAnyChunk = false; + let hasReceivedValidChunk = false; let hasToolCall = false; let lastChunk: GenerateContentResponse | null = null; - - let isStreamInvalid = false; - let firstInvalidChunkEncountered = false; - let validChunkAfterInvalidEncountered = false; + let lastChunkIsInvalid = false; for await (const chunk of streamResponse) { hasReceivedAnyChunk = true; lastChunk = chunk; if (isValidResponse(chunk)) { - if (firstInvalidChunkEncountered) { - // A valid chunk appeared *after* an invalid one. - validChunkAfterInvalidEncountered = true; - } - + hasReceivedValidChunk = true; + lastChunkIsInvalid = false; const content = chunk.candidates?.[0]?.content; if (content?.parts) { if (content.parts.some((part) => part.thought)) { @@ -640,14 +635,16 @@ export class GeminiChat { } // Always add parts - thoughts will be filtered out later in recordHistory modelResponseParts.push(...content.parts); + if (content.parts.some((part) => part.functionCall)) { + hasToolCall = true; + } } } else { logInvalidChunk( this.config, new InvalidChunkEvent('Invalid chunk received from stream.'), ); - isStreamInvalid = true; - firstInvalidChunkEncountered = true; + lastChunkIsInvalid = true; } // Record token usage if this chunk has usageMetadata @@ -662,27 +659,24 @@ export class GeminiChat { throw new EmptyStreamError('Model stream completed without any chunks.'); } - // --- FIX: The entire validation block was restructured for clarity and correctness --- - // Only apply complex validation if an invalid chunk was actually found. - if (isStreamInvalid) { - // Fail immediately if an invalid chunk was not the absolute last chunk. - if (validChunkAfterInvalidEncountered) { - throw new EmptyStreamError( - 'Model stream had invalid intermediate chunks without a tool call.', - ); - } + const hasFinishReason = lastChunk?.candidates?.some( + (candidate) => candidate.finishReason, + ); - if (!hasToolCall) { - // If the *only* invalid part was the last chunk, we still check its finish reason. - const finishReason = lastChunk?.candidates?.[0]?.finishReason; - const isSuccessfulFinish = - finishReason === 'STOP' || finishReason === 'MAX_TOKENS'; - if (!isSuccessfulFinish) { - throw new EmptyStreamError( - 'Model stream ended with an invalid chunk and a failed finish reason.', - ); - } - } + // Stream validation logic: A stream is considered successful if: + // 1. There's a tool call (tool calls can end without explicit finish reasons), OR + // 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks) + // + // We throw an error only when there's no tool call AND: + // - No finish reason, OR + // - Last chunk is invalid after receiving valid content + if ( + !hasToolCall && + (!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk)) + ) { + throw new EmptyStreamError( + 'Model stream ended with an invalid chunk or missing finish reason.', + ); } // Record model response text from the collected parts diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 451a3af351..ecb93781cc 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -24,39 +24,44 @@ async function getProcessInfo(pid: number): Promise<{ name: string; command: string; }> { - const platform = os.platform(); - if (platform === 'win32') { - const powershellCommand = [ - '$p = Get-CimInstance Win32_Process', - `-Filter 'ProcessId=${pid}'`, - '-ErrorAction SilentlyContinue;', - 'if ($p) {', - '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', - '| ConvertTo-Json', - '}', - ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); - const output = stdout.trim(); - if (!output) return { parentPid: 0, name: '', command: '' }; - const { - Name = '', - ParentProcessId = 0, - CommandLine = '', - } = JSON.parse(output); - return { parentPid: ParentProcessId, name: Name, command: CommandLine }; - } else { - const command = `ps -o ppid=,command= -p ${pid}`; - const { stdout } = await execAsync(command); - const trimmedStdout = stdout.trim(); - const ppidString = trimmedStdout.split(/\s+/)[0]; - const parentPid = parseInt(ppidString, 10); - const fullCommand = trimmedStdout.substring(ppidString.length).trim(); - const processName = path.basename(fullCommand.split(' ')[0]); - return { - parentPid: isNaN(parentPid) ? 1 : parentPid, - name: processName, - command: fullCommand, - }; + try { + const platform = os.platform(); + if (platform === 'win32') { + const powershellCommand = [ + '$p = Get-CimInstance Win32_Process', + `-Filter 'ProcessId=${pid}'`, + '-ErrorAction SilentlyContinue;', + 'if ($p) {', + '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', + '| ConvertTo-Json', + '}', + ].join(' '); + const { stdout } = await execAsync(`powershell "${powershellCommand}"`); + const output = stdout.trim(); + if (!output) return { parentPid: 0, name: '', command: '' }; + const { + Name = '', + ParentProcessId = 0, + CommandLine = '', + } = JSON.parse(output); + return { parentPid: ParentProcessId, name: Name, command: CommandLine }; + } else { + const command = `ps -o ppid=,command= -p ${pid}`; + const { stdout } = await execAsync(command); + const trimmedStdout = stdout.trim(); + const ppidString = trimmedStdout.split(/\s+/)[0]; + const parentPid = parseInt(ppidString, 10); + const fullCommand = trimmedStdout.substring(ppidString.length).trim(); + const processName = path.basename(fullCommand.split(' ')[0]); + return { + parentPid: isNaN(parentPid) ? 1 : parentPid, + name: processName, + command: fullCommand, + }; + } + } catch (_e) { + console.debug(`Failed to get process info for pid ${pid}:`, _e); + return { parentPid: 0, name: '', command: '' }; } } @@ -169,7 +174,6 @@ async function getIdeProcessInfoForWindows(): Promise<{ * top-level ancestor process ID and command as a fallback. * * @returns A promise that resolves to the PID and command of the IDE process. - * @throws Will throw an error if the underlying shell commands fail. */ export async function getIdeProcessInfo(): Promise<{ pid: number; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index eddc9a59cf..366aa41618 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -209,7 +209,7 @@ describe('ClearcutLogger', () => { const cli_version = CLI_VERSION; const git_commit_hash = GIT_COMMIT_INFO; const prompt_id = 'my-prompt-123'; - const user_settings = safeJsonStringify([{ smart_edit_enabled: true }]); + const user_settings = safeJsonStringify([{ smart_edit_enabled: false }]); // Setup logger with expected values const { logger, loggerConfig } = setup({ diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index bcdea047cb..e63b06f932 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -24,6 +24,7 @@ import type { InvalidChunkEvent, ContentRetryEvent, ContentRetryFailureEvent, + ExtensionInstallEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -55,6 +56,7 @@ export enum EventNames { INVALID_CHUNK = 'invalid_chunk', CONTENT_RETRY = 'content_retry', CONTENT_RETRY_FAILURE = 'content_retry_failure', + EXTENSION_INSTALL = 'extension_install', } export interface LogResponse { @@ -825,6 +827,32 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logExtensionInstallEvent(event: ExtensionInstallEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, + value: event.extension_name, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION, + value: event.extension_version, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE, + value: event.extension_source, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_INSTALL_STATUS, + value: event.status, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.EXTENSION_INSTALL, data), + ); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 31e718a466..2351c085ed 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -331,4 +331,20 @@ export enum EventMetadataKey { // Logs the current nodejs version GEMINI_CLI_NODE_VERSION = 83, + + // ========================================================================== + // Extension Install Event Keys + // =========================================================================== + + // Logs the name of the extension. + GEMINI_CLI_EXTENSION_NAME = 85, + + // Logs the version of the extension. + GEMINI_CLI_EXTENSION_VERSION = 86, + + // Logs the source of the extension. + GEMINI_CLI_EXTENSION_SOURCE = 87, + + // Logs the status of the extension install. + GEMINI_CLI_EXTENSION_INSTALL_STATUS = 88, } diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 804b0c0304..22800f78fd 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -517,4 +517,28 @@ export type TelemetryEvent = | FileOperationEvent | InvalidChunkEvent | ContentRetryEvent - | ContentRetryFailureEvent; + | ContentRetryFailureEvent + | ExtensionInstallEvent; + +export class ExtensionInstallEvent implements BaseTelemetryEvent { + 'event.name': 'extension_install'; + 'event.timestamp': string; + extension_name: string; + extension_version: string; + extension_source: string; + status: 'success' | 'error'; + + constructor( + extension_name: string, + extension_version: string, + extension_source: string, + status: 'success' | 'error', + ) { + this['event.name'] = 'extension_install'; + this['event.timestamp'] = new Date().toISOString(); + this.extension_name = extension_name; + this.extension_version = extension_version; + this.extension_source = extension_source; + this.status = status; + } +} diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f8a15e3b74..11f6e71c89 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index f83e8fb566..3dde3a4b5d 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "gemini-cli-vscode-ide-companion", "displayName": "Gemini CLI Companion", "description": "Enable Gemini CLI with direct access to your IDE workspace.", - "version": "0.4.0-preview", + "version": "0.4.0-preview.2", "publisher": "google", "icon": "assets/icon.png", "repository": {