From a9f04eba2ccbd3ed85e60670d82833dfc0c5233f Mon Sep 17 00:00:00 2001 From: Hyeladi Bassi <56229133+HyeladiBassi@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:18:27 +0100 Subject: [PATCH 001/136] refactor(telemetry): enhance flushToClearcut method with retry logic and early return for empty events (#1601) Co-authored-by: Scott Densmore --- .../clearcut-logger/clearcut-logger.ts | 126 ++++++++++-------- packages/core/src/utils/retry.test.ts | 7 +- packages/core/src/utils/retry.ts | 4 + 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index d36a16b5..fd5a9ab2 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -22,12 +22,13 @@ import { } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; -import { getInstallationId } from '../../utils/user_id.js'; +import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { getCachedGoogleAccount, getLifetimeGoogleAccounts, } from '../../utils/user_account.js'; -import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; +import { HttpError, retryWithBackoff } from '../../utils/retry.js'; +import { getInstallationId } from '../../utils/user_id.js'; const start_session_event_name = 'start_session'; const new_prompt_event_name = 'new_prompt'; @@ -113,66 +114,81 @@ export class ClearcutLogger { }); } - flushToClearcut(): Promise { + async flushToClearcut(): Promise { if (this.config?.getDebugMode()) { console.log('Flushing log events to Clearcut.'); } const eventsToSend = [...this.events]; - this.events.length = 0; + if (eventsToSend.length === 0) { + return {}; + } - return new Promise((resolve, reject) => { - const request = [ - { - log_source_name: 'CONCORD', - request_time_ms: Date.now(), - log_event: eventsToSend, - }, - ]; - const body = safeJsonStringify(request); - const options = { - hostname: 'play.googleapis.com', - path: '/log', - method: 'POST', - headers: { 'Content-Length': Buffer.byteLength(body) }, - }; - const bufs: Buffer[] = []; - const req = https.request( - { - ...options, - agent: this.getProxyAgent(), - }, - (res) => { - res.on('data', (buf) => bufs.push(buf)); - res.on('end', () => { - resolve(Buffer.concat(bufs)); - }); - }, - ); - req.on('error', (e) => { - if (this.config?.getDebugMode()) { - console.log('Clearcut POST request error: ', e); - } - // Add the events back to the front of the queue to be retried. - this.events.unshift(...eventsToSend); - reject(e); + const flushFn = () => + new Promise((resolve, reject) => { + const request = [ + { + log_source_name: 'CONCORD', + request_time_ms: Date.now(), + log_event: eventsToSend, + }, + ]; + const body = safeJsonStringify(request); + const options = { + hostname: 'play.googleapis.com', + path: '/log', + method: 'POST', + headers: { 'Content-Length': Buffer.byteLength(body) }, + }; + const bufs: Buffer[] = []; + const req = https.request( + { + ...options, + agent: this.getProxyAgent(), + }, + (res) => { + if ( + res.statusCode && + (res.statusCode < 200 || res.statusCode >= 300) + ) { + const err: HttpError = new Error( + `Request failed with status ${res.statusCode}`, + ); + err.status = res.statusCode; + res.resume(); + return reject(err); + } + res.on('data', (buf) => bufs.push(buf)); + res.on('end', () => resolve(Buffer.concat(bufs))); + }, + ); + req.on('error', reject); + req.end(body); }); - req.end(body); - }) - .then((buf: Buffer) => { - try { - this.last_flush_time = Date.now(); - return this.decodeLogResponse(buf) || {}; - } catch (error: unknown) { - console.error('Error flushing log events:', error); - return {}; - } - }) - .catch((error: unknown) => { - // Handle all errors to prevent unhandled promise rejections - console.error('Error flushing log events:', error); - // Return empty response to maintain the Promise contract - return {}; + + try { + const responseBuffer = await retryWithBackoff(flushFn, { + maxAttempts: 3, + initialDelayMs: 200, + shouldRetry: (err: unknown) => { + if (!(err instanceof Error)) return false; + const status = (err as HttpError).status as number | undefined; + // If status is not available, it's likely a network error + if (status === undefined) return true; + + // Retry on 429 (Too many Requests) and 5xx server errors. + return status === 429 || (status >= 500 && status < 600); + }, }); + + this.events.splice(0, eventsToSend.length); + this.last_flush_time = Date.now(); + return this.decodeLogResponse(responseBuffer) || {}; + } catch (error) { + if (this.config?.getDebugMode()) { + console.error('Clearcut flush failed after multiple retries.', error); + } + return {}; + } } // Visible for testing. Decodes protobuf-encoded response from Clearcut server. diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index f84d2004..196e7341 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -6,14 +6,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { retryWithBackoff } from './retry.js'; +import { retryWithBackoff, HttpError } from './retry.js'; import { setSimulate429 } from './testUtils.js'; -// Define an interface for the error with a status property -interface HttpError extends Error { - status?: number; -} - // Helper to create a mock function that fails a certain number of times const createFailingFunction = ( failures: number, diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index b29bf7df..81300882 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -10,6 +10,10 @@ import { isGenericQuotaExceededError, } from './quotaErrorDetection.js'; +export interface HttpError extends Error { + status?: number; +} + export interface RetryOptions { maxAttempts: number; initialDelayMs: number; From 36e1e57252c562a3a16dcbdfadf11384419a457d Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:33:58 -0400 Subject: [PATCH 002/136] (docs) - Fix small markdown mistake for custom commands docs (#4983) From b497791c59bc2e6ee14b0b9607adf4c3d6f09ffe Mon Sep 17 00:00:00 2001 From: owenofbrien <86964623+owenofbrien@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:34:39 -0500 Subject: [PATCH 003/136] Propagate user_prompt_id to GenerateConentRequest for logging (#4741) --- .../core/src/code_assist/converter.test.ts | 57 ++++++++++-- packages/core/src/code_assist/converter.ts | 3 + packages/core/src/code_assist/server.test.ts | 78 ++++++++++++---- packages/core/src/code_assist/server.ts | 16 +++- packages/core/src/code_assist/setup.test.ts | 10 ++- packages/core/src/code_assist/setup.ts | 2 +- packages/core/src/core/client.test.ts | 90 +++++++++++-------- packages/core/src/core/client.ts | 37 ++++---- packages/core/src/core/contentGenerator.ts | 2 + packages/core/src/core/geminiChat.test.ts | 26 +++--- packages/core/src/core/geminiChat.ts | 26 +++--- 11 files changed, 245 insertions(+), 102 deletions(-) diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 03f388dc..3d3a8ef3 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -24,7 +24,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq, 'my-project'); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: 'my-project', @@ -37,8 +42,9 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: undefined, + session_id: 'my-session', }, + user_prompt_id: 'my-prompt', }); }); @@ -47,7 +53,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + undefined, + 'my-session', + ); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: undefined, @@ -60,8 +71,9 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: undefined, + session_id: 'my-session', }, + user_prompt_id: 'my-prompt', }); }); @@ -72,6 +84,7 @@ describe('converter', () => { }; const codeAssistReq = toGenerateContentRequest( genaiReq, + 'my-prompt', 'my-project', 'session-123', ); @@ -89,6 +102,7 @@ describe('converter', () => { generationConfig: undefined, session_id: 'session-123', }, + user_prompt_id: 'my-prompt', }); }); @@ -97,7 +111,12 @@ describe('converter', () => { model: 'gemini-pro', contents: 'Hello', }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, ]); @@ -108,7 +127,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ text: 'Hello' }, { text: 'World' }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'user', parts: [{ text: 'World' }] }, @@ -123,7 +147,12 @@ describe('converter', () => { systemInstruction: 'You are a helpful assistant.', }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.systemInstruction).toEqual({ role: 'user', parts: [{ text: 'You are a helpful assistant.' }], @@ -139,7 +168,12 @@ describe('converter', () => { topK: 40, }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.8, topK: 40, @@ -165,7 +199,12 @@ describe('converter', () => { responseMimeType: 'application/json', }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.1, topP: 0.2, diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 8340cfc1..ffd471da 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -32,6 +32,7 @@ import { export interface CAGenerateContentRequest { model: string; project?: string; + user_prompt_id?: string; request: VertexGenerateContentRequest; } @@ -115,12 +116,14 @@ export function fromCountTokenResponse( export function toGenerateContentRequest( req: GenerateContentParameters, + userPromptId: string, project?: string, sessionId?: string, ): CAGenerateContentRequest { return { model: req.model, project, + user_prompt_id: userPromptId, request: toVertexGenerateContentRequest(req, sessionId), }; } diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 6246fd4e..3fc1891f 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -14,13 +14,25 @@ vi.mock('google-auth-library'); describe('CodeAssistServer', () => { it('should be able to be constructed', () => { const auth = new OAuth2Client(); - const server = new CodeAssistServer(auth, 'test-project'); + const server = new CodeAssistServer( + auth, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); expect(server).toBeInstanceOf(CodeAssistServer); }); it('should call the generateContent endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { response: { candidates: [ @@ -38,10 +50,13 @@ describe('CodeAssistServer', () => { }; vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - const response = await server.generateContent({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }); + const response = await server.generateContent( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + ); expect(server.requestPost).toHaveBeenCalledWith( 'generateContent', @@ -55,7 +70,13 @@ describe('CodeAssistServer', () => { it('should call the generateContentStream endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = (async function* () { yield { response: { @@ -75,10 +96,13 @@ describe('CodeAssistServer', () => { })(); vi.spyOn(server, 'requestStreamingPost').mockResolvedValue(mockResponse); - const stream = await server.generateContentStream({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }); + const stream = await server.generateContentStream( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + ); for await (const res of stream) { expect(server.requestStreamingPost).toHaveBeenCalledWith( @@ -92,7 +116,13 @@ describe('CodeAssistServer', () => { it('should call the onboardUser endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { name: 'operations/123', done: true, @@ -114,7 +144,13 @@ describe('CodeAssistServer', () => { it('should call the loadCodeAssist endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { currentTier: { id: UserTierId.FREE, @@ -140,7 +176,13 @@ describe('CodeAssistServer', () => { it('should return 0 for countTokens', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { totalTokens: 100, }; @@ -155,7 +197,13 @@ describe('CodeAssistServer', () => { it('should throw an error for embedContent', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); await expect( server.embedContent({ model: 'test-model', diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 7af643f7..08339bdc 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -53,10 +53,16 @@ export class CodeAssistServer implements ContentGenerator { async generateContentStream( req: GenerateContentParameters, + userPromptId: string, ): Promise> { const resps = await this.requestStreamingPost( 'streamGenerateContent', - toGenerateContentRequest(req, this.projectId, this.sessionId), + toGenerateContentRequest( + req, + userPromptId, + this.projectId, + this.sessionId, + ), req.config?.abortSignal, ); return (async function* (): AsyncGenerator { @@ -68,10 +74,16 @@ export class CodeAssistServer implements ContentGenerator { async generateContent( req: GenerateContentParameters, + userPromptId: string, ): Promise { const resp = await this.requestPost( 'generateContent', - toGenerateContentRequest(req, this.projectId, this.sessionId), + toGenerateContentRequest( + req, + userPromptId, + this.projectId, + this.sessionId, + ), req.config?.abortSignal, ); return fromGenerateContentResponse(resp); diff --git a/packages/core/src/code_assist/setup.test.ts b/packages/core/src/code_assist/setup.test.ts index 6db5fd88..c1260e3f 100644 --- a/packages/core/src/code_assist/setup.test.ts +++ b/packages/core/src/code_assist/setup.test.ts @@ -49,8 +49,11 @@ describe('setupUser', () => { }); await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - expect.any(Object), + {}, 'test-project', + {}, + '', + undefined, ); }); @@ -62,7 +65,10 @@ describe('setupUser', () => { }); const projectId = await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - expect.any(Object), + {}, + undefined, + {}, + '', undefined, ); expect(projectId).toEqual({ diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts index 8831d24b..9c7a8043 100644 --- a/packages/core/src/code_assist/setup.ts +++ b/packages/core/src/code_assist/setup.ts @@ -34,7 +34,7 @@ export interface UserData { */ export async function setupUser(client: OAuth2Client): Promise { let projectId = process.env.GOOGLE_CLOUD_PROJECT || undefined; - const caServer = new CodeAssistServer(client, projectId); + const caServer = new CodeAssistServer(client, projectId, {}, '', undefined); const clientMetadata: ClientMetadata = { ideType: 'IDE_UNSPECIFIED', diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 25ea9bc1..5101f98b 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -209,7 +209,9 @@ describe('Gemini Client (client.ts)', () => { // We can instantiate the client here since Config is mocked // and the constructor will use the mocked GoogleGenAI - client = new GeminiClient(new Config({} as never)); + client = new GeminiClient( + new Config({ sessionId: 'test-session-id' } as never), + ); mockConfigObject.getGeminiClient.mockReturnValue(client); await client.initialize(contentGeneratorConfig); @@ -348,16 +350,19 @@ describe('Gemini Client (client.ts)', () => { await client.generateContent(contents, generationConfig, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: 'test-model', - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.5, - topP: 1, + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: 'test-model', + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.5, + topP: 1, + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); }); @@ -376,18 +381,21 @@ describe('Gemini Client (client.ts)', () => { await client.generateJson(contents, schema, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: 'test-model', // Should use current model from config - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0, - topP: 1, - responseSchema: schema, - responseMimeType: 'application/json', + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: 'test-model', // Should use current model from config + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0, + topP: 1, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); it('should allow overriding model and config', async () => { @@ -411,19 +419,22 @@ describe('Gemini Client (client.ts)', () => { customConfig, ); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: customModel, - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.9, - topP: 1, // from default - topK: 20, - responseSchema: schema, - responseMimeType: 'application/json', + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: customModel, + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.9, + topP: 1, // from default + topK: 20, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); }); @@ -1006,11 +1017,14 @@ Here are files the user has recently opened, with the most recent at the top: config: expect.any(Object), contents, }); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: currentModel, - config: expect.any(Object), - contents, - }); + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: currentModel, + config: expect.any(Object), + contents, + }, + 'test-session-id', + ); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 77683a45..02fbeb38 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -106,7 +106,7 @@ export class GeminiClient { private readonly COMPRESSION_PRESERVE_THRESHOLD = 0.3; private readonly loopDetector: LoopDetectionService; - private lastPromptId?: string; + private lastPromptId: string; constructor(private config: Config) { if (config.getProxy()) { @@ -115,6 +115,7 @@ export class GeminiClient { this.embeddingModel = config.getEmbeddingModel(); this.loopDetector = new LoopDetectionService(config); + this.lastPromptId = this.config.getSessionId(); } async initialize(contentGeneratorConfig: ContentGeneratorConfig) { @@ -427,16 +428,19 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent({ - model: modelToUse, - config: { - ...requestConfig, - systemInstruction, - responseSchema: schema, - responseMimeType: 'application/json', + this.getContentGenerator().generateContent( + { + model: modelToUse, + config: { + ...requestConfig, + systemInstruction, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + this.lastPromptId, + ); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => @@ -521,11 +525,14 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent({ - model: modelToUse, - config: requestConfig, - contents, - }); + this.getContentGenerator().generateContent( + { + model: modelToUse, + config: requestConfig, + contents, + }, + this.lastPromptId, + ); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 44ed7beb..797bad73 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -25,10 +25,12 @@ import { UserTierId } from '../code_assist/types.js'; export interface ContentGenerator { generateContent( request: GenerateContentParameters, + userPromptId: string, ): Promise; generateContentStream( request: GenerateContentParameters, + userPromptId: string, ): Promise>; countTokens(request: CountTokensParameters): Promise; diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 39dd883e..cd5e3841 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -79,11 +79,14 @@ describe('GeminiChat', () => { await chat.sendMessage({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContent).toHaveBeenCalledWith({ - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }); + expect(mockModelsModule.generateContent).toHaveBeenCalledWith( + { + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }, + 'prompt-id-1', + ); }); }); @@ -111,11 +114,14 @@ describe('GeminiChat', () => { await chat.sendMessageStream({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith({ - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }); + expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith( + { + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }, + 'prompt-id-1', + ); }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 4c3cd4c8..14e8d946 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -286,11 +286,14 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContent({ - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }); + return this.contentGenerator.generateContent( + { + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }, + prompt_id, + ); }; response = await retryWithBackoff(apiCall, { @@ -393,11 +396,14 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContentStream({ - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }); + return this.contentGenerator.generateContentStream( + { + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }, + prompt_id, + ); }; // Note: Retrying streams can be complex. If generateContentStream itself doesn't handle retries From 0b5cc9636243574c4212e969621ea65f03e7e88a Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:40:55 -0400 Subject: [PATCH 004/136] (model) - Use Flash Lite For Next Speaker Checks (#4991) --- packages/core/src/config/models.ts | 1 + packages/core/src/core/tokenLimits.ts | 1 + packages/core/src/utils/nextSpeakerChecker.test.ts | 4 ++-- packages/core/src/utils/nextSpeakerChecker.ts | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 07e0e051..e879268b 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -6,4 +6,5 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; +export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001'; diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 1c7fbde9..d238cdb3 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -21,6 +21,7 @@ export function tokenLimit(model: Model): TokenCount { case 'gemini-2.5-pro': case 'gemini-2.5-flash-preview-05-20': case 'gemini-2.5-flash': + case 'gemini-2.5-flash-lite': case 'gemini-2.0-flash': return 1_048_576; case 'gemini-2.0-flash-preview-image-generation': diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 9141105f..70d6023f 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest'; import { Content, GoogleGenAI, Models } from '@google/genai'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; import { GeminiClient } from '../core/client.js'; import { Config } from '../config/config.js'; import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js'; @@ -248,6 +248,6 @@ describe('checkNextSpeaker', () => { expect(mockGeminiClient.generateJson).toHaveBeenCalled(); const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock .calls[0]; - expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_MODEL); + expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); }); }); diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index 9d428887..c75bf645 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -5,7 +5,7 @@ */ import { Content, SchemaUnion, Type } from '@google/genai'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; import { GeminiClient } from '../core/client.js'; import { GeminiChat } from '../core/geminiChat.js'; import { isFunctionResponse } from './messageInspectors.js'; @@ -132,7 +132,7 @@ export async function checkNextSpeaker( contents, RESPONSE_SCHEMA, abortSignal, - DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, )) as unknown as NextSpeakerResponse; if ( From 9ca48c00a6bfbf8fd25bebfb703ef299c0e38ae2 Mon Sep 17 00:00:00 2001 From: Leeroy Ding Date: Sun, 27 Jul 2025 22:42:26 +0100 Subject: [PATCH 005/136] fix: yolo mode not respected (#4972) --- .../cli/src/ui/hooks/useReactToolScheduler.ts | 1 - .../core/src/core/coreToolScheduler.test.ts | 86 +++++++++++++++++++ packages/core/src/core/coreToolScheduler.ts | 5 +- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 22988fef..307a90cf 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -138,7 +138,6 @@ export function useReactToolScheduler( outputUpdateHandler, onAllToolCallsComplete: allToolCallsCompleteHandler, onToolCallsUpdate: toolCallsUpdateHandler, - approvalMode: config.getApprovalMode(), getPreferredEditor, config, }), diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 7b6a130c..80651a14 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -20,6 +20,7 @@ import { ToolResult, Config, Icon, + ApprovalMode, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; @@ -126,6 +127,7 @@ describe('CoreToolScheduler', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -194,6 +196,7 @@ describe('CoreToolScheduler with payload', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -470,6 +473,7 @@ describe('CoreToolScheduler edit cancellation', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -527,3 +531,85 @@ describe('CoreToolScheduler edit cancellation', () => { expect(cancelledCall.response.resultDisplay.fileName).toBe('test.txt'); }); }); + +describe('CoreToolScheduler YOLO mode', () => { + it('should execute tool requiring confirmation directly without waiting', async () => { + // Arrange + const mockTool = new MockTool(); + // This tool would normally require confirmation. + mockTool.shouldConfirm = true; + + const toolRegistry = { + getTool: () => mockTool, + getToolByName: () => mockTool, + // Other properties are not needed for this test but are included for type consistency. + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {} as any, + registerTool: () => {}, + getToolByDisplayName: () => mockTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + }; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + // Configure the scheduler for YOLO mode. + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + toolRegistry: Promise.resolve(toolRegistry as any), + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + }); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'mockTool', + args: { param: 'value' }, + isClientInitiated: false, + prompt_id: 'prompt-id-yolo', + }; + + // Act + await scheduler.schedule([request], abortController.signal); + + // Assert + // 1. The tool's execute method was called directly. + expect(mockTool.executeFn).toHaveBeenCalledWith({ param: 'value' }); + + // 2. The tool call status never entered 'awaiting_approval'. + const statusUpdates = onToolCallsUpdate.mock.calls + .map((call) => (call[0][0] as ToolCall)?.status) + .filter(Boolean); + expect(statusUpdates).not.toContain('awaiting_approval'); + expect(statusUpdates).toEqual([ + 'validating', + 'scheduled', + 'executing', + 'success', + ]); + + // 3. The final callback indicates the tool call was successful. + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(1); + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('success'); + if (completedCall.status === 'success') { + expect(completedCall.response.resultDisplay).toBe('Tool executed'); + } + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 0d7d5923..af078faa 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -219,7 +219,6 @@ interface CoreToolSchedulerOptions { outputUpdateHandler?: OutputUpdateHandler; onAllToolCallsComplete?: AllToolCallsCompleteHandler; onToolCallsUpdate?: ToolCallsUpdateHandler; - approvalMode?: ApprovalMode; getPreferredEditor: () => EditorType | undefined; config: Config; } @@ -230,7 +229,6 @@ export class CoreToolScheduler { private outputUpdateHandler?: OutputUpdateHandler; private onAllToolCallsComplete?: AllToolCallsCompleteHandler; private onToolCallsUpdate?: ToolCallsUpdateHandler; - private approvalMode: ApprovalMode; private getPreferredEditor: () => EditorType | undefined; private config: Config; @@ -240,7 +238,6 @@ export class CoreToolScheduler { this.outputUpdateHandler = options.outputUpdateHandler; this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onToolCallsUpdate = options.onToolCallsUpdate; - this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT; this.getPreferredEditor = options.getPreferredEditor; } @@ -462,7 +459,7 @@ export class CoreToolScheduler { const { request: reqInfo, tool: toolInstance } = toolCall; try { - if (this.approvalMode === ApprovalMode.YOLO) { + if (this.config.getApprovalMode() === ApprovalMode.YOLO) { this.setStatusInternal(reqInfo.callId, 'scheduled'); } else { const confirmationDetails = await toolInstance.shouldConfirmExecute( From bce6eb501462b32fc629b6febb357ab679b6bd05 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi <131056197+flowernotfound@users.noreply.github.com> Date: Mon, 28 Jul 2025 07:18:12 +0900 Subject: [PATCH 006/136] feat(chat): Implement /chat delete command (#2401) --- .../cli/src/ui/commands/chatCommand.test.ts | 70 ++++++++++++++++++- packages/cli/src/ui/commands/chatCommand.ts | 42 ++++++++++- packages/core/src/core/logger.test.ts | 62 ++++++++++++++++ packages/core/src/core/logger.ts | 24 +++++++ 4 files changed, 194 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 0c98239a..aad0897c 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -40,14 +40,17 @@ describe('chatCommand', () => { let mockGetChat: ReturnType; let mockSaveCheckpoint: ReturnType; let mockLoadCheckpoint: ReturnType; + let mockDeleteCheckpoint: ReturnType; let mockGetHistory: ReturnType; - const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => { + const getSubCommand = ( + name: 'list' | 'save' | 'resume' | 'delete', + ): SlashCommand => { const subCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === name, ); if (!subCommand) { - throw new Error(`/memory ${name} command not found.`); + throw new Error(`/chat ${name} command not found.`); } return subCommand; }; @@ -59,6 +62,7 @@ describe('chatCommand', () => { }); mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); mockLoadCheckpoint = vi.fn().mockResolvedValue([]); + mockDeleteCheckpoint = vi.fn().mockResolvedValue(true); mockContext = createMockCommandContext({ services: { @@ -72,6 +76,7 @@ describe('chatCommand', () => { logger: { saveCheckpoint: mockSaveCheckpoint, loadCheckpoint: mockLoadCheckpoint, + deleteCheckpoint: mockDeleteCheckpoint, initialize: vi.fn().mockResolvedValue(undefined), }, }, @@ -85,7 +90,7 @@ describe('chatCommand', () => { it('should have the correct main command definition', () => { expect(chatCommand.name).toBe('chat'); expect(chatCommand.description).toBe('Manage conversation history.'); - expect(chatCommand.subCommands).toHaveLength(3); + expect(chatCommand.subCommands).toHaveLength(4); }); describe('list subcommand', () => { @@ -297,4 +302,63 @@ describe('chatCommand', () => { }); }); }); + + describe('delete subcommand', () => { + let deleteCommand: SlashCommand; + const tag = 'my-tag'; + beforeEach(() => { + deleteCommand = getSubCommand('delete'); + }); + + it('should return an error if tag is missing', async () => { + const result = await deleteCommand?.action?.(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /chat delete ', + }); + }); + + it('should return an error if checkpoint is not found', async () => { + mockDeleteCheckpoint.mockResolvedValue(false); + const result = await deleteCommand?.action?.(mockContext, tag); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Error: No checkpoint found with tag '${tag}'.`, + }); + }); + + it('should delete the conversation', async () => { + const result = await deleteCommand?.action?.(mockContext, tag); + + expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Conversation checkpoint '${tag}' has been deleted.`, + }); + }); + + describe('completion', () => { + it('should provide completion suggestions', async () => { + const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; + mockFs.readdir.mockImplementation( + (async (_: string): Promise => + fakeFiles as string[]) as unknown as typeof fsPromises.readdir, + ); + + mockFs.stat.mockImplementation( + (async (_: string): Promise => + ({ + mtime: new Date(), + }) as Stats) as unknown as typeof fsPromises.stat, + ); + + const result = await deleteCommand?.completion?.(mockContext, 'a'); + + expect(result).toEqual(['alpha']); + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 739097e3..a5fa13da 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -206,9 +206,49 @@ const resumeCommand: SlashCommand = { }, }; +const deleteCommand: SlashCommand = { + name: 'delete', + description: 'Delete a conversation checkpoint. Usage: /chat delete ', + kind: CommandKind.BUILT_IN, + action: async (context, args): Promise => { + const tag = args.trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /chat delete ', + }; + } + + const { logger } = context.services; + await logger.initialize(); + const deleted = await logger.deleteCheckpoint(tag); + + if (deleted) { + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint '${tag}' has been deleted.`, + }; + } else { + return { + type: 'message', + messageType: 'error', + content: `Error: No checkpoint found with tag '${tag}'.`, + }; + } + }, + completion: async (context, partialArg) => { + const chatDetails = await getSavedChatTags(context, true); + return chatDetails + .map((chat) => chat.name) + .filter((name) => name.startsWith(partialArg)); + }, +}; + export const chatCommand: SlashCommand = { name: 'chat', description: 'Manage conversation history.', kind: CommandKind.BUILT_IN, - subCommands: [listCommand, saveCommand, resumeCommand], + subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand], }; diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index 84cc1a0f..c74b92cf 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -490,6 +490,68 @@ describe('Logger', () => { }); }); + describe('deleteCheckpoint', () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'Content to be deleted' }] }, + ]; + const tag = 'delete-me'; + let taggedFilePath: string; + + beforeEach(async () => { + taggedFilePath = path.join( + TEST_GEMINI_DIR, + `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`, + ); + // Create a file to be deleted + await fs.writeFile(taggedFilePath, JSON.stringify(conversation)); + }); + + it('should delete the specified checkpoint file and return true', async () => { + const result = await logger.deleteCheckpoint(tag); + expect(result).toBe(true); + + // Verify the file is actually gone + await expect(fs.access(taggedFilePath)).rejects.toThrow(/ENOENT/); + }); + + it('should return false if the checkpoint file does not exist', async () => { + const result = await logger.deleteCheckpoint('non-existent-tag'); + expect(result).toBe(false); + }); + + it('should re-throw an error if file deletion fails for reasons other than not existing', async () => { + // Simulate a different error (e.g., permission denied) + vi.spyOn(fs, 'unlink').mockRejectedValueOnce( + new Error('EACCES: permission denied'), + ); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(logger.deleteCheckpoint(tag)).rejects.toThrow( + 'EACCES: permission denied', + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Failed to delete checkpoint file ${taggedFilePath}:`, + expect.any(Error), + ); + }); + + it('should return false if logger is not initialized', async () => { + const uninitializedLogger = new Logger(testSessionId); + uninitializedLogger.close(); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await uninitializedLogger.deleteCheckpoint(tag); + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Logger not initialized or checkpoint file path not set. Cannot delete checkpoint.', + ); + }); + }); + describe('close', () => { it('should reset logger state', async () => { await logger.logMessage(MessageSenderType.USER, 'A message'); diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 450a0d2f..2be9f1d4 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -292,6 +292,30 @@ export class Logger { } } + async deleteCheckpoint(tag: string): Promise { + if (!this.initialized || !this.geminiDir) { + console.error( + 'Logger not initialized or checkpoint file path not set. Cannot delete checkpoint.', + ); + return false; + } + + const path = this._checkpointPath(tag); + + try { + await fs.unlink(path); + return true; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + // File doesn't exist, which is fine. + return false; + } + console.error(`Failed to delete checkpoint file ${path}:`, error); + throw error; + } + } + close(): void { this.initialized = false; this.logFilePath = undefined; From ab0d9df658b809abaf2b7e0ca8c6b844074b5953 Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Sun, 27 Jul 2025 15:24:53 -0700 Subject: [PATCH 007/136] Clarify ToS and privacy documentation FAQs. (#4899) --- docs/tos-privacy.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/tos-privacy.md b/docs/tos-privacy.md index b2cbbc29..f7c8afe5 100644 --- a/docs/tos-privacy.md +++ b/docs/tos-privacy.md @@ -63,6 +63,8 @@ You may opt-out from sending Usage Statistics to Google by following the instruc Whether your code, including prompts and answers, is used to train Google's models depends on the type of authentication method you use and your account type. +By default (if you have not opted out): + - **Google account with Gemini Code Assist for Individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice, your **prompts, answers, and related code are collected** and may be used to improve Google's products, including for model training. - **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: No. For these accounts, your data is governed by the [Gemini Code Assist Privacy Notices](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models. @@ -71,17 +73,21 @@ Whether your code, including prompts and answers, is used to train Google's mode - **Paid services**: No. When you use the Gemini API key via the Gemini Developer API with a paid service, the [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) terms apply, which treats your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models. - **Gemini API key via the Vertex AI GenAI API**: No. For these accounts, your data is governed by the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models. +For more information about opting out, refer to the next question. + ### 2. What are Usage Statistics and what does the opt-out control? The **Usage Statistics** setting is the single control for all optional data collection in the Gemini CLI. The data it collects depends on your account and authentication type: -- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers** for model improvement. -- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting. +- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. +- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting. - **Gemini API key via the Gemini Developer API**: - **Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid). + **Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid). **Paid services**: This setting only controls the collection of anonymous telemetry. Google logs prompts and responses for a limited period of time, solely for the purpose of detecting violations of the Prohibited Use Policy and any required legal or regulatory disclosures. -- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting. +- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting. + +Please refer to the Privacy Notice that applies to your authentication method for more information about what data is collected and how this data is used. You can disable Usage Statistics for any account type by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation. From 9ed351260cc64d955580440da866098e6e5bddfb Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Sun, 27 Jul 2025 15:25:04 -0700 Subject: [PATCH 008/136] Update documentation for read_many_files. (#4874) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/tools/multi-file.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tools/multi-file.md b/docs/tools/multi-file.md index 0cd1e19e..1bc495f6 100644 --- a/docs/tools/multi-file.md +++ b/docs/tools/multi-file.md @@ -11,11 +11,13 @@ Use `read_many_files` to read content from multiple files specified by paths or `read_many_files` can be used to perform tasks such as getting an overview of a codebase, finding where specific functionality is implemented, reviewing documentation, or gathering context from multiple configuration files. +**Note:** `read_many_files` looks for files following the provided paths or glob patterns. A directory path such as `"/docs"` will return an empty result; the tool requires a pattern such as `"/docs/*"` or `"/docs/*.md"` to identify the relevant files. + ### Arguments `read_many_files` takes the following arguments: -- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/", "assets/logo.png"]`). +- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/*", "assets/logo.png"]`). - `exclude` (list[string], optional): Glob patterns for files/directories to exclude (e.g., `["**/*.log", "temp/"]`). These are added to default excludes if `useDefaultExcludes` is true. - `include` (list[string], optional): Additional glob patterns to include. These are merged with `paths` (e.g., `["*.test.ts"]` to specifically add test files if they were broadly excluded, or `["images/*.jpg"]` to include specific image types). - `recursive` (boolean, optional): Whether to search recursively. This is primarily controlled by `**` in glob patterns. Defaults to `true`. From bd850704113c415d107732d6230f72b3699e98c6 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Sun, 27 Jul 2025 19:28:20 -0700 Subject: [PATCH 009/136] Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) --- .../core/src/code_assist/converter.test.ts | 57 ++---------- packages/core/src/code_assist/converter.ts | 3 - packages/core/src/code_assist/server.test.ts | 78 ++++------------ packages/core/src/code_assist/server.ts | 16 +--- packages/core/src/code_assist/setup.test.ts | 10 +-- packages/core/src/code_assist/setup.ts | 2 +- packages/core/src/core/client.test.ts | 90 ++++++++----------- packages/core/src/core/client.ts | 37 ++++---- packages/core/src/core/contentGenerator.ts | 2 - packages/core/src/core/geminiChat.test.ts | 26 +++--- packages/core/src/core/geminiChat.ts | 26 +++--- 11 files changed, 102 insertions(+), 245 deletions(-) diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 3d3a8ef3..03f388dc 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -24,12 +24,7 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq, 'my-project'); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: 'my-project', @@ -42,9 +37,8 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: 'my-session', + session_id: undefined, }, - user_prompt_id: 'my-prompt', }); }); @@ -53,12 +47,7 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - undefined, - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: undefined, @@ -71,9 +60,8 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: 'my-session', + session_id: undefined, }, - user_prompt_id: 'my-prompt', }); }); @@ -84,7 +72,6 @@ describe('converter', () => { }; const codeAssistReq = toGenerateContentRequest( genaiReq, - 'my-prompt', 'my-project', 'session-123', ); @@ -102,7 +89,6 @@ describe('converter', () => { generationConfig: undefined, session_id: 'session-123', }, - user_prompt_id: 'my-prompt', }); }); @@ -111,12 +97,7 @@ describe('converter', () => { model: 'gemini-pro', contents: 'Hello', }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, ]); @@ -127,12 +108,7 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ text: 'Hello' }, { text: 'World' }], }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'user', parts: [{ text: 'World' }] }, @@ -147,12 +123,7 @@ describe('converter', () => { systemInstruction: 'You are a helpful assistant.', }, }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq.request.systemInstruction).toEqual({ role: 'user', parts: [{ text: 'You are a helpful assistant.' }], @@ -168,12 +139,7 @@ describe('converter', () => { topK: 40, }, }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.8, topK: 40, @@ -199,12 +165,7 @@ describe('converter', () => { responseMimeType: 'application/json', }, }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); + const codeAssistReq = toGenerateContentRequest(genaiReq); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.1, topP: 0.2, diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index ffd471da..8340cfc1 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -32,7 +32,6 @@ import { export interface CAGenerateContentRequest { model: string; project?: string; - user_prompt_id?: string; request: VertexGenerateContentRequest; } @@ -116,14 +115,12 @@ export function fromCountTokenResponse( export function toGenerateContentRequest( req: GenerateContentParameters, - userPromptId: string, project?: string, sessionId?: string, ): CAGenerateContentRequest { return { model: req.model, project, - user_prompt_id: userPromptId, request: toVertexGenerateContentRequest(req, sessionId), }; } diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 3fc1891f..6246fd4e 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -14,25 +14,13 @@ vi.mock('google-auth-library'); describe('CodeAssistServer', () => { it('should be able to be constructed', () => { const auth = new OAuth2Client(); - const server = new CodeAssistServer( - auth, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(auth, 'test-project'); expect(server).toBeInstanceOf(CodeAssistServer); }); it('should call the generateContent endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); const mockResponse = { response: { candidates: [ @@ -50,13 +38,10 @@ describe('CodeAssistServer', () => { }; vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - const response = await server.generateContent( - { - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }, - 'user-prompt-id', - ); + const response = await server.generateContent({ + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }); expect(server.requestPost).toHaveBeenCalledWith( 'generateContent', @@ -70,13 +55,7 @@ describe('CodeAssistServer', () => { it('should call the generateContentStream endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); const mockResponse = (async function* () { yield { response: { @@ -96,13 +75,10 @@ describe('CodeAssistServer', () => { })(); vi.spyOn(server, 'requestStreamingPost').mockResolvedValue(mockResponse); - const stream = await server.generateContentStream( - { - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }, - 'user-prompt-id', - ); + const stream = await server.generateContentStream({ + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }); for await (const res of stream) { expect(server.requestStreamingPost).toHaveBeenCalledWith( @@ -116,13 +92,7 @@ describe('CodeAssistServer', () => { it('should call the onboardUser endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); const mockResponse = { name: 'operations/123', done: true, @@ -144,13 +114,7 @@ describe('CodeAssistServer', () => { it('should call the loadCodeAssist endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); const mockResponse = { currentTier: { id: UserTierId.FREE, @@ -176,13 +140,7 @@ describe('CodeAssistServer', () => { it('should return 0 for countTokens', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); const mockResponse = { totalTokens: 100, }; @@ -197,13 +155,7 @@ describe('CodeAssistServer', () => { it('should throw an error for embedContent', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); + const server = new CodeAssistServer(client, 'test-project'); await expect( server.embedContent({ model: 'test-model', diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 08339bdc..7af643f7 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -53,16 +53,10 @@ export class CodeAssistServer implements ContentGenerator { async generateContentStream( req: GenerateContentParameters, - userPromptId: string, ): Promise> { const resps = await this.requestStreamingPost( 'streamGenerateContent', - toGenerateContentRequest( - req, - userPromptId, - this.projectId, - this.sessionId, - ), + toGenerateContentRequest(req, this.projectId, this.sessionId), req.config?.abortSignal, ); return (async function* (): AsyncGenerator { @@ -74,16 +68,10 @@ export class CodeAssistServer implements ContentGenerator { async generateContent( req: GenerateContentParameters, - userPromptId: string, ): Promise { const resp = await this.requestPost( 'generateContent', - toGenerateContentRequest( - req, - userPromptId, - this.projectId, - this.sessionId, - ), + toGenerateContentRequest(req, this.projectId, this.sessionId), req.config?.abortSignal, ); return fromGenerateContentResponse(resp); diff --git a/packages/core/src/code_assist/setup.test.ts b/packages/core/src/code_assist/setup.test.ts index c1260e3f..6db5fd88 100644 --- a/packages/core/src/code_assist/setup.test.ts +++ b/packages/core/src/code_assist/setup.test.ts @@ -49,11 +49,8 @@ describe('setupUser', () => { }); await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - {}, + expect.any(Object), 'test-project', - {}, - '', - undefined, ); }); @@ -65,10 +62,7 @@ describe('setupUser', () => { }); const projectId = await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - {}, - undefined, - {}, - '', + expect.any(Object), undefined, ); expect(projectId).toEqual({ diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts index 9c7a8043..8831d24b 100644 --- a/packages/core/src/code_assist/setup.ts +++ b/packages/core/src/code_assist/setup.ts @@ -34,7 +34,7 @@ export interface UserData { */ export async function setupUser(client: OAuth2Client): Promise { let projectId = process.env.GOOGLE_CLOUD_PROJECT || undefined; - const caServer = new CodeAssistServer(client, projectId, {}, '', undefined); + const caServer = new CodeAssistServer(client, projectId); const clientMetadata: ClientMetadata = { ideType: 'IDE_UNSPECIFIED', diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 5101f98b..25ea9bc1 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -209,9 +209,7 @@ describe('Gemini Client (client.ts)', () => { // We can instantiate the client here since Config is mocked // and the constructor will use the mocked GoogleGenAI - client = new GeminiClient( - new Config({ sessionId: 'test-session-id' } as never), - ); + client = new GeminiClient(new Config({} as never)); mockConfigObject.getGeminiClient.mockReturnValue(client); await client.initialize(contentGeneratorConfig); @@ -350,19 +348,16 @@ describe('Gemini Client (client.ts)', () => { await client.generateContent(contents, generationConfig, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith( - { - model: 'test-model', - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.5, - topP: 1, - }, - contents, + expect(mockGenerateContentFn).toHaveBeenCalledWith({ + model: 'test-model', + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.5, + topP: 1, }, - 'test-session-id', - ); + contents, + }); }); }); @@ -381,21 +376,18 @@ describe('Gemini Client (client.ts)', () => { await client.generateJson(contents, schema, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith( - { - model: 'test-model', // Should use current model from config - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0, - topP: 1, - responseSchema: schema, - responseMimeType: 'application/json', - }, - contents, + expect(mockGenerateContentFn).toHaveBeenCalledWith({ + model: 'test-model', // Should use current model from config + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0, + topP: 1, + responseSchema: schema, + responseMimeType: 'application/json', }, - 'test-session-id', - ); + contents, + }); }); it('should allow overriding model and config', async () => { @@ -419,22 +411,19 @@ describe('Gemini Client (client.ts)', () => { customConfig, ); - expect(mockGenerateContentFn).toHaveBeenCalledWith( - { - model: customModel, - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.9, - topP: 1, // from default - topK: 20, - responseSchema: schema, - responseMimeType: 'application/json', - }, - contents, + expect(mockGenerateContentFn).toHaveBeenCalledWith({ + model: customModel, + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.9, + topP: 1, // from default + topK: 20, + responseSchema: schema, + responseMimeType: 'application/json', }, - 'test-session-id', - ); + contents, + }); }); }); @@ -1017,14 +1006,11 @@ Here are files the user has recently opened, with the most recent at the top: config: expect.any(Object), contents, }); - expect(mockGenerateContentFn).toHaveBeenCalledWith( - { - model: currentModel, - config: expect.any(Object), - contents, - }, - 'test-session-id', - ); + expect(mockGenerateContentFn).toHaveBeenCalledWith({ + model: currentModel, + config: expect.any(Object), + contents, + }); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 02fbeb38..77683a45 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -106,7 +106,7 @@ export class GeminiClient { private readonly COMPRESSION_PRESERVE_THRESHOLD = 0.3; private readonly loopDetector: LoopDetectionService; - private lastPromptId: string; + private lastPromptId?: string; constructor(private config: Config) { if (config.getProxy()) { @@ -115,7 +115,6 @@ export class GeminiClient { this.embeddingModel = config.getEmbeddingModel(); this.loopDetector = new LoopDetectionService(config); - this.lastPromptId = this.config.getSessionId(); } async initialize(contentGeneratorConfig: ContentGeneratorConfig) { @@ -428,19 +427,16 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent( - { - model: modelToUse, - config: { - ...requestConfig, - systemInstruction, - responseSchema: schema, - responseMimeType: 'application/json', - }, - contents, + this.getContentGenerator().generateContent({ + model: modelToUse, + config: { + ...requestConfig, + systemInstruction, + responseSchema: schema, + responseMimeType: 'application/json', }, - this.lastPromptId, - ); + contents, + }); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => @@ -525,14 +521,11 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent( - { - model: modelToUse, - config: requestConfig, - contents, - }, - this.lastPromptId, - ); + this.getContentGenerator().generateContent({ + model: modelToUse, + config: requestConfig, + contents, + }); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 797bad73..44ed7beb 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -25,12 +25,10 @@ import { UserTierId } from '../code_assist/types.js'; export interface ContentGenerator { generateContent( request: GenerateContentParameters, - userPromptId: string, ): Promise; generateContentStream( request: GenerateContentParameters, - userPromptId: string, ): Promise>; countTokens(request: CountTokensParameters): Promise; diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index cd5e3841..39dd883e 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -79,14 +79,11 @@ describe('GeminiChat', () => { await chat.sendMessage({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContent).toHaveBeenCalledWith( - { - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }, - 'prompt-id-1', - ); + expect(mockModelsModule.generateContent).toHaveBeenCalledWith({ + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }); }); }); @@ -114,14 +111,11 @@ describe('GeminiChat', () => { await chat.sendMessageStream({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith( - { - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }, - 'prompt-id-1', - ); + expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith({ + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }); }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 14e8d946..4c3cd4c8 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -286,14 +286,11 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContent( - { - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }, - prompt_id, - ); + return this.contentGenerator.generateContent({ + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }); }; response = await retryWithBackoff(apiCall, { @@ -396,14 +393,11 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContentStream( - { - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }, - prompt_id, - ); + return this.contentGenerator.generateContentStream({ + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }); }; // Note: Retrying streams can be complex. If generateContentStream itself doesn't handle retries From f2e006179d27af34c35b58b1df3032e351e61eaf Mon Sep 17 00:00:00 2001 From: James Woo Date: Mon, 28 Jul 2025 10:45:23 -0400 Subject: [PATCH 010/136] Fix author attribution (#5042) From e2754416516edb8c27e63cee5b249f41c3e0fffc Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 28 Jul 2025 11:03:22 -0400 Subject: [PATCH 011/136] Updates schema, UX and prompt for IDE context (#5046) --- packages/cli/src/ui/App.test.tsx | 84 +++- packages/cli/src/ui/App.tsx | 16 +- .../ui/components/ContextSummaryDisplay.tsx | 22 +- .../ui/components/IDEContextDetailDisplay.tsx | 26 +- packages/core/src/core/client.test.ts | 209 ++++++++- packages/core/src/core/client.ts | 36 +- packages/core/src/ide/ide-client.ts | 10 +- packages/core/src/ide/ideContext.test.ts | 396 +++++++++++++----- packages/core/src/ide/ideContext.ts | 101 +++-- .../vscode-ide-companion/src/ide-server.ts | 39 +- 10 files changed, 680 insertions(+), 259 deletions(-) diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 93230d1c..f35f8cb7 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -153,8 +153,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); const ideContextMock = { - getOpenFilesContext: vi.fn(), - subscribeToOpenFiles: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function + getIdeContext: vi.fn(), + subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function }; return { @@ -277,7 +277,7 @@ describe('App UI', () => { // Ensure a theme is set so the theme dialog does not appear. mockSettings = createMockSettings({ workspace: { theme: 'Default' } }); - vi.mocked(ideContext.getOpenFilesContext).mockReturnValue(undefined); + vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined); }); afterEach(() => { @@ -289,10 +289,17 @@ describe('App UI', () => { }); it('should display active file when available', async () => { - vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ - activeFile: '/path/to/my-file.ts', - recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }], - selectedText: 'hello', + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/my-file.ts', + isActive: true, + selectedText: 'hello', + timestamp: 0, + }, + ], + }, }); const { lastFrame, unmount } = render( @@ -304,12 +311,14 @@ describe('App UI', () => { ); currentUnmount = unmount; await Promise.resolve(); - expect(lastFrame()).toContain('1 recent file (ctrl+e to view)'); + expect(lastFrame()).toContain('1 open file (ctrl+e to view)'); }); - it('should not display active file when not available', async () => { - vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ - activeFile: '', + it('should not display any files when not available', async () => { + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [], + }, }); const { lastFrame, unmount } = render( @@ -324,11 +333,54 @@ describe('App UI', () => { expect(lastFrame()).not.toContain('Open File'); }); + it('should display active file and other open files', async () => { + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/my-file.ts', + isActive: true, + selectedText: 'hello', + timestamp: 0, + }, + { + path: '/path/to/another-file.ts', + isActive: false, + timestamp: 1, + }, + { + path: '/path/to/third-file.ts', + isActive: false, + timestamp: 2, + }, + ], + }, + }); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + await Promise.resolve(); + expect(lastFrame()).toContain('3 open files (ctrl+e to view)'); + }); + it('should display active file and other context', async () => { - vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ - activeFile: '/path/to/my-file.ts', - recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }], - selectedText: 'hello', + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/my-file.ts', + isActive: true, + selectedText: 'hello', + timestamp: 0, + }, + ], + }, }); mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']); @@ -343,7 +395,7 @@ describe('App UI', () => { currentUnmount = unmount; await Promise.resolve(); expect(lastFrame()).toContain( - 'Using: 1 recent file (ctrl+e to view) | 1 GEMINI.md file', + 'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file', ); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 87a78ac6..aacf45d7 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -60,7 +60,7 @@ import { FlashFallbackEvent, logFlashFallback, AuthType, - type OpenFiles, + type IdeContext, ideContext, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; @@ -169,13 +169,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = useState(false); const [userTier, setUserTier] = useState(undefined); - const [openFiles, setOpenFiles] = useState(); + const [ideContextState, setIdeContextState] = useState< + IdeContext | undefined + >(); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { - const unsubscribe = ideContext.subscribeToOpenFiles(setOpenFiles); + const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState); // Set the initial value - setOpenFiles(ideContext.getOpenFilesContext()); + setIdeContextState(ideContext.getIdeContext()); return unsubscribe; }, []); @@ -568,7 +570,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (Object.keys(mcpServers || {}).length > 0) { handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); } - } else if (key.ctrl && input === 'e' && ideContext) { + } else if (key.ctrl && input === 'e' && ideContextState) { setShowIDEContextDetail((prev) => !prev); } else if (key.ctrl && (input === 'c' || input === 'C')) { handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); @@ -943,7 +945,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ) : ( { {showIDEContextDetail && ( - + )} {showErrorDetails && ( diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index b166056a..78a19f0d 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Text } from 'ink'; import { Colors } from '../colors.js'; -import { type OpenFiles, type MCPServerConfig } from '@google/gemini-cli-core'; +import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core'; interface ContextSummaryDisplayProps { geminiMdFileCount: number; @@ -15,7 +15,7 @@ interface ContextSummaryDisplayProps { mcpServers?: Record; blockedMcpServers?: Array<{ name: string; extensionName: string }>; showToolDescriptions?: boolean; - openFiles?: OpenFiles; + ideContext?: IdeContext; } export const ContextSummaryDisplay: React.FC = ({ @@ -24,26 +24,28 @@ export const ContextSummaryDisplay: React.FC = ({ mcpServers, blockedMcpServers, showToolDescriptions, - openFiles, + ideContext, }) => { const mcpServerCount = Object.keys(mcpServers || {}).length; const blockedMcpServerCount = blockedMcpServers?.length || 0; + const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; if ( geminiMdFileCount === 0 && mcpServerCount === 0 && blockedMcpServerCount === 0 && - (openFiles?.recentOpenFiles?.length ?? 0) === 0 + openFileCount === 0 ) { return ; // Render an empty space to reserve height } - const recentFilesText = (() => { - const count = openFiles?.recentOpenFiles?.length ?? 0; - if (count === 0) { + const openFilesText = (() => { + if (openFileCount === 0) { return ''; } - return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`; + return `${openFileCount} open file${ + openFileCount > 1 ? 's' : '' + } (ctrl+e to view)`; })(); const geminiMdText = (() => { @@ -81,8 +83,8 @@ export const ContextSummaryDisplay: React.FC = ({ let summaryText = 'Using: '; const summaryParts = []; - if (recentFilesText) { - summaryParts.push(recentFilesText); + if (openFilesText) { + summaryParts.push(openFilesText); } if (geminiMdText) { summaryParts.push(geminiMdText); diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx index 8d4fb2c9..f535c40a 100644 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx +++ b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx @@ -5,25 +5,21 @@ */ import { Box, Text } from 'ink'; -import { type OpenFiles } from '@google/gemini-cli-core'; +import { type File, type IdeContext } from '@google/gemini-cli-core'; import { Colors } from '../colors.js'; import path from 'node:path'; interface IDEContextDetailDisplayProps { - openFiles: OpenFiles | undefined; + ideContext: IdeContext | undefined; } export function IDEContextDetailDisplay({ - openFiles, + ideContext, }: IDEContextDetailDisplayProps) { - if ( - !openFiles || - !openFiles.recentOpenFiles || - openFiles.recentOpenFiles.length === 0 - ) { + const openFiles = ideContext?.workspaceState?.openFiles; + if (!openFiles || openFiles.length === 0) { return null; } - const recentFiles = openFiles.recentOpenFiles || []; return ( IDE Context (ctrl+e to toggle) - {recentFiles.length > 0 && ( + {openFiles.length > 0 && ( - Recent files: - {recentFiles.map((file) => ( - - - {path.basename(file.filePath)} - {file.filePath === openFiles.activeFile ? ' (active)' : ''} + Open files: + {openFiles.map((file: File) => ( + + - {path.basename(file.path)} + {file.isActive ? ' (active)' : ''} ))} diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 25ea9bc1..8c46d7f5 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -647,14 +647,26 @@ describe('Gemini Client (client.ts)', () => { describe('sendMessageStream', () => { it('should include IDE context when ideMode is enabled', async () => { // Arrange - vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ - activeFile: '/path/to/active/file.ts', - selectedText: 'hello', - cursor: { line: 5, character: 10 }, - recentOpenFiles: [ - { filePath: '/path/to/recent/file1.ts', timestamp: Date.now() }, - { filePath: '/path/to/recent/file2.ts', timestamp: Date.now() }, - ], + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/active/file.ts', + timestamp: Date.now(), + isActive: true, + selectedText: 'hello', + cursor: { line: 5, character: 10 }, + }, + { + path: '/path/to/recent/file1.ts', + timestamp: Date.now(), + }, + { + path: '/path/to/recent/file2.ts', + timestamp: Date.now(), + }, + ], + }, }); vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); @@ -689,15 +701,188 @@ describe('Gemini Client (client.ts)', () => { } // Assert - expect(ideContext.getOpenFilesContext).toHaveBeenCalled(); + expect(ideContext.getIdeContext).toHaveBeenCalled(); const expectedContext = ` -This is the file that the user was most recently looking at: +This is the file that the user is looking at: - Path: /path/to/active/file.ts This is the cursor position in the file: - Cursor Position: Line 5, Character 10 -This is the selected text in the active file: +This is the selected text in the file: - hello -Here are files the user has recently opened, with the most recent at the top: +Here are some other files the user has open, with the most recent at the top: +- /path/to/recent/file1.ts +- /path/to/recent/file2.ts + `.trim(); + const expectedRequest = [{ text: expectedContext }, ...initialRequest]; + expect(mockTurnRunFn).toHaveBeenCalledWith( + expectedRequest, + expect.any(Object), + ); + }); + + it('should not add context if ideMode is enabled but no open files', async () => { + // Arrange + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [], + }, + }); + + vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + generateContent: mockGenerateContentFn, + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + const initialRequest = [{ text: 'Hi' }]; + + // Act + const stream = client.sendMessageStream( + initialRequest, + new AbortController().signal, + 'prompt-id-ide', + ); + for await (const _ of stream) { + // consume stream + } + + // Assert + expect(ideContext.getIdeContext).toHaveBeenCalled(); + expect(mockTurnRunFn).toHaveBeenCalledWith( + initialRequest, + expect.any(Object), + ); + }); + + it('should add context if ideMode is enabled and there is one active file', async () => { + // Arrange + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/active/file.ts', + timestamp: Date.now(), + isActive: true, + selectedText: 'hello', + cursor: { line: 5, character: 10 }, + }, + ], + }, + }); + + vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + generateContent: mockGenerateContentFn, + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + const initialRequest = [{ text: 'Hi' }]; + + // Act + const stream = client.sendMessageStream( + initialRequest, + new AbortController().signal, + 'prompt-id-ide', + ); + for await (const _ of stream) { + // consume stream + } + + // Assert + expect(ideContext.getIdeContext).toHaveBeenCalled(); + const expectedContext = ` +This is the file that the user is looking at: +- Path: /path/to/active/file.ts +This is the cursor position in the file: +- Cursor Position: Line 5, Character 10 +This is the selected text in the file: +- hello + `.trim(); + const expectedRequest = [{ text: expectedContext }, ...initialRequest]; + expect(mockTurnRunFn).toHaveBeenCalledWith( + expectedRequest, + expect.any(Object), + ); + }); + + it('should add context if ideMode is enabled and there are open files but no active file', async () => { + // Arrange + vi.mocked(ideContext.getIdeContext).mockReturnValue({ + workspaceState: { + openFiles: [ + { + path: '/path/to/recent/file1.ts', + timestamp: Date.now(), + }, + { + path: '/path/to/recent/file2.ts', + timestamp: Date.now(), + }, + ], + }, + }); + + vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }), + generateContent: mockGenerateContentFn, + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + const initialRequest = [{ text: 'Hi' }]; + + // Act + const stream = client.sendMessageStream( + initialRequest, + new AbortController().signal, + 'prompt-id-ide', + ); + for await (const _ of stream) { + // consume stream + } + + // Assert + expect(ideContext.getIdeContext).toHaveBeenCalled(); + const expectedContext = ` +Here are some files the user has open, with the most recent at the top: - /path/to/recent/file1.ts - /path/to/recent/file2.ts `.trim(); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 77683a45..e58e7040 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -320,32 +320,40 @@ export class GeminiClient { } if (this.config.getIdeMode()) { - const openFiles = ideContext.getOpenFilesContext(); - if (openFiles) { + const ideContextState = ideContext.getIdeContext(); + const openFiles = ideContextState?.workspaceState?.openFiles; + + if (openFiles && openFiles.length > 0) { const contextParts: string[] = []; - if (openFiles.activeFile) { + const firstFile = openFiles[0]; + const activeFile = firstFile.isActive ? firstFile : undefined; + + if (activeFile) { contextParts.push( - `This is the file that the user was most recently looking at:\n- Path: ${openFiles.activeFile}`, + `This is the file that the user is looking at:\n- Path: ${activeFile.path}`, ); - if (openFiles.cursor) { + if (activeFile.cursor) { contextParts.push( - `This is the cursor position in the file:\n- Cursor Position: Line ${openFiles.cursor.line}, Character ${openFiles.cursor.character}`, + `This is the cursor position in the file:\n- Cursor Position: Line ${activeFile.cursor.line}, Character ${activeFile.cursor.character}`, ); } - if (openFiles.selectedText) { + if (activeFile.selectedText) { contextParts.push( - `This is the selected text in the active file:\n- ${openFiles.selectedText}`, + `This is the selected text in the file:\n- ${activeFile.selectedText}`, ); } } - if (openFiles.recentOpenFiles && openFiles.recentOpenFiles.length > 0) { - const recentFiles = openFiles.recentOpenFiles - .map((file) => `- ${file.filePath}`) + const otherOpenFiles = activeFile ? openFiles.slice(1) : openFiles; + + if (otherOpenFiles.length > 0) { + const recentFiles = otherOpenFiles + .map((file) => `- ${file.path}`) .join('\n'); - contextParts.push( - `Here are files the user has recently opened, with the most recent at the top:\n${recentFiles}`, - ); + const heading = activeFile + ? `Here are some other files the user has open, with the most recent at the top:` + : `Here are some files the user has open, with the most recent at the top:`; + contextParts.push(`${heading}\n${recentFiles}`); } if (contextParts.length > 0) { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 3f91f386..64264fd1 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ideContext, OpenFilesNotificationSchema } from '../ide/ideContext.js'; +import { ideContext, IdeContextNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -77,20 +77,20 @@ export class IdeClient { await this.client.connect(transport); this.client.setNotificationHandler( - OpenFilesNotificationSchema, + IdeContextNotificationSchema, (notification) => { - ideContext.setOpenFilesContext(notification.params); + ideContext.setIdeContext(notification.params); }, ); this.client.onerror = (error) => { logger.debug('IDE MCP client error:', error); this.connectionStatus = IDEConnectionStatus.Disconnected; - ideContext.clearOpenFilesContext(); + ideContext.clearIdeContext(); }; this.client.onclose = () => { logger.debug('IDE MCP client connection closed.'); this.connectionStatus = IDEConnectionStatus.Disconnected; - ideContext.clearOpenFilesContext(); + ideContext.clearIdeContext(); }; this.connectionStatus = IDEConnectionStatus.Connected; diff --git a/packages/core/src/ide/ideContext.test.ts b/packages/core/src/ide/ideContext.test.ts index 1cb09c53..7e01d3aa 100644 --- a/packages/core/src/ide/ideContext.test.ts +++ b/packages/core/src/ide/ideContext.test.ts @@ -5,136 +5,300 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createIdeContextStore } from './ideContext.js'; +import { + createIdeContextStore, + FileSchema, + IdeContextSchema, +} from './ideContext.js'; -describe('ideContext - Active File', () => { - let ideContext: ReturnType; +describe('ideContext', () => { + describe('createIdeContextStore', () => { + let ideContext: ReturnType; - beforeEach(() => { - // Create a fresh, isolated instance for each test - ideContext = createIdeContextStore(); - }); - - it('should return undefined initially for active file context', () => { - expect(ideContext.getOpenFilesContext()).toBeUndefined(); - }); - - it('should set and retrieve the active file context', () => { - const testFile = { - activeFile: '/path/to/test/file.ts', - selectedText: '1234', - }; - - ideContext.setOpenFilesContext(testFile); - - const activeFile = ideContext.getOpenFilesContext(); - expect(activeFile).toEqual(testFile); - }); - - it('should update the active file context when called multiple times', () => { - const firstFile = { - activeFile: '/path/to/first.js', - selectedText: '1234', - }; - ideContext.setOpenFilesContext(firstFile); - - const secondFile = { - activeFile: '/path/to/second.py', - cursor: { line: 20, character: 30 }, - }; - ideContext.setOpenFilesContext(secondFile); - - const activeFile = ideContext.getOpenFilesContext(); - expect(activeFile).toEqual(secondFile); - }); - - it('should handle empty string for file path', () => { - const testFile = { - activeFile: '', - selectedText: '1234', - }; - ideContext.setOpenFilesContext(testFile); - expect(ideContext.getOpenFilesContext()).toEqual(testFile); - }); - - it('should notify subscribers when active file context changes', () => { - const subscriber1 = vi.fn(); - const subscriber2 = vi.fn(); - - ideContext.subscribeToOpenFiles(subscriber1); - ideContext.subscribeToOpenFiles(subscriber2); - - const testFile = { - activeFile: '/path/to/subscribed.ts', - cursor: { line: 15, character: 25 }, - }; - ideContext.setOpenFilesContext(testFile); - - expect(subscriber1).toHaveBeenCalledTimes(1); - expect(subscriber1).toHaveBeenCalledWith(testFile); - expect(subscriber2).toHaveBeenCalledTimes(1); - expect(subscriber2).toHaveBeenCalledWith(testFile); - - // Test with another update - const newFile = { - activeFile: '/path/to/new.js', - selectedText: '1234', - }; - ideContext.setOpenFilesContext(newFile); - - expect(subscriber1).toHaveBeenCalledTimes(2); - expect(subscriber1).toHaveBeenCalledWith(newFile); - expect(subscriber2).toHaveBeenCalledTimes(2); - expect(subscriber2).toHaveBeenCalledWith(newFile); - }); - - it('should stop notifying a subscriber after unsubscribe', () => { - const subscriber1 = vi.fn(); - const subscriber2 = vi.fn(); - - const unsubscribe1 = ideContext.subscribeToOpenFiles(subscriber1); - ideContext.subscribeToOpenFiles(subscriber2); - - ideContext.setOpenFilesContext({ - activeFile: '/path/to/file1.txt', - selectedText: '1234', + beforeEach(() => { + // Create a fresh, isolated instance for each test + ideContext = createIdeContextStore(); }); - expect(subscriber1).toHaveBeenCalledTimes(1); - expect(subscriber2).toHaveBeenCalledTimes(1); - unsubscribe1(); - - ideContext.setOpenFilesContext({ - activeFile: '/path/to/file2.txt', - selectedText: '1234', + it('should return undefined initially for ide context', () => { + expect(ideContext.getIdeContext()).toBeUndefined(); + }); + + it('should set and retrieve the ide context', () => { + const testFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/test/file.ts', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }; + + ideContext.setIdeContext(testFile); + + const activeFile = ideContext.getIdeContext(); + expect(activeFile).toEqual(testFile); + }); + + it('should update the ide context when called multiple times', () => { + const firstFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/first.js', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }; + ideContext.setIdeContext(firstFile); + + const secondFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/second.py', + isActive: true, + cursor: { line: 20, character: 30 }, + timestamp: 0, + }, + ], + }, + }; + ideContext.setIdeContext(secondFile); + + const activeFile = ideContext.getIdeContext(); + expect(activeFile).toEqual(secondFile); + }); + + it('should handle empty string for file path', () => { + const testFile = { + workspaceState: { + openFiles: [ + { + path: '', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }; + ideContext.setIdeContext(testFile); + expect(ideContext.getIdeContext()).toEqual(testFile); + }); + + it('should notify subscribers when ide context changes', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + ideContext.subscribeToIdeContext(subscriber1); + ideContext.subscribeToIdeContext(subscriber2); + + const testFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/subscribed.ts', + isActive: true, + cursor: { line: 15, character: 25 }, + timestamp: 0, + }, + ], + }, + }; + ideContext.setIdeContext(testFile); + + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber1).toHaveBeenCalledWith(testFile); + expect(subscriber2).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledWith(testFile); + + // Test with another update + const newFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/new.js', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }; + ideContext.setIdeContext(newFile); + + expect(subscriber1).toHaveBeenCalledTimes(2); + expect(subscriber1).toHaveBeenCalledWith(newFile); + expect(subscriber2).toHaveBeenCalledTimes(2); + expect(subscriber2).toHaveBeenCalledWith(newFile); + }); + + it('should stop notifying a subscriber after unsubscribe', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + const unsubscribe1 = ideContext.subscribeToIdeContext(subscriber1); + ideContext.subscribeToIdeContext(subscriber2); + + ideContext.setIdeContext({ + workspaceState: { + openFiles: [ + { + path: '/path/to/file1.txt', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + ideContext.setIdeContext({ + workspaceState: { + openFiles: [ + { + path: '/path/to/file2.txt', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); // Should not be called again + expect(subscriber2).toHaveBeenCalledTimes(2); + }); + + it('should clear the ide context', () => { + const testFile = { + workspaceState: { + openFiles: [ + { + path: '/path/to/test/file.ts', + isActive: true, + selectedText: '1234', + timestamp: 0, + }, + ], + }, + }; + + ideContext.setIdeContext(testFile); + + expect(ideContext.getIdeContext()).toEqual(testFile); + + ideContext.clearIdeContext(); + + expect(ideContext.getIdeContext()).toBeUndefined(); }); - expect(subscriber1).toHaveBeenCalledTimes(1); // Should not be called again - expect(subscriber2).toHaveBeenCalledTimes(2); }); - it('should allow the cursor to be optional', () => { - const testFile = { - activeFile: '/path/to/test/file.ts', - }; + describe('FileSchema', () => { + it('should validate a file with only required fields', () => { + const file = { + path: '/path/to/file.ts', + timestamp: 12345, + }; + const result = FileSchema.safeParse(file); + expect(result.success).toBe(true); + }); - ideContext.setOpenFilesContext(testFile); + it('should validate a file with all fields', () => { + const file = { + path: '/path/to/file.ts', + timestamp: 12345, + isActive: true, + selectedText: 'const x = 1;', + cursor: { + line: 10, + character: 20, + }, + }; + const result = FileSchema.safeParse(file); + expect(result.success).toBe(true); + }); - const activeFile = ideContext.getOpenFilesContext(); - expect(activeFile).toEqual(testFile); + it('should fail validation if path is missing', () => { + const file = { + timestamp: 12345, + }; + const result = FileSchema.safeParse(file); + expect(result.success).toBe(false); + }); + + it('should fail validation if timestamp is missing', () => { + const file = { + path: '/path/to/file.ts', + }; + const result = FileSchema.safeParse(file); + expect(result.success).toBe(false); + }); }); - it('should clear the active file context', () => { - const testFile = { - activeFile: '/path/to/test/file.ts', - selectedText: '1234', - }; + describe('IdeContextSchema', () => { + it('should validate an empty context', () => { + const context = {}; + const result = IdeContextSchema.safeParse(context); + expect(result.success).toBe(true); + }); - ideContext.setOpenFilesContext(testFile); + it('should validate a context with an empty workspaceState', () => { + const context = { + workspaceState: {}, + }; + const result = IdeContextSchema.safeParse(context); + expect(result.success).toBe(true); + }); - expect(ideContext.getOpenFilesContext()).toEqual(testFile); + it('should validate a context with an empty openFiles array', () => { + const context = { + workspaceState: { + openFiles: [], + }, + }; + const result = IdeContextSchema.safeParse(context); + expect(result.success).toBe(true); + }); - ideContext.clearOpenFilesContext(); + it('should validate a context with a valid file', () => { + const context = { + workspaceState: { + openFiles: [ + { + path: '/path/to/file.ts', + timestamp: 12345, + }, + ], + }, + }; + const result = IdeContextSchema.safeParse(context); + expect(result.success).toBe(true); + }); - expect(ideContext.getOpenFilesContext()).toBeUndefined(); + it('should fail validation with an invalid file', () => { + const context = { + workspaceState: { + openFiles: [ + { + timestamp: 12345, // path is missing + }, + ], + }, + }; + const result = IdeContextSchema.safeParse(context); + expect(result.success).toBe(false); + }); }); }); diff --git a/packages/core/src/ide/ideContext.ts b/packages/core/src/ide/ideContext.ts index bc7383a1..588e25ee 100644 --- a/packages/core/src/ide/ideContext.ts +++ b/packages/core/src/ide/ideContext.ts @@ -7,97 +7,96 @@ import { z } from 'zod'; /** - * Zod schema for validating a cursor position. + * Zod schema for validating a file context from the IDE. */ -export const CursorSchema = z.object({ - line: z.number(), - character: z.number(), -}); -export type Cursor = z.infer; - -/** - * Zod schema for validating an active file context from the IDE. - */ -export const OpenFilesSchema = z.object({ - activeFile: z.string(), +export const FileSchema = z.object({ + path: z.string(), + timestamp: z.number(), + isActive: z.boolean().optional(), selectedText: z.string().optional(), - cursor: CursorSchema.optional(), - recentOpenFiles: z - .array( - z.object({ - filePath: z.string(), - timestamp: z.number(), - }), - ) + cursor: z + .object({ + line: z.number(), + character: z.number(), + }) .optional(), }); -export type OpenFiles = z.infer; +export type File = z.infer; + +export const IdeContextSchema = z.object({ + workspaceState: z + .object({ + openFiles: z.array(FileSchema).optional(), + }) + .optional(), +}); +export type IdeContext = z.infer; /** - * Zod schema for validating the 'ide/openFilesChanged' notification from the IDE. + * Zod schema for validating the 'ide/contextUpdate' notification from the IDE. */ -export const OpenFilesNotificationSchema = z.object({ - method: z.literal('ide/openFilesChanged'), - params: OpenFilesSchema, +export const IdeContextNotificationSchema = z.object({ + method: z.literal('ide/contextUpdate'), + params: IdeContextSchema, }); -type OpenFilesSubscriber = (openFiles: OpenFiles | undefined) => void; +type IdeContextSubscriber = (ideContext: IdeContext | undefined) => void; /** - * Creates a new store for managing the IDE's active file context. + * Creates a new store for managing the IDE's context. * This factory function encapsulates the state and logic, allowing for the creation * of isolated instances, which is particularly useful for testing. * - * @returns An object with methods to interact with the active file context. + * @returns An object with methods to interact with the IDE context. */ export function createIdeContextStore() { - let openFilesContext: OpenFiles | undefined = undefined; - const subscribers = new Set(); + let ideContextState: IdeContext | undefined = undefined; + const subscribers = new Set(); /** - * Notifies all registered subscribers about the current active file context. + * Notifies all registered subscribers about the current IDE context. */ function notifySubscribers(): void { for (const subscriber of subscribers) { - subscriber(openFilesContext); + subscriber(ideContextState); } } /** - * Sets the active file context and notifies all registered subscribers of the change. - * @param newOpenFiles The new active file context from the IDE. + * Sets the IDE context and notifies all registered subscribers of the change. + * @param newIdeContext The new IDE context from the IDE. */ - function setOpenFilesContext(newOpenFiles: OpenFiles): void { - openFilesContext = newOpenFiles; + function setIdeContext(newIdeContext: IdeContext): void { + ideContextState = newIdeContext; notifySubscribers(); } /** - * Clears the active file context and notifies all registered subscribers of the change. + * Clears the IDE context and notifies all registered subscribers of the change. */ - function clearOpenFilesContext(): void { - openFilesContext = undefined; + function clearIdeContext(): void { + ideContextState = undefined; notifySubscribers(); } /** - * Retrieves the current active file context. - * @returns The `OpenFiles` object if a file is active; otherwise, `undefined`. + * Retrieves the current IDE context. + * @returns The `IdeContext` object if a file is active; otherwise, `undefined`. */ - function getOpenFilesContext(): OpenFiles | undefined { - return openFilesContext; + function getIdeContext(): IdeContext | undefined { + return ideContextState; } /** - * Subscribes to changes in the active file context. + * Subscribes to changes in the IDE context. * - * When the active file context changes, the provided `subscriber` function will be called. + * When the IDE context changes, the provided `subscriber` function will be called. * Note: The subscriber is not called with the current value upon subscription. * - * @param subscriber The function to be called when the active file context changes. + * @param subscriber The function to be called when the IDE context changes. * @returns A function that, when called, will unsubscribe the provided subscriber. */ - function subscribeToOpenFiles(subscriber: OpenFilesSubscriber): () => void { + function subscribeToIdeContext(subscriber: IdeContextSubscriber): () => void { subscribers.add(subscriber); return () => { subscribers.delete(subscriber); @@ -105,10 +104,10 @@ export function createIdeContextStore() { } return { - setOpenFilesContext, - getOpenFilesContext, - subscribeToOpenFiles, - clearOpenFilesContext, + setIdeContext, + getIdeContext, + subscribeToIdeContext, + clearIdeContext, }; } diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index f47463ba..df8e160b 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -20,16 +20,17 @@ const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit -function sendOpenFilesChangedNotification( +function sendIdeContextUpdateNotification( transport: StreamableHTTPServerTransport, log: (message: string) => void, recentFilesManager: RecentFilesManager, ) { const editor = vscode.window.activeTextEditor; - const filePath = + const activeFile = editor && editor.document.uri.scheme === 'file' ? editor.document.uri.fsPath - : ''; + : undefined; + const selection = editor?.selection; const cursor = selection ? { @@ -38,25 +39,37 @@ function sendOpenFilesChangedNotification( character: selection.active.character, } : undefined; + let selectedText = editor?.document.getText(selection) ?? undefined; if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { selectedText = selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; } + + const openFiles = recentFilesManager.recentFiles.map((file) => { + const isActive = file.filePath === activeFile; + return { + path: file.filePath, + timestamp: file.timestamp, + isActive, + ...(isActive && { + cursor, + selectedText, + }), + }; + }); + const notification: JSONRPCNotification = { jsonrpc: '2.0', - method: 'ide/openFilesChanged', + method: 'ide/contextUpdate', params: { - activeFile: filePath, - recentOpenFiles: recentFilesManager.recentFiles.filter( - (file) => file.filePath !== filePath, - ), - cursor, - selectedText, + workspaceState: { + openFiles, + }, }, }; log( - `Sending active file changed notification: ${JSON.stringify( + `Sending IDE context update notification: ${JSON.stringify( notification, null, 2, @@ -87,7 +100,7 @@ export class IDEServer { const recentFilesManager = new RecentFilesManager(context); const onDidChangeSubscription = recentFilesManager.onDidChange(() => { for (const transport of Object.values(transports)) { - sendOpenFilesChangedNotification( + sendIdeContextUpdateNotification( transport, this.log.bind(this), recentFilesManager, @@ -191,7 +204,7 @@ export class IDEServer { } if (!sessionsWithInitialNotification.has(sessionId)) { - sendOpenFilesChangedNotification( + sendIdeContextUpdateNotification( transport, this.log.bind(this), recentFilesManager, From 0170791800183b81e2afc98f8fb2368219bfb3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Medrano=20Llamas?= <45878745+rmedranollamas@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:46:43 +0200 Subject: [PATCH 012/136] feat: Add /config refresh command (#4993) Co-authored-by: Bryan Morgan --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/App.tsx | 39 +++++- packages/cli/src/ui/commands/configCommand.ts | 33 +++++ packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 3 + packages/core/src/config/config.ts | 130 +++++++++++++----- 6 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/ui/commands/configCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 7ba0d6bb..b0c85d2a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -29,6 +29,7 @@ import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; +import { configCommand } from '../ui/commands/configCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -54,6 +55,7 @@ export class BuiltinCommandLoader implements ICommandLoader { compressCommand, copyCommand, corgiCommand, + configCommand, docsCommand, editorCommand, extensionsCommand, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index aacf45d7..43060fdb 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -39,8 +39,12 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; import { Help } from './components/Help.js'; -import { loadHierarchicalGeminiMemory } from '../config/config.js'; -import { LoadedSettings } from '../config/settings.js'; +import { + loadHierarchicalGeminiMemory, + loadCliConfig, + parseArguments, +} from '../config/config.js'; +import { LoadedSettings, loadSettings } from '../config/settings.js'; import { Tips } from './components/Tips.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup } from '../utils/cleanup.js'; @@ -62,6 +66,7 @@ import { AuthType, type IdeContext, ideContext, + sessionId, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.js'; @@ -89,6 +94,7 @@ import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; import { appEvents, AppEvent } from '../utils/events.js'; +import { loadExtensions } from '../config/extension.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -107,12 +113,14 @@ export const AppWrapper = (props: AppProps) => ( ); -const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { +const App = (props: AppProps) => { + const [config, setConfig] = useState(props.config); + const [settings, setSettings] = useState(props.settings); const isFocused = useFocus(); useBracketedPaste(); const [updateMessage, setUpdateMessage] = useState(null); const { stdout } = useStdout(); - const nightly = version.includes('nightly'); + const nightly = props.version.includes('nightly'); useEffect(() => { checkForUpdates().then(setUpdateMessage); @@ -307,6 +315,22 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [config, addItem, settings.merged]); + const refreshConfig = useCallback(async () => { + const newSettings = loadSettings(process.cwd()); + const newExtensions = loadExtensions(process.cwd()); + const argv = await parseArguments(); + const newConfig = await loadCliConfig( + newSettings.merged, + newExtensions, + sessionId, + argv, + ); + await newConfig.initialize(); + setConfig(newConfig); + setSettings(newSettings); + setGeminiMdFileCount(newConfig.getGeminiMdFileCount()); + }, []); + // Watch for model changes (e.g., from Flash fallback) useEffect(() => { const checkModelChange = () => { @@ -474,6 +498,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, + refreshConfig, ); const { @@ -777,7 +802,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {!settings.merged.hideBanner && (
)} @@ -821,7 +846,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showHelp && } - {startupWarnings.length > 0 && ( + {props.startupWarnings && props.startupWarnings.length > 0 && ( { marginY={1} flexDirection="column" > - {startupWarnings.map((warning, index) => ( + {props.startupWarnings.map((warning, index) => ( {warning} diff --git a/packages/cli/src/ui/commands/configCommand.ts b/packages/cli/src/ui/commands/configCommand.ts new file mode 100644 index 00000000..3651b221 --- /dev/null +++ b/packages/cli/src/ui/commands/configCommand.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; + +export const configCommand: SlashCommand = { + name: 'config', + description: 'Commands for interacting with the CLI configuration.', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'refresh', + description: 'Reload settings and extensions from the filesystem.', + kind: CommandKind.BUILT_IN, + action: async (context): Promise => { + await context.ui.refreshConfig(); + return { + type: 'message', + messageType: 'info', + content: + 'Configuration, extensions, memory, and tools have been refreshed.', + }; + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2844177f..6665da4b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,6 +59,7 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; + refreshConfig: () => Promise; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index be32de11..67e49c21 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -50,6 +50,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, + refreshConfig: () => Promise, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -158,6 +159,7 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, + refreshConfig, }, session: { stats: session.stats, @@ -180,6 +182,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, + refreshConfig, ], ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7ccfdbc8..e03abe8a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -188,60 +188,62 @@ export interface ConfigParameters { export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; - private readonly sessionId: string; + private sessionId: string; private contentGeneratorConfig!: ContentGeneratorConfig; - private readonly embeddingModel: string; - private readonly sandbox: SandboxConfig | undefined; - private readonly targetDir: string; - private readonly debugMode: boolean; - private readonly question: string | undefined; - private readonly fullContext: boolean; - private readonly coreTools: string[] | undefined; - private readonly excludeTools: string[] | undefined; - private readonly toolDiscoveryCommand: string | undefined; - private readonly toolCallCommand: string | undefined; - private readonly mcpServerCommand: string | undefined; - private readonly mcpServers: Record | undefined; + private embeddingModel: string; + private sandbox: SandboxConfig | undefined; + private targetDir: string; + private debugMode: boolean; + private question: string | undefined; + private fullContext: boolean; + private coreTools: string[] | undefined; + private excludeTools: string[] | undefined; + private toolDiscoveryCommand: string | undefined; + private toolCallCommand: string | undefined; + private mcpServerCommand: string | undefined; + private mcpServers: Record | undefined; private userMemory: string; private geminiMdFileCount: number; private approvalMode: ApprovalMode; - private readonly showMemoryUsage: boolean; - private readonly accessibility: AccessibilitySettings; - private readonly telemetrySettings: TelemetrySettings; - private readonly usageStatisticsEnabled: boolean; + private showMemoryUsage: boolean; + private accessibility: AccessibilitySettings; + private telemetrySettings: TelemetrySettings; + private usageStatisticsEnabled: boolean; private geminiClient!: GeminiClient; - private readonly fileFiltering: { + private fileFiltering: { respectGitIgnore: boolean; respectGeminiIgnore: boolean; enableRecursiveFileSearch: boolean; }; private fileDiscoveryService: FileDiscoveryService | null = null; private gitService: GitService | undefined = undefined; - private readonly checkpointing: boolean; - private readonly proxy: string | undefined; - private readonly cwd: string; - private readonly bugCommand: BugCommandSettings | undefined; - private readonly model: string; - private readonly extensionContextFilePaths: string[]; - private readonly noBrowser: boolean; - private readonly ideMode: boolean; - private readonly ideClient: IdeClient | undefined; + private checkpointing: boolean; + private proxy: string | undefined; + private cwd: string; + private bugCommand: BugCommandSettings | undefined; + private model: string; + private extensionContextFilePaths: string[]; + private noBrowser: boolean; + private ideMode: boolean; + private ideClient: IdeClient | undefined; private modelSwitchedDuringSession: boolean = false; - private readonly maxSessionTurns: number; - private readonly listExtensions: boolean; - private readonly _extensions: GeminiCLIExtension[]; - private readonly _blockedMcpServers: Array<{ + private maxSessionTurns: number; + private listExtensions: boolean; + private _extensions: GeminiCLIExtension[]; + private _blockedMcpServers: Array<{ name: string; extensionName: string; }>; flashFallbackHandler?: FlashFallbackHandler; private quotaErrorOccurred: boolean = false; - private readonly summarizeToolOutput: + private summarizeToolOutput: | Record | undefined; - private readonly experimentalAcp: boolean = false; + private experimentalAcp: boolean = false; + private _params: ConfigParameters; constructor(params: ConfigParameters) { + this._params = params; this.sessionId = params.sessionId; this.embeddingModel = params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; @@ -310,6 +312,68 @@ export class Config { } } + async refresh() { + // Re-run initialization logic. + await this.initialize(); + // After re-initializing, the tool registry will be updated. + // We need to update the gemini client with the new tools. + await this.geminiClient.setTools(); + } + + update(params: ConfigParameters) { + this._params = params; + // Re-assign all properties from the new params. + this.sessionId = params.sessionId; + this.embeddingModel = + params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; + this.sandbox = params.sandbox; + this.targetDir = path.resolve(params.targetDir); + this.debugMode = params.debugMode; + this.question = params.question; + this.fullContext = params.fullContext ?? false; + this.coreTools = params.coreTools; + this.excludeTools = params.excludeTools; + this.toolDiscoveryCommand = params.toolDiscoveryCommand; + this.toolCallCommand = params.toolCallCommand; + this.mcpServerCommand = params.mcpServerCommand; + this.mcpServers = params.mcpServers; + this.userMemory = params.userMemory ?? ''; + this.geminiMdFileCount = params.geminiMdFileCount ?? 0; + this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT; + this.showMemoryUsage = params.showMemoryUsage ?? false; + this.accessibility = params.accessibility ?? {}; + this.telemetrySettings = { + enabled: params.telemetry?.enabled ?? false, + target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET, + otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, + logPrompts: params.telemetry?.logPrompts ?? true, + outfile: params.telemetry?.outfile, + }; + this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; + this.fileFiltering = { + respectGitIgnore: params.fileFiltering?.respectGitIgnore ?? true, + respectGeminiIgnore: params.fileFiltering?.respectGeminiIgnore ?? true, + enableRecursiveFileSearch: + params.fileFiltering?.enableRecursiveFileSearch ?? true, + }; + this.checkpointing = params.checkpointing ?? false; + this.proxy = params.proxy; + this.cwd = params.cwd ?? process.cwd(); + this.fileDiscoveryService = params.fileDiscoveryService ?? null; + this.bugCommand = params.bugCommand; + this.model = params.model; + this.extensionContextFilePaths = params.extensionContextFilePaths ?? []; + this.maxSessionTurns = params.maxSessionTurns ?? -1; + this.experimentalAcp = params.experimentalAcp ?? false; + this.listExtensions = params.listExtensions ?? false; + this._extensions = params.extensions ?? []; + this._blockedMcpServers = params.blockedMcpServers ?? []; + this.noBrowser = params.noBrowser ?? false; + this.summarizeToolOutput = params.summarizeToolOutput; + this.ideMode = params.ideMode ?? false; + this.ideClient = params.ideClient; + } + async initialize(): Promise { // Initialize centralized FileDiscoveryService this.getFileService(); From f7e559223d5ca4e2f7180c4b765c51300a56820b Mon Sep 17 00:00:00 2001 From: Alexander Parshakov Date: Mon, 28 Jul 2025 16:54:09 +0100 Subject: [PATCH 013/136] docs: Add more examples to Popular tasks (#4979) Co-authored-by: Bryan Morgan --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 0c722512..3e2db940 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,34 @@ Start by `cd`ing into an existing or newly-cloned repository and running `gemini > What security mechanisms are in place? ``` +```text +> Provide a step-by-step dev onboarding doc for developers new to the codebase. +``` + +```text +> Summarize this codebase and highlight the most interesting patterns or techniques I could learn from. +``` + +```text +> Identify potential areas for improvement or refactoring in this codebase, highlighting parts that appear fragile, complex, or hard to maintain. +``` + +```text +> Which parts of this codebase might be challenging to scale or debug? +``` + +```text +> Generate a README section for the [module name] module explaining what it does and how to use it. +``` + +```text +> What kind of error handling and logging strategies does the project use? +``` + +```text +> Which tools, libraries, and dependencies are used in this project? +``` + ### Work with your existing code ```text From 379765da238ec113801648999be392cda5f690b8 Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 28 Jul 2025 16:01:15 +0000 Subject: [PATCH 014/136] Add documentation for MCP prompts (#4897) Co-authored-by: matt korwel Co-authored-by: Bryan Morgan --- docs/tools/mcp-server.md | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index cd70da04..050e10e8 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -570,3 +570,70 @@ The MCP integration tracks several states: - **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use. + +## MCP Prompts as Slash Commands + +In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name. + +### Defining Prompts on the Server + +Here's a small example of a stdio MCP server that defines prompts: + +```ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'prompt-server', + version: '1.0.0', +}); + +server.registerPrompt( + 'poem-writer', + { + title: 'Poem Writer', + description: 'Write a nice haiku', + argsSchema: { title: z.string(), mood: z.string().optional() }, + }, + ({ title, mood }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `, + }, + }, + ], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This can be included in `settings.json` under `mcpServers` with: + +```json +"nodeServer": { + "command": "node", + "args": ["filename.ts"], +} +``` + +### Invoking Prompts + +Once a prompt is discovered, you can invoke it using its name as a slash command. The CLI will automatically handle parsing arguments. + +```bash +/poem-writer --title="Gemini CLI" --mood="reverent" +``` + +or, using positional arguments: + +```bash +/poem-writer "Gemini CLI" reverent +``` + +When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows. From a5ea113a8e485f42cc1136b6c57e337cbf369c57 Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Mon, 28 Jul 2025 23:27:33 +0530 Subject: [PATCH 015/136] fix: Clear previous thoughts when starting new prompts (#4966) --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 195 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 7 +- 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 02fae607..085e3e96 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -319,6 +319,7 @@ describe('useGeminiStream', () => { }, setQuotaErrorOccurred: vi.fn(), getQuotaErrorOccurred: vi.fn(() => false), + getModel: vi.fn(() => 'gemini-2.5-pro'), getContentGeneratorConfig: vi .fn() .mockReturnValue(contentGeneratorConfig), @@ -1473,4 +1474,198 @@ describe('useGeminiStream', () => { } }); }); + + describe('Thought Reset', () => { + it('should reset thought to null when starting a new prompt', async () => { + // First, simulate a response with a thought + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { + subject: 'Previous thought', + description: 'Old description', + }, + }; + yield { + type: ServerGeminiEventType.Content, + value: 'Some response content', + }; + yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockSetShowHelp, + mockConfig, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + ), + ); + + // Submit first query to set a thought + await act(async () => { + await result.current.submitQuery('First query'); + }); + + // Wait for the first response to complete + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'gemini', + text: 'Some response content', + }), + expect.any(Number), + ); + }); + + // Now simulate a new response without a thought + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'New response content', + }; + yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; + })(), + ); + + // Submit second query - thought should be reset + await act(async () => { + await result.current.submitQuery('Second query'); + }); + + // The thought should be reset to null when starting the new prompt + // We can verify this by checking that the LoadingIndicator would not show the previous thought + // The actual thought state is internal to the hook, but we can verify the behavior + // by ensuring the second response doesn't show the previous thought + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'gemini', + text: 'New response content', + }), + expect.any(Number), + ); + }); + }); + + it('should reset thought to null when user cancels', async () => { + // Mock a stream that yields a thought then gets cancelled + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { subject: 'Some thought', description: 'Description' }, + }; + yield { type: ServerGeminiEventType.UserCancelled }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockSetShowHelp, + mockConfig, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + ), + ); + + // Submit query + await act(async () => { + await result.current.submitQuery('Test query'); + }); + + // Verify cancellation message was added + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'User cancelled the request.', + }), + expect.any(Number), + ); + }); + + // Verify state is reset to idle + expect(result.current.streamingState).toBe(StreamingState.Idle); + }); + + it('should reset thought to null when there is an error', async () => { + // Mock a stream that yields a thought then encounters an error + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { subject: 'Some thought', description: 'Description' }, + }; + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'Test error' } }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockSetShowHelp, + mockConfig, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + ), + ); + + // Submit query + await act(async () => { + await result.current.submitQuery('Test query'); + }); + + // Verify error message was added + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); + + // Verify parseAndFormatApiError was called + expect(mockParseAndFormatApiError).toHaveBeenCalledWith( + { message: 'Test error' }, + expect.any(String), + undefined, + 'gemini-2.5-pro', + 'gemini-2.5-flash', + ); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 456c0fb7..c934a139 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -414,8 +414,9 @@ export const useGeminiStream = ( userMessageTimestamp, ); setIsResponding(false); + setThought(null); // Reset thought when user cancels }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem], + [addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought], ); const handleErrorEvent = useCallback( @@ -437,8 +438,9 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); + setThought(null); // Reset thought when there's an error }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, config], + [addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought], ); const handleFinishedEvent = useCallback( @@ -637,6 +639,7 @@ export const useGeminiStream = ( if (!options?.isContinuation) { startNewPrompt(); + setThought(null); // Reset thought when starting a new prompt } setIsResponding(true); From 9aef0a8e6c133d3bf1ff43f80119664c154ffdf2 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Mon, 28 Jul 2025 11:13:46 -0700 Subject: [PATCH 016/136] Revert "feat: Add /config refresh command" (#5060) --- .../cli/src/services/BuiltinCommandLoader.ts | 2 - packages/cli/src/ui/App.tsx | 39 +----- packages/cli/src/ui/commands/configCommand.ts | 33 ----- packages/cli/src/ui/commands/types.ts | 1 - .../cli/src/ui/hooks/slashCommandProcessor.ts | 3 - packages/core/src/config/config.ts | 130 +++++------------- 6 files changed, 40 insertions(+), 168 deletions(-) delete mode 100644 packages/cli/src/ui/commands/configCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index b0c85d2a..7ba0d6bb 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -29,7 +29,6 @@ import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; -import { configCommand } from '../ui/commands/configCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -55,7 +54,6 @@ export class BuiltinCommandLoader implements ICommandLoader { compressCommand, copyCommand, corgiCommand, - configCommand, docsCommand, editorCommand, extensionsCommand, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 43060fdb..aacf45d7 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -39,12 +39,8 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; import { Help } from './components/Help.js'; -import { - loadHierarchicalGeminiMemory, - loadCliConfig, - parseArguments, -} from '../config/config.js'; -import { LoadedSettings, loadSettings } from '../config/settings.js'; +import { loadHierarchicalGeminiMemory } from '../config/config.js'; +import { LoadedSettings } from '../config/settings.js'; import { Tips } from './components/Tips.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup } from '../utils/cleanup.js'; @@ -66,7 +62,6 @@ import { AuthType, type IdeContext, ideContext, - sessionId, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.js'; @@ -94,7 +89,6 @@ import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; import { appEvents, AppEvent } from '../utils/events.js'; -import { loadExtensions } from '../config/extension.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -113,14 +107,12 @@ export const AppWrapper = (props: AppProps) => ( ); -const App = (props: AppProps) => { - const [config, setConfig] = useState(props.config); - const [settings, setSettings] = useState(props.settings); +const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const isFocused = useFocus(); useBracketedPaste(); const [updateMessage, setUpdateMessage] = useState(null); const { stdout } = useStdout(); - const nightly = props.version.includes('nightly'); + const nightly = version.includes('nightly'); useEffect(() => { checkForUpdates().then(setUpdateMessage); @@ -315,22 +307,6 @@ const App = (props: AppProps) => { } }, [config, addItem, settings.merged]); - const refreshConfig = useCallback(async () => { - const newSettings = loadSettings(process.cwd()); - const newExtensions = loadExtensions(process.cwd()); - const argv = await parseArguments(); - const newConfig = await loadCliConfig( - newSettings.merged, - newExtensions, - sessionId, - argv, - ); - await newConfig.initialize(); - setConfig(newConfig); - setSettings(newSettings); - setGeminiMdFileCount(newConfig.getGeminiMdFileCount()); - }, []); - // Watch for model changes (e.g., from Flash fallback) useEffect(() => { const checkModelChange = () => { @@ -498,7 +474,6 @@ const App = (props: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, - refreshConfig, ); const { @@ -802,7 +777,7 @@ const App = (props: AppProps) => { {!settings.merged.hideBanner && (
)} @@ -846,7 +821,7 @@ const App = (props: AppProps) => { {showHelp && } - {props.startupWarnings && props.startupWarnings.length > 0 && ( + {startupWarnings.length > 0 && ( { marginY={1} flexDirection="column" > - {props.startupWarnings.map((warning, index) => ( + {startupWarnings.map((warning, index) => ( {warning} diff --git a/packages/cli/src/ui/commands/configCommand.ts b/packages/cli/src/ui/commands/configCommand.ts deleted file mode 100644 index 3651b221..00000000 --- a/packages/cli/src/ui/commands/configCommand.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - CommandKind, - SlashCommand, - SlashCommandActionReturn, -} from './types.js'; - -export const configCommand: SlashCommand = { - name: 'config', - description: 'Commands for interacting with the CLI configuration.', - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'refresh', - description: 'Reload settings and extensions from the filesystem.', - kind: CommandKind.BUILT_IN, - action: async (context): Promise => { - await context.ui.refreshConfig(); - return { - type: 'message', - messageType: 'info', - content: - 'Configuration, extensions, memory, and tools have been refreshed.', - }; - }, - }, - ], -}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6665da4b..2844177f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,7 +59,6 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; - refreshConfig: () => Promise; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 67e49c21..be32de11 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -50,7 +50,6 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, - refreshConfig: () => Promise, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -159,7 +158,6 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, - refreshConfig, }, session: { stats: session.stats, @@ -182,7 +180,6 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, - refreshConfig, ], ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e03abe8a..7ccfdbc8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -188,62 +188,60 @@ export interface ConfigParameters { export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; - private sessionId: string; + private readonly sessionId: string; private contentGeneratorConfig!: ContentGeneratorConfig; - private embeddingModel: string; - private sandbox: SandboxConfig | undefined; - private targetDir: string; - private debugMode: boolean; - private question: string | undefined; - private fullContext: boolean; - private coreTools: string[] | undefined; - private excludeTools: string[] | undefined; - private toolDiscoveryCommand: string | undefined; - private toolCallCommand: string | undefined; - private mcpServerCommand: string | undefined; - private mcpServers: Record | undefined; + private readonly embeddingModel: string; + private readonly sandbox: SandboxConfig | undefined; + private readonly targetDir: string; + private readonly debugMode: boolean; + private readonly question: string | undefined; + private readonly fullContext: boolean; + private readonly coreTools: string[] | undefined; + private readonly excludeTools: string[] | undefined; + private readonly toolDiscoveryCommand: string | undefined; + private readonly toolCallCommand: string | undefined; + private readonly mcpServerCommand: string | undefined; + private readonly mcpServers: Record | undefined; private userMemory: string; private geminiMdFileCount: number; private approvalMode: ApprovalMode; - private showMemoryUsage: boolean; - private accessibility: AccessibilitySettings; - private telemetrySettings: TelemetrySettings; - private usageStatisticsEnabled: boolean; + private readonly showMemoryUsage: boolean; + private readonly accessibility: AccessibilitySettings; + private readonly telemetrySettings: TelemetrySettings; + private readonly usageStatisticsEnabled: boolean; private geminiClient!: GeminiClient; - private fileFiltering: { + private readonly fileFiltering: { respectGitIgnore: boolean; respectGeminiIgnore: boolean; enableRecursiveFileSearch: boolean; }; private fileDiscoveryService: FileDiscoveryService | null = null; private gitService: GitService | undefined = undefined; - private checkpointing: boolean; - private proxy: string | undefined; - private cwd: string; - private bugCommand: BugCommandSettings | undefined; - private model: string; - private extensionContextFilePaths: string[]; - private noBrowser: boolean; - private ideMode: boolean; - private ideClient: IdeClient | undefined; + private readonly checkpointing: boolean; + private readonly proxy: string | undefined; + private readonly cwd: string; + private readonly bugCommand: BugCommandSettings | undefined; + private readonly model: string; + private readonly extensionContextFilePaths: string[]; + private readonly noBrowser: boolean; + private readonly ideMode: boolean; + private readonly ideClient: IdeClient | undefined; private modelSwitchedDuringSession: boolean = false; - private maxSessionTurns: number; - private listExtensions: boolean; - private _extensions: GeminiCLIExtension[]; - private _blockedMcpServers: Array<{ + private readonly maxSessionTurns: number; + private readonly listExtensions: boolean; + private readonly _extensions: GeminiCLIExtension[]; + private readonly _blockedMcpServers: Array<{ name: string; extensionName: string; }>; flashFallbackHandler?: FlashFallbackHandler; private quotaErrorOccurred: boolean = false; - private summarizeToolOutput: + private readonly summarizeToolOutput: | Record | undefined; - private experimentalAcp: boolean = false; - private _params: ConfigParameters; + private readonly experimentalAcp: boolean = false; constructor(params: ConfigParameters) { - this._params = params; this.sessionId = params.sessionId; this.embeddingModel = params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; @@ -312,68 +310,6 @@ export class Config { } } - async refresh() { - // Re-run initialization logic. - await this.initialize(); - // After re-initializing, the tool registry will be updated. - // We need to update the gemini client with the new tools. - await this.geminiClient.setTools(); - } - - update(params: ConfigParameters) { - this._params = params; - // Re-assign all properties from the new params. - this.sessionId = params.sessionId; - this.embeddingModel = - params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; - this.sandbox = params.sandbox; - this.targetDir = path.resolve(params.targetDir); - this.debugMode = params.debugMode; - this.question = params.question; - this.fullContext = params.fullContext ?? false; - this.coreTools = params.coreTools; - this.excludeTools = params.excludeTools; - this.toolDiscoveryCommand = params.toolDiscoveryCommand; - this.toolCallCommand = params.toolCallCommand; - this.mcpServerCommand = params.mcpServerCommand; - this.mcpServers = params.mcpServers; - this.userMemory = params.userMemory ?? ''; - this.geminiMdFileCount = params.geminiMdFileCount ?? 0; - this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT; - this.showMemoryUsage = params.showMemoryUsage ?? false; - this.accessibility = params.accessibility ?? {}; - this.telemetrySettings = { - enabled: params.telemetry?.enabled ?? false, - target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET, - otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, - logPrompts: params.telemetry?.logPrompts ?? true, - outfile: params.telemetry?.outfile, - }; - this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; - this.fileFiltering = { - respectGitIgnore: params.fileFiltering?.respectGitIgnore ?? true, - respectGeminiIgnore: params.fileFiltering?.respectGeminiIgnore ?? true, - enableRecursiveFileSearch: - params.fileFiltering?.enableRecursiveFileSearch ?? true, - }; - this.checkpointing = params.checkpointing ?? false; - this.proxy = params.proxy; - this.cwd = params.cwd ?? process.cwd(); - this.fileDiscoveryService = params.fileDiscoveryService ?? null; - this.bugCommand = params.bugCommand; - this.model = params.model; - this.extensionContextFilePaths = params.extensionContextFilePaths ?? []; - this.maxSessionTurns = params.maxSessionTurns ?? -1; - this.experimentalAcp = params.experimentalAcp ?? false; - this.listExtensions = params.listExtensions ?? false; - this._extensions = params.extensions ?? []; - this._blockedMcpServers = params.blockedMcpServers ?? []; - this.noBrowser = params.noBrowser ?? false; - this.summarizeToolOutput = params.summarizeToolOutput; - this.ideMode = params.ideMode ?? false; - this.ideClient = params.ideClient; - } - async initialize(): Promise { // Initialize centralized FileDiscoveryService this.getFileService(); From cfe3753d4c956ffcf13e227c0619069db672adbf Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 28 Jul 2025 14:20:56 -0400 Subject: [PATCH 017/136] Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059) --- .../vscode-ide-companion/src/ide-server.ts | 53 +-- .../src/open-files-manager.test.ts | 440 ++++++++++++++++++ .../src/open-files-manager.ts | 178 +++++++ .../src/recent-files-manager.test.ts | 278 ----------- .../src/recent-files-manager.ts | 111 ----- 5 files changed, 626 insertions(+), 434 deletions(-) create mode 100644 packages/vscode-ide-companion/src/open-files-manager.test.ts create mode 100644 packages/vscode-ide-companion/src/open-files-manager.ts delete mode 100644 packages/vscode-ide-companion/src/recent-files-manager.test.ts delete mode 100644 packages/vscode-ide-companion/src/recent-files-manager.ts diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index df8e160b..8296c64c 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -14,59 +14,22 @@ import { type JSONRPCNotification, } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; -import { RecentFilesManager } from './recent-files-manager.js'; +import { OpenFilesManager } from './open-files-manager.js'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; -const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit function sendIdeContextUpdateNotification( transport: StreamableHTTPServerTransport, log: (message: string) => void, - recentFilesManager: RecentFilesManager, + openFilesManager: OpenFilesManager, ) { - const editor = vscode.window.activeTextEditor; - const activeFile = - editor && editor.document.uri.scheme === 'file' - ? editor.document.uri.fsPath - : undefined; - - const selection = editor?.selection; - const cursor = selection - ? { - // This value is a zero-based index, but the vscode IDE is one-based. - line: selection.active.line + 1, - character: selection.active.character, - } - : undefined; - - let selectedText = editor?.document.getText(selection) ?? undefined; - if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { - selectedText = - selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; - } - - const openFiles = recentFilesManager.recentFiles.map((file) => { - const isActive = file.filePath === activeFile; - return { - path: file.filePath, - timestamp: file.timestamp, - isActive, - ...(isActive && { - cursor, - selectedText, - }), - }; - }); + const ideContext = openFilesManager.state; const notification: JSONRPCNotification = { jsonrpc: '2.0', method: 'ide/contextUpdate', - params: { - workspaceState: { - openFiles, - }, - }, + params: ideContext, }; log( `Sending IDE context update notification: ${JSON.stringify( @@ -97,13 +60,13 @@ export class IDEServer { app.use(express.json()); const mcpServer = createMcpServer(); - const recentFilesManager = new RecentFilesManager(context); - const onDidChangeSubscription = recentFilesManager.onDidChange(() => { + const openFilesManager = new OpenFilesManager(context); + const onDidChangeSubscription = openFilesManager.onDidChange(() => { for (const transport of Object.values(transports)) { sendIdeContextUpdateNotification( transport, this.log.bind(this), - recentFilesManager, + openFilesManager, ); } }); @@ -207,7 +170,7 @@ export class IDEServer { sendIdeContextUpdateNotification( transport, this.log.bind(this), - recentFilesManager, + openFilesManager, ); sessionsWithInitialNotification.add(sessionId); } diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts new file mode 100644 index 00000000..0b1ada82 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { OpenFilesManager, MAX_FILES } from './open-files-manager.js'; + +vi.mock('vscode', () => ({ + EventEmitter: vi.fn(() => { + const listeners: Array<(e: void) => unknown> = []; + return { + event: vi.fn((listener) => { + listeners.push(listener); + return { dispose: vi.fn() }; + }), + fire: vi.fn(() => { + listeners.forEach((listener) => listener(undefined)); + }), + dispose: vi.fn(), + }; + }), + window: { + onDidChangeActiveTextEditor: vi.fn(), + onDidChangeTextEditorSelection: vi.fn(), + }, + workspace: { + onDidDeleteFiles: vi.fn(), + onDidCloseTextDocument: vi.fn(), + onDidRenameFiles: vi.fn(), + }, + Uri: { + file: (path: string) => ({ + fsPath: path, + scheme: 'file', + }), + }, + TextEditorSelectionChangeKind: { + Mouse: 2, + }, +})); + +describe('OpenFilesManager', () => { + let context: vscode.ExtensionContext; + let onDidChangeActiveTextEditorListener: ( + editor: vscode.TextEditor | undefined, + ) => void; + let onDidChangeTextEditorSelectionListener: ( + e: vscode.TextEditorSelectionChangeEvent, + ) => void; + let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void; + let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void; + let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void; + + beforeEach(() => { + vi.useFakeTimers(); + + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation( + (listener) => { + onDidChangeActiveTextEditorListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation( + (listener) => { + onDidChangeTextEditorSelectionListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation( + (listener) => { + onDidDeleteFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation( + (listener) => { + onDidCloseTextDocumentListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation( + (listener) => { + onDidRenameFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + + context = { + subscriptions: [], + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + const getUri = (path: string) => + vscode.Uri.file(path) as unknown as vscode.Uri; + + const addFile = (uri: vscode.Uri) => { + onDidChangeActiveTextEditorListener({ + document: { + uri, + getText: () => '', + }, + selection: { + active: { line: 0, character: 0 }, + }, + } as unknown as vscode.TextEditor); + }; + + it('adds a file to the list', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('moves an existing file to the top', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('does not exceed the max number of files', async () => { + const manager = new OpenFilesManager(context); + for (let i = 0; i < MAX_FILES + 5; i++) { + const uri = getUri(`/test/file${i}.txt`); + addFile(uri); + } + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(MAX_FILES); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + `/test/file${MAX_FILES + 4}.txt`, + ); + expect(manager.state.workspaceState!.openFiles![MAX_FILES - 1].path).toBe( + `/test/file5.txt`, + ); + }); + + it('fires onDidChange when a file is added', async () => { + const manager = new OpenFilesManager(context); + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + const uri = getUri('/test/file1.txt'); + addFile(uri); + + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is closed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); + + it('fires onDidChange when a file is removed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + + onDidDeleteFilesListener({ files: [uri1] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange when a file is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidDeleteFilesListener({ files: [uri] }); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes multiple files when they are deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + const uri3 = getUri('/test/file3.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri3); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(3); + + onDidDeleteFilesListener({ files: [uri1, uri3] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange only once when adding an existing file', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('updates the file when it is renamed', async () => { + const manager = new OpenFilesManager(context); + const oldUri = getUri('/test/file1.txt'); + const newUri = getUri('/test/file2.txt'); + addFile(oldUri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + + onDidRenameFilesListener({ files: [{ oldUri, newUri }] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('adds a file when the active editor changes', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('updates the cursor position on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.cursor).toEqual({ line: 11, character: 20 }); + }); + + it('updates the selected text on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + // We need to override the mock for getText for this test + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue('selected text'), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe('selected text'); + expect(textEditor.document.getText).toHaveBeenCalledWith(selection); + }); + + it('truncates long selected text', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const longText = 'a'.repeat(20000); + const truncatedText = longText.substring(0, 16384) + '... [TRUNCATED]'; + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue(longText), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe(truncatedText); + }); + + it('deactivates the previously active file', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri: uri1, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + await vi.advanceTimersByTimeAsync(100); + + let file1 = manager.state.workspaceState!.openFiles![0]; + expect(file1.isActive).toBe(true); + expect(file1.cursor).toBeDefined(); + + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + + file1 = manager.state.workspaceState!.openFiles!.find( + (f) => f.path === '/test/file1.txt', + )!; + const file2 = manager.state.workspaceState!.openFiles![0]; + + expect(file1.isActive).toBe(false); + expect(file1.cursor).toBeUndefined(); + expect(file1.selectedText).toBeUndefined(); + expect(file2.path).toBe('/test/file2.txt'); + expect(file2.isActive).toBe(true); + }); + + it('ignores non-file URIs', async () => { + const manager = new OpenFilesManager(context); + const uri = { + fsPath: '/test/file1.txt', + scheme: 'untitled', + } as vscode.Uri; + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); +}); diff --git a/packages/vscode-ide-companion/src/open-files-manager.ts b/packages/vscode-ide-companion/src/open-files-manager.ts new file mode 100644 index 00000000..ffd1a568 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { File, IdeContext } from '@google/gemini-cli-core'; + +export const MAX_FILES = 10; +const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit + +/** + * Keeps track of the workspace state, including open files, cursor position, and selected text. + */ +export class OpenFilesManager { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + private debounceTimer: NodeJS.Timeout | undefined; + private openFiles: File[] = []; + + constructor(private readonly context: vscode.ExtensionContext) { + const editorWatcher = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + if (editor && this.isFileUri(editor.document.uri)) { + this.addOrMoveToFront(editor); + this.fireWithDebounce(); + } + }, + ); + + const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( + (event) => { + if (this.isFileUri(event.textEditor.document.uri)) { + this.updateActiveContext(event.textEditor); + this.fireWithDebounce(); + } + }, + ); + + const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { + if (this.isFileUri(document.uri)) { + this.remove(document.uri); + this.fireWithDebounce(); + } + }); + + const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { + for (const uri of event.files) { + if (this.isFileUri(uri)) { + this.remove(uri); + } + } + this.fireWithDebounce(); + }); + + const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { + for (const { oldUri, newUri } of event.files) { + if (this.isFileUri(oldUri)) { + if (this.isFileUri(newUri)) { + this.rename(oldUri, newUri); + } else { + // The file was renamed to a non-file URI, so we should remove it. + this.remove(oldUri); + } + } + } + this.fireWithDebounce(); + }); + + context.subscriptions.push( + editorWatcher, + selectionWatcher, + closeWatcher, + deleteWatcher, + renameWatcher, + ); + + // Just add current active file on start-up. + if ( + vscode.window.activeTextEditor && + this.isFileUri(vscode.window.activeTextEditor.document.uri) + ) { + this.addOrMoveToFront(vscode.window.activeTextEditor); + } + } + + private isFileUri(uri: vscode.Uri): boolean { + return uri.scheme === 'file'; + } + + private addOrMoveToFront(editor: vscode.TextEditor) { + // Deactivate previous active file + const currentActive = this.openFiles.find((f) => f.isActive); + if (currentActive) { + currentActive.isActive = false; + currentActive.cursor = undefined; + currentActive.selectedText = undefined; + } + + // Remove if it exists + const index = this.openFiles.findIndex( + (f) => f.path === editor.document.uri.fsPath, + ); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + + // Add to the front as active + this.openFiles.unshift({ + path: editor.document.uri.fsPath, + timestamp: Date.now(), + isActive: true, + }); + + // Enforce max length + if (this.openFiles.length > MAX_FILES) { + this.openFiles.pop(); + } + + this.updateActiveContext(editor); + } + + private remove(uri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === uri.fsPath); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + } + + private rename(oldUri: vscode.Uri, newUri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath); + if (index !== -1) { + this.openFiles[index].path = newUri.fsPath; + } + } + + private updateActiveContext(editor: vscode.TextEditor) { + const file = this.openFiles.find( + (f) => f.path === editor.document.uri.fsPath, + ); + if (!file || !file.isActive) { + return; + } + + file.cursor = editor.selection.active + ? { + line: editor.selection.active.line + 1, + character: editor.selection.active.character, + } + : undefined; + + let selectedText: string | undefined = + editor.document.getText(editor.selection) || undefined; + if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { + selectedText = + selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; + } + file.selectedText = selectedText; + } + + private fireWithDebounce() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.onDidChangeEmitter.fire(); + }, 50); // 50ms + } + + get state(): IdeContext { + return { + workspaceState: { + openFiles: [...this.openFiles], + }, + }; + } +} diff --git a/packages/vscode-ide-companion/src/recent-files-manager.test.ts b/packages/vscode-ide-companion/src/recent-files-manager.test.ts deleted file mode 100644 index 9d56a10d..00000000 --- a/packages/vscode-ide-companion/src/recent-files-manager.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; -import { - RecentFilesManager, - MAX_FILES, - MAX_FILE_AGE_MINUTES, -} from './recent-files-manager.js'; - -vi.mock('vscode', () => ({ - EventEmitter: vi.fn(() => { - const listeners: Array<(e: void) => unknown> = []; - return { - event: vi.fn((listener) => { - listeners.push(listener); - return { dispose: vi.fn() }; - }), - fire: vi.fn(() => { - listeners.forEach((listener) => listener(undefined)); - }), - dispose: vi.fn(), - }; - }), - window: { - onDidChangeActiveTextEditor: vi.fn(), - onDidChangeTextEditorSelection: vi.fn(), - }, - workspace: { - onDidDeleteFiles: vi.fn(), - onDidCloseTextDocument: vi.fn(), - onDidRenameFiles: vi.fn(), - }, - Uri: { - file: (path: string) => ({ - fsPath: path, - scheme: 'file', - }), - }, -})); - -describe('RecentFilesManager', () => { - let context: vscode.ExtensionContext; - let onDidChangeActiveTextEditorListener: ( - editor: vscode.TextEditor | undefined, - ) => void; - let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void; - let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void; - let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void; - - beforeEach(() => { - vi.useFakeTimers(); - - vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation( - (listener) => { - onDidChangeActiveTextEditorListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation( - (listener) => { - onDidDeleteFilesListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation( - (listener) => { - onDidCloseTextDocumentListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation( - (listener) => { - onDidRenameFilesListener = listener; - return { dispose: vi.fn() }; - }, - ); - - context = { - subscriptions: [], - } as unknown as vscode.ExtensionContext; - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - const getUri = (path: string) => - vscode.Uri.file(path) as unknown as vscode.Uri; - - it('adds a file to the list', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); - - it('moves an existing file to the top', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - manager.add(uri1); - manager.add(uri2); - manager.add(uri1); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(2); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); - - it('does not exceed the max number of files', async () => { - const manager = new RecentFilesManager(context); - for (let i = 0; i < MAX_FILES + 5; i++) { - const uri = getUri(`/test/file${i}.txt`); - manager.add(uri); - } - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(MAX_FILES); - expect(manager.recentFiles[0].filePath).toBe( - `/test/file${MAX_FILES + 4}.txt`, - ); - expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`); - }); - - it('fires onDidChange when a file is added', async () => { - const manager = new RecentFilesManager(context); - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - const uri = getUri('/test/file1.txt'); - manager.add(uri); - - await vi.advanceTimersByTimeAsync(100); - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes a file when it is closed', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - - onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(0); - }); - - it('fires onDidChange when a file is removed', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); - await vi.advanceTimersByTimeAsync(100); - - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes a file when it is deleted', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - manager.add(uri1); - manager.add(uri2); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(2); - - onDidDeleteFilesListener({ files: [uri1] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('fires onDidChange when a file is deleted', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - onDidDeleteFilesListener({ files: [uri] }); - await vi.advanceTimersByTimeAsync(100); - - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes multiple files when they are deleted', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - const uri3 = getUri('/test/file3.txt'); - manager.add(uri1); - manager.add(uri2); - manager.add(uri3); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(3); - - onDidDeleteFilesListener({ files: [uri1, uri3] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('prunes files older than the max age', () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - manager.add(uri1); - - // Advance time by more than the max age - const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000; - vi.advanceTimersByTime(twoMinutesMs); - - const uri2 = getUri('/test/file2.txt'); - manager.add(uri2); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('fires onDidChange only once when adding an existing file', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(onDidChangeSpy).toHaveBeenCalledTimes(1); - }); - - it('updates the file when it is renamed', async () => { - const manager = new RecentFilesManager(context); - const oldUri = getUri('/test/file1.txt'); - const newUri = getUri('/test/file2.txt'); - manager.add(oldUri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - - onDidRenameFilesListener({ files: [{ oldUri, newUri }] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('adds a file when the active editor changes', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - - onDidChangeActiveTextEditorListener({ - document: { uri }, - } as vscode.TextEditor); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); -}); diff --git a/packages/vscode-ide-companion/src/recent-files-manager.ts b/packages/vscode-ide-companion/src/recent-files-manager.ts deleted file mode 100644 index 317cc903..00000000 --- a/packages/vscode-ide-companion/src/recent-files-manager.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode'; - -export const MAX_FILES = 10; -export const MAX_FILE_AGE_MINUTES = 5; - -interface RecentFile { - uri: vscode.Uri; - timestamp: number; -} - -/** - * Keeps track of the 10 most recently-opened files - * opened less than 5 min ago. If a file is closed or deleted, - * it will be removed. If the max length is reached, older files will get removed first. - */ -export class RecentFilesManager { - private readonly files: RecentFile[] = []; - private readonly onDidChangeEmitter = new vscode.EventEmitter(); - readonly onDidChange = this.onDidChangeEmitter.event; - private debounceTimer: NodeJS.Timeout | undefined; - - constructor(private readonly context: vscode.ExtensionContext) { - const editorWatcher = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - if (editor) { - this.add(editor.document.uri); - } - }, - ); - const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { - for (const uri of event.files) { - this.remove(uri); - } - }); - const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { - this.remove(document.uri); - }); - const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { - for (const { oldUri, newUri } of event.files) { - this.remove(oldUri, false); - this.add(newUri); - } - }); - - const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( - () => { - this.fireWithDebounce(); - }, - ); - - context.subscriptions.push( - editorWatcher, - deleteWatcher, - closeWatcher, - renameWatcher, - selectionWatcher, - ); - } - - private fireWithDebounce() { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - this.debounceTimer = setTimeout(() => { - this.onDidChangeEmitter.fire(); - }, 50); // 50ms - } - - private remove(uri: vscode.Uri, fireEvent = true) { - const index = this.files.findIndex( - (file) => file.uri.fsPath === uri.fsPath, - ); - if (index !== -1) { - this.files.splice(index, 1); - if (fireEvent) { - this.fireWithDebounce(); - } - } - } - - add(uri: vscode.Uri) { - if (uri.scheme !== 'file') { - return; - } - - this.remove(uri, false); - this.files.unshift({ uri, timestamp: Date.now() }); - - if (this.files.length > MAX_FILES) { - this.files.pop(); - } - this.fireWithDebounce(); - } - - get recentFiles(): Array<{ filePath: string; timestamp: number }> { - const now = Date.now(); - const maxAgeInMs = MAX_FILE_AGE_MINUTES * 60 * 1000; - return this.files - .filter((file) => now - file.timestamp < maxAgeInMs) - .map((file) => ({ - filePath: file.uri.fsPath, - timestamp: file.timestamp, - })); - } -} From b6c2c64f9b7e1ac034a35ccf3f5e0d7845fcdd77 Mon Sep 17 00:00:00 2001 From: Danny <659908+Dannyzen@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:35:06 -0400 Subject: [PATCH 018/136] Adds docs outlining keyboard shortcuts for gemini-cli (#4727) Co-authored-by: dannyzen Co-authored-by: Jacob Richman --- docs/keyboard-shortcuts.md | 62 +++++++++++++++++++++++++ packages/cli/src/ui/components/Help.tsx | 47 +++++++++++++------ 2 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 docs/keyboard-shortcuts.md diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md new file mode 100644 index 00000000..37e47045 --- /dev/null +++ b/docs/keyboard-shortcuts.md @@ -0,0 +1,62 @@ +# Gemini CLI Keyboard Shortcuts + +This document lists the available keyboard shortcuts in the Gemini CLI. + +## General + +| Shortcut | Description | +| -------- | --------------------------------------------------------------------------------------------------------------------- | +| `Esc` | Close dialogs and suggestions. | +| `Ctrl+C` | Exit the application. Press twice to confirm. | +| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | +| `Ctrl+L` | Clear the screen. | +| `Ctrl+O` | Toggle the display of the debug console. | +| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | +| `Ctrl+T` | Toggle the display of tool descriptions. | +| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. | + +## Input Prompt + +| Shortcut | Description | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `!` | Toggle shell mode when the input is empty. | +| `\` (at end of line) + `Enter` | Insert a newline. | +| `Down Arrow` | Navigate down through the input history. | +| `Enter` | Submit the current prompt. | +| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. | +| `Tab` | Autocomplete the current suggestion if one exists. | +| `Up Arrow` | Navigate up through the input history. | +| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. | +| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. | +| `Ctrl+C` | Clear the input prompt | +| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. | +| `Ctrl+E` / `End` | Move the cursor to the end of the line. | +| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. | +| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. | +| `Ctrl+K` | Delete from the cursor to the end of the line. | +| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. | +| `Ctrl+N` | Navigate down through the input history. | +| `Ctrl+P` | Navigate up through the input history. | +| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | +| `Ctrl+U` | Delete from the cursor to the beginning of the line. | +| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | +| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. | +| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. | + +## Suggestions + +| Shortcut | Description | +| --------------- | -------------------------------------- | +| `Down Arrow` | Navigate down through the suggestions. | +| `Tab` / `Enter` | Accept the selected suggestion. | +| `Up Arrow` | Navigate up through the suggestions. | + +## Radio Button Select + +| Shortcut | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------- | +| `Down Arrow` / `j` | Move selection down. | +| `Enter` | Confirm selection. | +| `Up Arrow` / `k` | Move selection up. | +| `1-9` | Select an item by its number. | +| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. | diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index ecad9b5e..d9f7b4a8 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -103,9 +103,15 @@ export const Help: React.FC = ({ commands }) => ( - Enter + Alt+Left/Right {' '} - - Send message + - Jump through words in the input + + + + Ctrl+C + {' '} + - Quit application @@ -117,21 +123,15 @@ export const Help: React.FC = ({ commands }) => ( - Up/Down + Ctrl+L {' '} - - Cycle through your prompt history + - Clear the screen - Alt+Left/Right + {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} {' '} - - Jump through words in the input - - - - Shift+Tab - {' '} - - Toggle auto-accepting edits + - Open input in external editor @@ -139,6 +139,12 @@ export const Help: React.FC = ({ commands }) => ( {' '} - Toggle YOLO mode + + + Enter + {' '} + - Send message + Esc @@ -147,9 +153,22 @@ export const Help: React.FC = ({ commands }) => ( - Ctrl+C + Shift+Tab {' '} - - Quit application + - Toggle auto-accepting edits + + + + Up/Down + {' '} + - Cycle through your prompt history + + + + For a full list of shortcuts, see{' '} + + docs/keyboard-shortcuts.md + ); From b08679c9066c9e26bd7a26ba9530bbef077cc883 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:55:50 -0400 Subject: [PATCH 019/136] Add new fallback state as prefactor for routing (#5065) --- packages/cli/src/ui/App.tsx | 1 + packages/core/src/config/config.test.ts | 6 ++ packages/core/src/config/config.ts | 16 ++--- .../core/src/config/flashFallback.test.ts | 68 ++++--------------- packages/core/src/core/client.test.ts | 4 +- packages/core/src/core/client.ts | 1 + packages/core/src/core/geminiChat.ts | 1 + 7 files changed, 30 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index aacf45d7..1ee8e8a8 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -398,6 +398,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Switch model for future use but return false to stop current retry config.setModel(fallbackModel); + config.setFallbackMode(true); logFlashFallback( config, new FlashFallbackEvent(config.getContentGeneratorConfig().authType!), diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3f0b3db5..f2169790 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -152,6 +152,10 @@ describe('Server Config (config.ts)', () => { (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); + // Set fallback mode to true to ensure it gets reset + config.setFallbackMode(true); + expect(config.isInFallbackMode()).toBe(true); + await config.refreshAuth(authType); expect(createContentGeneratorConfig).toHaveBeenCalledWith( @@ -163,6 +167,8 @@ describe('Server Config (config.ts)', () => { expect(config.getContentGeneratorConfig().model).toBe(newModel); expect(config.getModel()).toBe(newModel); // getModel() should return the updated model expect(GeminiClient).toHaveBeenCalledWith(config); + // Verify that fallback mode is reset + expect(config.isInFallbackMode()).toBe(false); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7ccfdbc8..ee9067c6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -226,7 +226,7 @@ export class Config { private readonly noBrowser: boolean; private readonly ideMode: boolean; private readonly ideClient: IdeClient | undefined; - private modelSwitchedDuringSession: boolean = false; + private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; private readonly _extensions: GeminiCLIExtension[]; @@ -330,7 +330,7 @@ export class Config { await this.geminiClient.initialize(this.contentGeneratorConfig); // Reset the session flag since we're explicitly changing auth and using default model - this.modelSwitchedDuringSession = false; + this.inFallbackMode = false; } getSessionId(): string { @@ -348,19 +348,15 @@ export class Config { setModel(newModel: string): void { if (this.contentGeneratorConfig) { this.contentGeneratorConfig.model = newModel; - this.modelSwitchedDuringSession = true; } } - isModelSwitchedDuringSession(): boolean { - return this.modelSwitchedDuringSession; + isInFallbackMode(): boolean { + return this.inFallbackMode; } - resetModelToDefault(): void { - if (this.contentGeneratorConfig) { - this.contentGeneratorConfig.model = this.model; // Reset to the original default model - this.modelSwitchedDuringSession = false; - } + setFallbackMode(active: boolean): void { + this.inFallbackMode = active; } setFlashFallbackHandler(handler: FlashFallbackHandler): void { diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 64f0f6fd..cd78cd34 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -29,26 +29,11 @@ describe('Flash Model Fallback Configuration', () => { }; }); + // These tests do not actually test fallback. isInFallbackMode() only returns true, + // when setFallbackMode is marked as true. This is to decouple setting a model + // with the fallback mechanism. This will be necessary we introduce more + // intelligent model routing. describe('setModel', () => { - it('should update the model and mark as switched during session', () => { - expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(false); - - config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - - expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(true); - }); - - it('should handle multiple model switches during session', () => { - config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(true); - - config.setModel('gemini-1.5-pro'); - expect(config.getModel()).toBe('gemini-1.5-pro'); - expect(config.isModelSwitchedDuringSession()).toBe(true); - }); - it('should only mark as switched if contentGeneratorConfig exists', () => { // Create config without initializing contentGeneratorConfig const newConfig = new Config({ @@ -61,7 +46,7 @@ describe('Flash Model Fallback Configuration', () => { // Should not crash when contentGeneratorConfig is undefined newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(newConfig.isModelSwitchedDuringSession()).toBe(false); + expect(newConfig.isInFallbackMode()).toBe(false); }); }); @@ -86,54 +71,25 @@ describe('Flash Model Fallback Configuration', () => { }); }); - describe('isModelSwitchedDuringSession', () => { + describe('isInFallbackMode', () => { it('should start as false for new session', () => { - expect(config.isModelSwitchedDuringSession()).toBe(false); + expect(config.isInFallbackMode()).toBe(false); }); it('should remain false if no model switch occurs', () => { // Perform other operations that don't involve model switching - expect(config.isModelSwitchedDuringSession()).toBe(false); + expect(config.isInFallbackMode()).toBe(false); }); it('should persist switched state throughout session', () => { config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(true); + // Setting state for fallback mode as is expected of clients + config.setFallbackMode(true); + expect(config.isInFallbackMode()).toBe(true); // Should remain true even after getting model config.getModel(); - expect(config.isModelSwitchedDuringSession()).toBe(true); - }); - }); - - describe('resetModelToDefault', () => { - it('should reset model to default and clear session switch flag', () => { - // Switch to Flash first - config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(true); - - // Reset to default - config.resetModelToDefault(); - - // Should be back to default with flag cleared - expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL); - expect(config.isModelSwitchedDuringSession()).toBe(false); - }); - - it('should handle case where contentGeneratorConfig is not initialized', () => { - // Create config without initializing contentGeneratorConfig - const newConfig = new Config({ - sessionId: 'test-session-2', - targetDir: '/test', - debugMode: false, - cwd: '/test', - model: DEFAULT_GEMINI_MODEL, - }); - - // Should not crash when contentGeneratorConfig is undefined - expect(() => newConfig.resetModelToDefault()).not.toThrow(); - expect(newConfig.isModelSwitchedDuringSession()).toBe(false); + expect(config.isInFallbackMode()).toBe(true); }); }); }); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 8c46d7f5..1da355f4 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -201,6 +201,7 @@ describe('Gemini Client (client.ts)', () => { getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getIdeMode: vi.fn().mockReturnValue(false), getGeminiClient: vi.fn(), + setFallbackMode: vi.fn(), }; const MockedConfig = vi.mocked(Config, true); MockedConfig.mockImplementation( @@ -1262,7 +1263,8 @@ Here are some files the user has open, with the most recent at the top: // mock config been changed const currentModel = initialModel + '-changed'; - vi.spyOn(client['config'], 'getModel').mockReturnValueOnce(currentModel); + const getModelSpy = vi.spyOn(client['config'], 'getModel'); + getModelSpy.mockReturnValue(currentModel); const mockFallbackHandler = vi.fn().mockResolvedValue(true); client['config'].flashFallbackHandler = mockFallbackHandler; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index e58e7040..6337bac2 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -717,6 +717,7 @@ export class GeminiClient { ); if (accepted !== false && accepted !== null) { this.config.setModel(fallbackModel); + this.config.setFallbackMode(true); return fallbackModel; } // Check if the model was switched manually in the handler diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 4c3cd4c8..d3b2e060 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -225,6 +225,7 @@ export class GeminiChat { ); if (accepted !== false && accepted !== null) { this.config.setModel(fallbackModel); + this.config.setFallbackMode(true); return fallbackModel; } // Check if the model was switched manually in the handler From 1c1aa047ff71992a4f9b9a43f1572037d7401691 Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Mon, 28 Jul 2025 13:43:39 -0700 Subject: [PATCH 020/136] feat: Add tests for checkpoint tag sanitization (#4882) --- packages/core/src/core/logger.test.ts | 31 +++++++++++++++++++-------- packages/core/src/core/logger.ts | 14 ++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index c74b92cf..3f243b52 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -393,12 +393,16 @@ describe('Logger', () => { { role: 'model', parts: [{ text: 'Hi there' }] }, ]; - it('should save a checkpoint to a tagged file when a tag is provided', async () => { - const tag = 'my-test-tag'; + it.each([ + { tag: 'test-tag', sanitizedTag: 'test-tag' }, + { tag: 'invalid/?*!', sanitizedTag: 'invalid' }, + { tag: '/?*!', sanitizedTag: 'default' }, + { tag: '../../secret', sanitizedTag: 'secret' }, + ])('should save a checkpoint', async ({ tag, sanitizedTag }) => { await logger.saveCheckpoint(conversation, tag); const taggedFilePath = path.join( TEST_GEMINI_DIR, - `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`, + `checkpoint-${sanitizedTag}.json`, ); const fileContent = await fs.readFile(taggedFilePath, 'utf-8'); expect(JSON.parse(fileContent)).toEqual(conversation); @@ -433,15 +437,19 @@ describe('Logger', () => { ); }); - it('should load from a tagged checkpoint file when a tag is provided', async () => { - const tag = 'my-load-tag'; + it.each([ + { tag: 'load-tag', sanitizedTag: 'load-tag' }, + { tag: 'inv/load?*!', sanitizedTag: 'invload' }, + { tag: '/?*!', sanitizedTag: 'default' }, + { tag: '../../secret', sanitizedTag: 'secret' }, + ])('should load from a checkpoint', async ({ tag, sanitizedTag }) => { const taggedConversation = [ ...conversation, - { role: 'user', parts: [{ text: 'Another message' }] }, + { role: 'user', parts: [{ text: 'hello' }] }, ]; const taggedFilePath = path.join( TEST_GEMINI_DIR, - `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`, + `checkpoint-${sanitizedTag}.json`, ); await fs.writeFile( taggedFilePath, @@ -464,11 +472,16 @@ describe('Logger', () => { }); it('should return an empty array if the file contains invalid JSON', async () => { - await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, 'invalid json'); + const tag = 'invalid-json-tag'; + const taggedFilePath = path.join( + TEST_GEMINI_DIR, + `checkpoint-${tag}.json`, + ); + await fs.writeFile(taggedFilePath, 'invalid json'); const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - const loadedCheckpoint = await logger.loadCheckpoint('missing'); + const loadedCheckpoint = await logger.loadCheckpoint(tag); expect(loadedCheckpoint).toEqual([]); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to read or parse checkpoint file'), diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 2be9f1d4..9f4622e7 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -239,12 +239,11 @@ export class Logger { throw new Error('Checkpoint file path not set.'); } // Sanitize tag to prevent directory traversal attacks - tag = tag.replace(/[^a-zA-Z0-9-_]/g, ''); - if (!tag) { - console.error('Sanitized tag is empty setting to "default".'); - tag = 'default'; + let sanitizedTag = tag.replace(/[^a-zA-Z0-9-_]/g, ''); + if (!sanitizedTag) { + sanitizedTag = 'default'; } - return path.join(this.geminiDir, `checkpoint-${tag}.json`); + return path.join(this.geminiDir, `checkpoint-${sanitizedTag}.json`); } async saveCheckpoint(conversation: Content[], tag: string): Promise { @@ -283,11 +282,6 @@ export class Logger { return parsedContent as Content[]; } catch (error) { console.error(`Failed to read or parse checkpoint file ${path}:`, error); - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - // File doesn't exist, which is fine. Return empty array. - return []; - } return []; } } From 83c4dddb7ee7ba34d7dec09d00819972d2e1ff5f Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 28 Jul 2025 16:55:00 -0400 Subject: [PATCH 021/136] Only enable IDE integration if gemini-cli is running in the same path as open workspace (#5068) --- packages/core/src/ide/ide-client.ts | 138 ++++++++++++------ .../vscode-ide-companion/src/extension.ts | 24 +++ 2 files changed, 117 insertions(+), 45 deletions(-) diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 64264fd1..3c670d54 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -15,7 +15,7 @@ const logger = { export type IDEConnectionState = { status: IDEConnectionStatus; - details?: string; + details?: string; // User-facing }; export enum IDEConnectionStatus { @@ -29,41 +29,82 @@ export enum IDEConnectionStatus { */ export class IdeClient { client: Client | undefined = undefined; - connectionStatus: IDEConnectionStatus = IDEConnectionStatus.Disconnected; + private state: IDEConnectionState = { + status: IDEConnectionStatus.Disconnected, + }; constructor() { - this.connectToMcpServer().catch((err) => { + this.init().catch((err) => { logger.debug('Failed to initialize IdeClient:', err); }); } - getConnectionStatus(): { - status: IDEConnectionStatus; - details?: string; - } { - let details: string | undefined; - if (this.connectionStatus === IDEConnectionStatus.Disconnected) { - if (!process.env['GEMINI_CLI_IDE_SERVER_PORT']) { - details = 'GEMINI_CLI_IDE_SERVER_PORT environment variable is not set.'; - } - } - return { - status: this.connectionStatus, - details, - }; + getConnectionStatus(): IDEConnectionState { + return this.state; } - async connectToMcpServer(): Promise { - this.connectionStatus = IDEConnectionStatus.Connecting; - const idePort = process.env['GEMINI_CLI_IDE_SERVER_PORT']; - if (!idePort) { - logger.debug( - 'Unable to connect to IDE mode MCP server. GEMINI_CLI_IDE_SERVER_PORT environment variable is not set.', + private setState(status: IDEConnectionStatus, details?: string) { + this.state = { status, details }; + + if (status === IDEConnectionStatus.Disconnected) { + logger.debug('IDE integration is disconnected. ', details); + ideContext.clearIdeContext(); + } + } + + private getPortFromEnv(): string | undefined { + const port = process.env['GEMINI_CLI_IDE_SERVER_PORT']; + if (!port) { + this.setState( + IDEConnectionStatus.Disconnected, + 'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.', ); - this.connectionStatus = IDEConnectionStatus.Disconnected; + return undefined; + } + return port; + } + + private validateWorkspacePath(): boolean { + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (!ideWorkspacePath) { + this.setState( + IDEConnectionStatus.Disconnected, + 'IDE integration requires a single workspace folder to be open in the IDE. Please ensure one folder is open and try again.', + ); + return false; + } + if (ideWorkspacePath !== process.cwd()) { + this.setState( + IDEConnectionStatus.Disconnected, + `Gemini CLI is running in a different directory (${process.cwd()}) from the IDE's open workspace (${ideWorkspacePath}). Please run Gemini CLI in the same directory.`, + ); + return false; + } + return true; + } + + private registerClientHandlers() { + if (!this.client) { return; } + this.client.setNotificationHandler( + IdeContextNotificationSchema, + (notification) => { + ideContext.setIdeContext(notification.params); + }, + ); + + this.client.onerror = (_error) => { + this.setState(IDEConnectionStatus.Disconnected, 'Client error.'); + }; + + this.client.onclose = () => { + this.setState(IDEConnectionStatus.Disconnected, 'Connection closed.'); + }; + } + + private async establishConnection(port: string) { let transport: StreamableHTTPClientTransport | undefined; try { this.client = new Client({ @@ -71,32 +112,21 @@ export class IdeClient { // TODO(#3487): use the CLI version here. version: '1.0.0', }); + transport = new StreamableHTTPClientTransport( - new URL(`http://localhost:${idePort}/mcp`), + new URL(`http://localhost:${port}/mcp`), ); + + this.registerClientHandlers(); + await this.client.connect(transport); - this.client.setNotificationHandler( - IdeContextNotificationSchema, - (notification) => { - ideContext.setIdeContext(notification.params); - }, - ); - this.client.onerror = (error) => { - logger.debug('IDE MCP client error:', error); - this.connectionStatus = IDEConnectionStatus.Disconnected; - ideContext.clearIdeContext(); - }; - this.client.onclose = () => { - logger.debug('IDE MCP client connection closed.'); - this.connectionStatus = IDEConnectionStatus.Disconnected; - ideContext.clearIdeContext(); - }; - - this.connectionStatus = IDEConnectionStatus.Connected; + this.setState(IDEConnectionStatus.Connected); } catch (error) { - this.connectionStatus = IDEConnectionStatus.Disconnected; - logger.debug('Failed to connect to MCP server:', error); + this.setState( + IDEConnectionStatus.Disconnected, + `Failed to connect to IDE server: ${error}`, + ); if (transport) { try { await transport.close(); @@ -106,4 +136,22 @@ export class IdeClient { } } } + + async init(): Promise { + if (this.state.status === IDEConnectionStatus.Connected) { + return; + } + this.setState(IDEConnectionStatus.Connecting); + + if (!this.validateWorkspacePath()) { + return; + } + + const port = this.getPortFromEnv(); + if (!port) { + return; + } + + await this.establishConnection(port); + } } diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 647acae3..637b69e3 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -8,14 +8,35 @@ import * as vscode from 'vscode'; import { IDEServer } from './ide-server'; import { createLogger } from './utils/logger'; +const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; + let ideServer: IDEServer; let logger: vscode.OutputChannel; let log: (message: string) => void = () => {}; +function updateWorkspacePath(context: vscode.ExtensionContext) { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length === 1) { + const workspaceFolder = workspaceFolders[0]; + context.environmentVariableCollection.replace( + IDE_WORKSPACE_PATH_ENV_VAR, + workspaceFolder.uri.fsPath, + ); + } else { + context.environmentVariableCollection.replace( + IDE_WORKSPACE_PATH_ENV_VAR, + '', + ); + } +} + export async function activate(context: vscode.ExtensionContext) { logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion'); log = createLogger(context, logger); log('Extension activated'); + + updateWorkspacePath(context); + ideServer = new IDEServer(log); try { await ideServer.start(context); @@ -25,6 +46,9 @@ export async function activate(context: vscode.ExtensionContext) { } context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + updateWorkspacePath(context); + }), vscode.commands.registerCommand('gemini-cli.runGeminiCLI', () => { const geminiCmd = 'gemini'; const terminal = vscode.window.createTerminal(`Gemini CLI`); From 871e0dfab811192f67cd80bc270580ad784ffdc8 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:56:52 -0700 Subject: [PATCH 022/136] feat: Add auto update functionality (#4686) --- packages/cli/src/config/settings.ts | 4 + packages/cli/src/gemini.tsx | 13 + packages/cli/src/ui/App.test.tsx | 178 ++++++++++ packages/cli/src/ui/App.tsx | 17 +- packages/cli/src/ui/utils/updateCheck.test.ts | 14 +- packages/cli/src/ui/utils/updateCheck.ts | 21 +- .../cli/src/utils/handleAutoUpdate.test.ts | 153 +++++++++ packages/cli/src/utils/handleAutoUpdate.ts | 139 ++++++++ .../cli/src/utils/installationInfo.test.ts | 313 ++++++++++++++++++ packages/cli/src/utils/installationInfo.ts | 177 ++++++++++ packages/cli/src/utils/updateEventEmitter.ts | 13 + packages/core/src/index.ts | 1 + 12 files changed, 1023 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/utils/handleAutoUpdate.test.ts create mode 100644 packages/cli/src/utils/handleAutoUpdate.ts create mode 100644 packages/cli/src/utils/installationInfo.test.ts create mode 100644 packages/cli/src/utils/installationInfo.ts create mode 100644 packages/cli/src/utils/updateEventEmitter.ts diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index c353d0c1..17c1d0d5 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -101,6 +101,10 @@ export interface Settings { // Add other settings here. ideMode?: boolean; + + // Setting for disabling auto-update. + disableAutoUpdate?: boolean; + memoryDiscoveryMaxDirs?: number; } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index c771fb95..a31c4b2f 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -40,6 +40,8 @@ import { import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; function getNodeMemoryArgs(config: Config): string[] { @@ -246,6 +248,17 @@ export async function main() { { exitOnCtrlC: false }, ); + checkForUpdates() + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + if (config.getDebugMode()) { + console.error('Update check failed:', err); + } + }); + registerCleanup(() => instance.unmount()); return; } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index f35f8cb7..fef4106a 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -23,6 +23,9 @@ import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { StreamingState, ConsoleMessageItem } from './types.js'; import { Tips } from './components/Tips.js'; +import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; +import { EventEmitter } from 'events'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -163,6 +166,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { MCPServerConfig: actualCore.MCPServerConfig, getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), ideContext: ideContextMock, + isGitRepository: vi.fn(), }; }); @@ -220,6 +224,17 @@ vi.mock('./components/Header.js', () => ({ Header: vi.fn(() => null), })); +vi.mock('./utils/updateCheck.js', () => ({ + checkForUpdates: vi.fn(), +})); + +const mockedCheckForUpdates = vi.mocked(checkForUpdates); +const { isGitRepository: mockedIsGitRepository } = vi.mocked( + await import('@google/gemini-cli-core'), +); + +vi.mock('node:child_process'); + describe('App UI', () => { let mockConfig: MockServerConfig; let mockSettings: LoadedSettings; @@ -288,6 +303,169 @@ describe('App UI', () => { vi.clearAllMocks(); // Clear mocks after each test }); + describe('handleAutoUpdate', () => { + let spawnEmitter: EventEmitter; + + beforeEach(async () => { + const { spawn } = await import('node:child_process'); + spawnEmitter = new EventEmitter(); + spawnEmitter.stdout = new EventEmitter(); + spawnEmitter.stderr = new EventEmitter(); + (spawn as vi.Mock).mockReturnValue(spawnEmitter); + }); + + afterEach(() => { + delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER; + }); + + it('should not start the update process when running from git', async () => { + mockedIsGitRepository.mockResolvedValue(true); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Gemini CLI update available!', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should show a success message when update succeeds', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-success', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Update successful! The new version will be used on your next run.', + ); + }); + + it('should show an error message when update fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should show an error message when spawn fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + // We are testing the App's reaction to an `update-failed` event, + // which is what should be emitted when a spawn error occurs elsewhere. + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => { + mockedIsGitRepository.mockResolvedValue(false); + process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true'; + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + }); + it('should display active file when available', async () => { vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 1ee8e8a8..7ac6936c 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -83,11 +83,12 @@ import { isGenericQuotaExceededError, UserTierId, } from '@google/gemini-cli-core'; -import { checkForUpdates } from './utils/updateCheck.js'; +import { UpdateObject } from './utils/updateCheck.js'; import ansiEscapes from 'ansi-escapes'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; +import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -110,15 +111,16 @@ export const AppWrapper = (props: AppProps) => ( const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const isFocused = useFocus(); useBracketedPaste(); - const [updateMessage, setUpdateMessage] = useState(null); + const [updateInfo, setUpdateInfo] = useState(null); const { stdout } = useStdout(); const nightly = version.includes('nightly'); + const { history, addItem, clearItems, loadHistory } = useHistory(); useEffect(() => { - checkForUpdates().then(setUpdateMessage); - }, []); + const cleanup = setUpdateHandler(addItem, setUpdateInfo); + return cleanup; + }, [addItem]); - const { history, addItem, clearItems, loadHistory } = useHistory(); const { consoleMessages, handleNewMessage, @@ -757,9 +759,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { return ( - {/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */} - {updateMessage && } - {/* * The Static component is an Ink intrinsic in which there can only be 1 per application. * Because of this restriction we're hacking it slightly by having a 'header' item here to @@ -822,6 +821,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showHelp && } + {/* Move UpdateNotification to render update notification above input area */} + {updateInfo && } {startupWarnings.length > 0 && ( { name: 'test-package', version: '1.0.0', }); - updateNotifier.mockReturnValue({ update: null }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn(async () => null), + }); const result = await checkForUpdates(); expect(result).toBeNull(); }); @@ -61,10 +63,12 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.1.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.1.0' })), }); + const result = await checkForUpdates(); - expect(result).toContain('1.0.0 → 1.1.0'); + expect(result?.message).toContain('1.0.0 → 1.1.0'); + expect(result?.update).toEqual({ current: '1.0.0', latest: '1.1.0' }); }); it('should return null if the latest version is the same as the current version', async () => { @@ -73,7 +77,7 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.0.0' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -85,7 +89,7 @@ describe('checkForUpdates', () => { version: '1.1.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.1.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '0.09' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 904a9890..b0a0de1b 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -4,11 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import updateNotifier from 'update-notifier'; +import updateNotifier, { UpdateInfo } from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; -export async function checkForUpdates(): Promise { +export interface UpdateObject { + message: string; + update: UpdateInfo; +} + +export async function checkForUpdates(): Promise { try { // Skip update check when running from source (development mode) if (process.env.DEV === 'true') { @@ -30,11 +35,13 @@ export async function checkForUpdates(): Promise { shouldNotifyInNpmScript: true, }); - if ( - notifier.update && - semver.gt(notifier.update.latest, notifier.update.current) - ) { - return `Gemini CLI update available! ${notifier.update.current} → ${notifier.update.latest}\nRun npm install -g ${packageJson.name} to update`; + const updateInfo = await notifier.fetchInfo(); + + if (updateInfo && semver.gt(updateInfo.latest, updateInfo.current)) { + return { + message: `Gemini CLI update available! ${updateInfo.current} → ${updateInfo.latest}`, + update: updateInfo, + }; } return null; diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts new file mode 100644 index 00000000..adaed932 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ChildProcess, spawn } from 'node:child_process'; +import { handleAutoUpdate } from './handleAutoUpdate.js'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { UpdateObject } from '../ui/utils/updateCheck.js'; +import { LoadedSettings } from '../config/settings.js'; + +// Mock dependencies +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +vi.mock('./installationInfo.js', async () => { + const actual = await vi.importActual('./installationInfo.js'); + return { + ...actual, + getInstallationInfo: vi.fn(), + }; +}); + +vi.mock('./updateEventEmitter.js', async () => { + const actual = await vi.importActual('./updateEventEmitter.js'); + return { + ...actual, + updateEventEmitter: { + ...actual.updateEventEmitter, + emit: vi.fn(), + }, + }; +}); + +const mockSpawn = vi.mocked(spawn); +const mockGetInstallationInfo = vi.mocked(getInstallationInfo); +const mockUpdateEventEmitter = vi.mocked(updateEventEmitter); + +describe('handleAutoUpdate', () => { + let mockUpdateInfo: UpdateObject; + let mockSettings: LoadedSettings; + let mockChildProcess: { + stderr: { on: ReturnType }; + stdout: { on: ReturnType }; + on: ReturnType; + unref: ReturnType; + }; + + beforeEach(() => { + mockUpdateInfo = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@google/gemini-cli', + }, + message: 'An update is available!', + }; + + mockSettings = { + merged: { + disableAutoUpdate: false, + }, + } as LoadedSettings; + + mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + unref: vi.fn(), + }; + mockSpawn.mockReturnValue(mockChildProcess as unknown as ChildProcess); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should do nothing if update info is null', () => { + handleAutoUpdate(null, mockSettings, '/root'); + expect(mockGetInstallationInfo).not.toHaveBeenCalled(); + expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-received" but not update if auto-updates are disabled', () => { + mockSettings.merged.disableAutoUpdate = true; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'Please update manually.', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nPlease update manually.', + }, + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-received" but not update if no update command is found', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: 'Cannot determine update command.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nCannot determine update command.', + }, + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should combine update messages correctly', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, // No command to prevent spawn + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nThis is an additional message.', + }, + ); + }); +}); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts new file mode 100644 index 00000000..1ef2d475 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { UpdateObject } from '../ui/utils/updateCheck.js'; +import { LoadedSettings } from '../config/settings.js'; +import { getInstallationInfo } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { HistoryItem, MessageType } from '../ui/types.js'; + +export function handleAutoUpdate( + info: UpdateObject | null, + settings: LoadedSettings, + projectRoot: string, +) { + if (!info) { + return; + } + + const installationInfo = getInstallationInfo( + projectRoot, + settings.merged.disableAutoUpdate ?? false, + ); + + let combinedMessage = info.message; + if (installationInfo.updateMessage) { + combinedMessage += `\n${installationInfo.updateMessage}`; + } + + updateEventEmitter.emit('update-received', { + message: combinedMessage, + }); + + if (!installationInfo.updateCommand || settings.merged.disableAutoUpdate) { + return; + } + + const updateCommand = installationInfo.updateCommand.replace( + '@latest', + `@${info.update.latest}`, + ); + + const updateProcess = spawn(updateCommand, { stdio: 'pipe', shell: true }); + let errorOutput = ''; + updateProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + updateProcess.on('close', (code) => { + if (code === 0) { + updateEventEmitter.emit('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + } else { + updateEventEmitter.emit('update-failed', { + message: `Automatic update failed. Please try updating manually. (command: ${updateCommand}, stderr: ${errorOutput.trim()})`, + }); + } + }); + + updateProcess.on('error', (err) => { + updateEventEmitter.emit('update-failed', { + message: `Automatic update failed. Please try updating manually. (error: ${err.message})`, + }); + }); + return updateProcess; +} + +export function setUpdateHandler( + addItem: (item: Omit, timestamp: number) => void, + setUpdateInfo: (info: UpdateObject | null) => void, +) { + let successfullyInstalled = false; + const handleUpdateRecieved = (info: UpdateObject) => { + setUpdateInfo(info); + const savedMessage = info.message; + setTimeout(() => { + if (!successfullyInstalled) { + addItem( + { + type: MessageType.INFO, + text: savedMessage, + }, + Date.now(), + ); + } + setUpdateInfo(null); + }, 60000); + }; + + const handleUpdateFailed = () => { + setUpdateInfo(null); + addItem( + { + type: MessageType.ERROR, + text: `Automatic update failed. Please try updating manually`, + }, + Date.now(), + ); + }; + + const handleUpdateSuccess = () => { + successfullyInstalled = true; + setUpdateInfo(null); + addItem( + { + type: MessageType.INFO, + text: `Update successful! The new version will be used on your next run.`, + }, + Date.now(), + ); + }; + + const handleUpdateInfo = (data: { message: string }) => { + addItem( + { + type: MessageType.INFO, + text: data.message, + }, + Date.now(), + ); + }; + + updateEventEmitter.on('update-received', handleUpdateRecieved); + updateEventEmitter.on('update-failed', handleUpdateFailed); + updateEventEmitter.on('update-success', handleUpdateSuccess); + updateEventEmitter.on('update-info', handleUpdateInfo); + + return () => { + updateEventEmitter.off('update-received', handleUpdateRecieved); + updateEventEmitter.off('update-failed', handleUpdateFailed); + updateEventEmitter.off('update-success', handleUpdateSuccess); + updateEventEmitter.off('update-info', handleUpdateInfo); + }; +} diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts new file mode 100644 index 00000000..c2bcf074 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; +import { isGitRepository } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + isGitRepository: vi.fn(), +})); + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + realpathSync: vi.fn(), + existsSync: vi.fn(), + }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +const mockedIsGitRepository = vi.mocked(isGitRepository); +const mockedRealPathSync = vi.mocked(fs.realpathSync); +const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedExecSync = vi.mocked(childProcess.execSync); + +describe('getInstallationInfo', () => { + const projectRoot = '/path/to/project'; + let originalArgv: string[]; + + beforeEach(() => { + vi.resetAllMocks(); + originalArgv = [...process.argv]; + // Mock process.cwd() for isGitRepository + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should return UNKNOWN when cliPath is not available', () => { + process.argv[1] = ''; + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + }); + + it('should return UNKNOWN and log error if realpathSync fails', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + process.argv[1] = '/path/to/cli'; + const error = new Error('realpath failed'); + mockedRealPathSync.mockImplementation(() => { + throw error; + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(consoleSpy).toHaveBeenCalledWith(error); + consoleSpy.mockRestore(); + }); + + it('should detect running from a local git clone', () => { + process.argv[1] = `${projectRoot}/packages/cli/dist/index.js`; + mockedRealPathSync.mockReturnValue( + `${projectRoot}/packages/cli/dist/index.js`, + ); + mockedIsGitRepository.mockReturnValue(true); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe( + 'Running from a local git clone. Please update with "git pull".', + ); + }); + + it('should detect running via npx', () => { + const npxPath = `/Users/test/.npm/_npx/12345/bin/gemini`; + process.argv[1] = npxPath; + mockedRealPathSync.mockReturnValue(npxPath); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.NPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via npx, update not applicable.'); + }); + + it('should detect running via pnpx', () => { + const pnpxPath = `/Users/test/.pnpm/_pnpx/12345/bin/gemini`; + process.argv[1] = pnpxPath; + mockedRealPathSync.mockReturnValue(pnpxPath); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.PNPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via pnpx, update not applicable.'); + }); + + it('should detect running via bunx', () => { + const bunxPath = `/Users/test/.bun/install/cache/12345/bin/gemini`; + process.argv[1] = bunxPath; + mockedRealPathSync.mockReturnValue(bunxPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.BUNX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via bunx, update not applicable.'); + }); + + it('should detect Homebrew installation via execSync', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const cliPath = '/usr/local/bin/gemini'; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExecSync.mockReturnValue(Buffer.from('gemini-cli')); // Simulate successful command + + const info = getInstallationInfo(projectRoot, false); + + expect(mockedExecSync).toHaveBeenCalledWith( + 'brew list -1 | grep -q "^gemini-cli$"', + { stdio: 'ignore' }, + ); + expect(info.packageManager).toBe(PackageManager.HOMEBREW); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toContain('brew upgrade'); + }); + + it('should fall through if brew command fails', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const cliPath = '/usr/local/bin/gemini'; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(mockedExecSync).toHaveBeenCalledWith( + 'brew list -1 | grep -q "^gemini-cli$"', + { stdio: 'ignore' }, + ); + // Should fall back to default global npm + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + }); + + it('should detect global pnpm installation', () => { + const pnpmPath = `/Users/test/.pnpm/global/5/node_modules/.pnpm/some-hash/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = pnpmPath; + mockedRealPathSync.mockReturnValue(pnpmPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('pnpm add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run pnpm add'); + }); + + it('should detect global yarn installation', () => { + const yarnPath = `/Users/test/.yarn/global/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = yarnPath; + mockedRealPathSync.mockReturnValue(yarnPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe( + 'yarn global add @google/gemini-cli@latest', + ); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run yarn global add'); + }); + + it('should detect global bun installation', () => { + const bunPath = `/Users/test/.bun/bin/gemini`; + process.argv[1] = bunPath; + mockedRealPathSync.mockReturnValue(bunPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('bun add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run bun add'); + }); + + it('should detect local installation and identify yarn from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'yarn.lock'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toContain('Locally installed'); + }); + + it('should detect local installation and identify pnpm from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'pnpm-lock.yaml'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(false); + }); + + it('should detect local installation and identify bun from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'bun.lockb'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(false); + }); + + it('should default to local npm installation if no lockfile is found', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockReturnValue(false); // No lockfiles + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(false); + }); + + it('should default to global npm installation for unrecognized paths', () => { + const globalPath = `/usr/local/bin/gemini`; + process.argv[1] = globalPath; + mockedRealPathSync.mockReturnValue(globalPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('npm install -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run npm install'); + }); +}); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts new file mode 100644 index 00000000..ca5733d3 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isGitRepository } from '@google/gemini-cli-core'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; + +export enum PackageManager { + NPM = 'npm', + YARN = 'yarn', + PNPM = 'pnpm', + PNPX = 'pnpx', + BUN = 'bun', + BUNX = 'bunx', + HOMEBREW = 'homebrew', + NPX = 'npx', + UNKNOWN = 'unknown', +} + +export interface InstallationInfo { + packageManager: PackageManager; + isGlobal: boolean; + updateCommand?: string; + updateMessage?: string; +} + +export function getInstallationInfo( + projectRoot: string, + isAutoUpdateDisabled: boolean, +): InstallationInfo { + const cliPath = process.argv[1]; + if (!cliPath) { + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } + + try { + // Normalize path separators to forward slashes for consistent matching. + const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/'); + const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/'); + const isGit = isGitRepository(process.cwd()); + + // Check for local git clone first + if ( + isGit && + normalizedProjectRoot && + realPath.startsWith(normalizedProjectRoot) && + !realPath.includes('/node_modules/') + ) { + return { + packageManager: PackageManager.UNKNOWN, // Not managed by a package manager in this sense + isGlobal: false, + updateMessage: + 'Running from a local git clone. Please update with "git pull".', + }; + } + + // Check for npx/pnpx + if (realPath.includes('/.npm/_npx') || realPath.includes('/npm/_npx')) { + return { + packageManager: PackageManager.NPX, + isGlobal: false, + updateMessage: 'Running via npx, update not applicable.', + }; + } + if (realPath.includes('/.pnpm/_pnpx')) { + return { + packageManager: PackageManager.PNPX, + isGlobal: false, + updateMessage: 'Running via pnpx, update not applicable.', + }; + } + + // Check for Homebrew + if (process.platform === 'darwin') { + try { + // The package name in homebrew is gemini-cli + childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', { + stdio: 'ignore', + }); + return { + packageManager: PackageManager.HOMEBREW, + isGlobal: true, + updateMessage: + 'Installed via Homebrew. Please update with "brew upgrade".', + }; + } catch (_error) { + // Brew is not installed or gemini-cli is not installed via brew. + // Continue to the next check. + } + } + + // Check for pnpm + if (realPath.includes('/.pnpm/global')) { + const updateCommand = 'pnpm add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.PNPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with pnpm. Attempting to automatically update now...', + }; + } + + // Check for yarn + if (realPath.includes('/.yarn/global')) { + const updateCommand = 'yarn global add @google/gemini-cli@latest'; + return { + packageManager: PackageManager.YARN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with yarn. Attempting to automatically update now...', + }; + } + + // Check for bun + if (realPath.includes('/.bun/install/cache')) { + return { + packageManager: PackageManager.BUNX, + isGlobal: false, + updateMessage: 'Running via bunx, update not applicable.', + }; + } + if (realPath.includes('/.bun/bin')) { + const updateCommand = 'bun add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.BUN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with bun. Attempting to automatically update now...', + }; + } + + // Check for local install + if ( + normalizedProjectRoot && + realPath.startsWith(`${normalizedProjectRoot}/node_modules`) + ) { + let pm = PackageManager.NPM; + if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) { + pm = PackageManager.YARN; + } else if (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) { + pm = PackageManager.PNPM; + } else if (fs.existsSync(path.join(projectRoot, 'bun.lockb'))) { + pm = PackageManager.BUN; + } + return { + packageManager: pm, + isGlobal: false, + updateMessage: + "Locally installed. Please update via your project's package.json.", + }; + } + + // Assume global npm + const updateCommand = 'npm install -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with npm. Attempting to automatically update now...', + }; + } catch (error) { + console.log(error); + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } +} diff --git a/packages/cli/src/utils/updateEventEmitter.ts b/packages/cli/src/utils/updateEventEmitter.ts new file mode 100644 index 00000000..a60ef039 --- /dev/null +++ b/packages/cli/src/utils/updateEventEmitter.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'events'; + +/** + * A shared event emitter for application-wide communication + * between decoupled parts of the CLI. + */ +export const updateEventEmitter = new EventEmitter(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a49c83fe..ecc408fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export * from './utils/errors.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/gitIgnoreParser.js'; +export * from './utils/gitUtils.js'; export * from './utils/editor.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/fileUtils.js'; From 7356764a489b47bc43dae9e9653380cbe9bce294 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 28 Jul 2025 18:40:47 -0700 Subject: [PATCH 023/136] feat(commands): add custom commands support for extensions (#4703) --- docs/extension.md | 36 +- packages/cli/src/config/config.test.ts | 5 + packages/cli/src/config/extension.test.ts | 25 ++ packages/cli/src/config/extension.ts | 5 + .../cli/src/services/CommandService.test.ts | 172 +++++++++ packages/cli/src/services/CommandService.ts | 36 +- .../src/services/FileCommandLoader.test.ts | 361 ++++++++++++++++-- .../cli/src/services/FileCommandLoader.ts | 135 ++++--- packages/cli/src/ui/commands/types.ts | 3 + .../ui/hooks/slashCommandProcessor.test.ts | 9 +- packages/core/src/config/config.ts | 1 + 11 files changed, 705 insertions(+), 83 deletions(-) diff --git a/docs/extension.md b/docs/extension.md index 0bdede0b..aa5d837a 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -33,10 +33,44 @@ The `gemini-extension.json` file contains the configuration for the extension. T } ``` -- `name`: The name of the extension. This is used to uniquely identify the extension. This should match the name of your extension directory. +- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded. - `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. + +## Extension Commands + +Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions. + +### Example + +An extension named `gcp` with the following structure: + +``` +.gemini/extensions/gcp/ +├── gemini-extension.json +└── commands/ + ├── deploy.toml + └── gcs/ + └── sync.toml +``` + +Would provide these commands: + +- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help +- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help + +### Conflict Resolution + +Extension commands have the lowest precedence. When a conflict occurs with user or project commands: + +1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`) +2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`) + +For example, if both a user and the `gcp` extension define a `deploy` command: + +- `/deploy` - Executes the user's deploy command +- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 55780320..7f47660d 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -35,6 +35,11 @@ vi.mock('@google/gemini-cli-core', async () => { ); return { ...actualServer, + IdeClient: vi.fn().mockImplementation(() => ({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + })), loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( (cwd, debug, fileService, extensionPaths, _maxDirs) => diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 6b2a3f83..85852bd7 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -42,6 +42,31 @@ describe('loadExtensions', () => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); }); + it('should include extension path in loaded extension', () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + + const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + const config = { + name: 'test-extension', + version: '1.0.0', + }; + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(config), + ); + + const extensions = loadExtensions(tempWorkspaceDir); + expect(extensions).toHaveLength(1); + expect(extensions[0].path).toBe(extensionDir); + expect(extensions[0].config.name).toBe('test-extension'); + }); + it('should load context file path when GEMINI.md is present', () => { const workspaceExtensionsDir = path.join( tempWorkspaceDir, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index adefec29..1922f55a 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -13,6 +13,7 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export interface Extension { + path: string; config: ExtensionConfig; contextFiles: string[]; } @@ -90,6 +91,7 @@ function loadExtension(extensionDir: string): Extension | null { .filter((contextFilePath) => fs.existsSync(contextFilePath)); return { + path: extensionDir, config, contextFiles, }; @@ -121,6 +123,7 @@ export function annotateActiveExtensions( name: extension.config.name, version: extension.config.version, isActive: true, + path: extension.path, })); } @@ -136,6 +139,7 @@ export function annotateActiveExtensions( name: extension.config.name, version: extension.config.version, isActive: false, + path: extension.path, })); } @@ -153,6 +157,7 @@ export function annotateActiveExtensions( name: extension.config.name, version: extension.config.version, isActive, + path: extension.path, }); } diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 28731f81..e2d5b9f5 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -177,4 +177,176 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledTimes(1); expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); + + it('should rename extension commands when they conflict', async () => { + const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + const userCommand = createMockCommand('sync', CommandKind.FILE); + const extensionCommand1 = { + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'firebase', + description: '[firebase] Deploy to Firebase', + }; + const extensionCommand2 = { + ...createMockCommand('sync', CommandKind.FILE), + extensionName: 'git-helper', + description: '[git-helper] Sync with remote', + }; + + const mockLoader1 = new MockCommandLoader([builtinCommand]); + const mockLoader2 = new MockCommandLoader([ + userCommand, + extensionCommand1, + extensionCommand2, + ]); + + const service = await CommandService.create( + [mockLoader1, mockLoader2], + new AbortController().signal, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(4); + + // Built-in command keeps original name + const deployBuiltin = commands.find( + (cmd) => cmd.name === 'deploy' && !cmd.extensionName, + ); + expect(deployBuiltin).toBeDefined(); + expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); + + // Extension command conflicting with built-in gets renamed + const deployExtension = commands.find( + (cmd) => cmd.name === 'firebase.deploy', + ); + expect(deployExtension).toBeDefined(); + expect(deployExtension?.extensionName).toBe('firebase'); + + // User command keeps original name + const syncUser = commands.find( + (cmd) => cmd.name === 'sync' && !cmd.extensionName, + ); + expect(syncUser).toBeDefined(); + expect(syncUser?.kind).toBe(CommandKind.FILE); + + // Extension command conflicting with user command gets renamed + const syncExtension = commands.find( + (cmd) => cmd.name === 'git-helper.sync', + ); + expect(syncExtension).toBeDefined(); + expect(syncExtension?.extensionName).toBe('git-helper'); + }); + + it('should handle user/project command override correctly', async () => { + const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN); + const userCommand = createMockCommand('help', CommandKind.FILE); + const projectCommand = createMockCommand('deploy', CommandKind.FILE); + const userDeployCommand = createMockCommand('deploy', CommandKind.FILE); + + const mockLoader1 = new MockCommandLoader([builtinCommand]); + const mockLoader2 = new MockCommandLoader([ + userCommand, + userDeployCommand, + projectCommand, + ]); + + const service = await CommandService.create( + [mockLoader1, mockLoader2], + new AbortController().signal, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + + // User command overrides built-in + const helpCommand = commands.find((cmd) => cmd.name === 'help'); + expect(helpCommand).toBeDefined(); + expect(helpCommand?.kind).toBe(CommandKind.FILE); + + // Project command overrides user command (last wins) + const deployCommand = commands.find((cmd) => cmd.name === 'deploy'); + expect(deployCommand).toBeDefined(); + expect(deployCommand?.kind).toBe(CommandKind.FILE); + }); + + it('should handle secondary conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp.deploy commands + const userCommand1 = createMockCommand('deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + + // Extension also has a deploy command that will conflict with user's /deploy + const extensionCommand = { + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'gcp', + description: '[gcp] Deploy to Google Cloud', + }; + + const mockLoader = new MockCommandLoader([ + userCommand1, + userCommand2, + extensionCommand, + ]); + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(3); + + // Original user command keeps its name + const deployUser = commands.find( + (cmd) => cmd.name === 'deploy' && !cmd.extensionName, + ); + expect(deployUser).toBeDefined(); + + // User's dot notation command keeps its name + const gcpDeployUser = commands.find( + (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, + ); + expect(gcpDeployUser).toBeDefined(); + + // Extension command gets renamed with suffix due to secondary conflict + const deployExtension = commands.find( + (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', + ); + expect(deployExtension).toBeDefined(); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + }); + + it('should handle multiple secondary conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp.deploy, and /gcp.deploy1 + const userCommand1 = createMockCommand('deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); + + // Extension has a deploy command + const extensionCommand = { + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'gcp', + description: '[gcp] Deploy to Google Cloud', + }; + + const mockLoader = new MockCommandLoader([ + userCommand1, + userCommand2, + userCommand3, + extensionCommand, + ]); + + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(4); + + // Extension command gets renamed with suffix 2 due to multiple conflicts + const deployExtension = commands.find( + (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', + ); + expect(deployExtension).toBeDefined(); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index ef4f4d14..78e4817b 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -30,13 +30,17 @@ export class CommandService { * * This factory method orchestrates the entire command loading process. It * runs all provided loaders in parallel, aggregates their results, handles - * name conflicts by letting the last-loaded command win, and then returns a + * name conflicts for extension commands by renaming them, and then returns a * fully constructed `CommandService` instance. * + * Conflict resolution: + * - Extension commands that conflict with existing commands are renamed to + * `extensionName.commandName` + * - Non-extension commands (built-in, user, project) override earlier commands + * with the same name based on loader order + * * @param loaders An array of objects that conform to the `ICommandLoader` - * interface. The order of loaders is significant: if multiple loaders - * provide a command with the same name, the command from the loader that - * appears later in the array will take precedence. + * interface. Built-in commands should come first, followed by FileCommandLoader. * @param signal An AbortSignal to cancel the loading process. * @returns A promise that resolves to a new, fully initialized `CommandService` instance. */ @@ -57,12 +61,28 @@ export class CommandService { } } - // De-duplicate commands using a Map. The last one found with a given name wins. - // This creates a natural override system based on the order of the loaders - // passed to the constructor. const commandMap = new Map(); for (const cmd of allCommands) { - commandMap.set(cmd.name, cmd); + let finalName = cmd.name; + + // Extension commands get renamed if they conflict with existing commands + if (cmd.extensionName && commandMap.has(cmd.name)) { + let renamedName = `${cmd.extensionName}.${cmd.name}`; + let suffix = 1; + + // Keep trying until we find a name that doesn't conflict + while (commandMap.has(renamedName)) { + renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; + suffix++; + } + + finalName = renamedName; + } + + commandMap.set(finalName, { + ...cmd, + name: finalName, + }); } const finalCommands = Object.freeze(Array.from(commandMap.values())); diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index e3cbceb2..f4f8ac2c 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { FileCommandLoader } from './FileCommandLoader.js'; +import * as path from 'node:path'; import { Config, getProjectCommandsDir, getUserCommandsDir, } from '@google/gemini-cli-core'; import mock from 'mock-fs'; +import { FileCommandLoader } from './FileCommandLoader.js'; import { assert, vi } from 'vitest'; import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; import { @@ -85,7 +86,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -176,7 +177,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(2); @@ -194,9 +195,11 @@ describe('FileCommandLoader', () => { }, }, }); - const loader = new FileCommandLoader({ - getProjectRoot: () => '/path/to/project', - } as Config); + const mockConfig = { + getProjectRoot: vi.fn(() => '/path/to/project'), + getExtensions: vi.fn(() => []), + } as Config; + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0]!.name).toBe('gcp:pipelines:run'); @@ -212,7 +215,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -221,7 +224,7 @@ describe('FileCommandLoader', () => { expect(command.name).toBe('git:commit'); }); - it('overrides user commands with project commands', async () => { + it('returns both user and project commands in order', async () => { const userCommandsDir = getUserCommandsDir(); const projectCommandsDir = getProjectCommandsDir(process.cwd()); mock({ @@ -233,16 +236,15 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader({ - getProjectRoot: () => process.cwd(), - } as Config); + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => []), + } as Config; + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); - expect(commands).toHaveLength(1); - const command = commands[0]; - expect(command).toBeDefined(); - - const result = await command.action?.( + expect(commands).toHaveLength(2); + const userResult = await commands[0].action?.( createMockCommandContext({ invocation: { raw: '/test', @@ -252,10 +254,25 @@ describe('FileCommandLoader', () => { }), '', ); - if (result?.type === 'submit_prompt') { - expect(result.content).toBe('Project prompt'); + if (userResult?.type === 'submit_prompt') { + expect(userResult.content).toBe('User prompt'); } else { - assert.fail('Incorrect action type'); + assert.fail('Incorrect action type for user command'); + } + const projectResult = await commands[1].action?.( + createMockCommandContext({ + invocation: { + raw: '/test', + name: 'test', + args: '', + }, + }), + '', + ); + if (projectResult?.type === 'submit_prompt') { + expect(projectResult.content).toBe('Project prompt'); + } else { + assert.fail('Incorrect action type for project command'); } }); @@ -268,7 +285,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -284,7 +301,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -299,7 +316,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); @@ -308,7 +325,7 @@ describe('FileCommandLoader', () => { it('handles file system errors gracefully', async () => { mock({}); // Mock an empty file system - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(0); }); @@ -321,7 +338,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); @@ -336,7 +353,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); @@ -351,7 +368,7 @@ describe('FileCommandLoader', () => { }, }); - const loader = new FileCommandLoader(null as unknown as Config); + const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -362,6 +379,298 @@ describe('FileCommandLoader', () => { expect(command.name).toBe('legacy_command'); }); + describe('Extension Command Loading', () => { + it('loads commands from active extensions', async () => { + const userCommandsDir = getUserCommandsDir(); + const projectCommandsDir = getProjectCommandsDir(process.cwd()); + const extensionDir = path.join( + process.cwd(), + '.gemini/extensions/test-ext', + ); + + mock({ + [userCommandsDir]: { + 'user.toml': 'prompt = "User command"', + }, + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project command"', + }, + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'test-ext', + version: '1.0.0', + }), + commands: { + 'ext.toml': 'prompt = "Extension command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + const commandNames = commands.map((cmd) => cmd.name); + expect(commandNames).toEqual(['user', 'project', 'ext']); + + const extCommand = commands.find((cmd) => cmd.name === 'ext'); + expect(extCommand?.extensionName).toBe('test-ext'); + expect(extCommand?.description).toMatch(/^\[test-ext\]/); + }); + + it('extension commands have extensionName metadata for conflict resolution', async () => { + const userCommandsDir = getUserCommandsDir(); + const projectCommandsDir = getProjectCommandsDir(process.cwd()); + const extensionDir = path.join( + process.cwd(), + '.gemini/extensions/test-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'test-ext', + version: '1.0.0', + }), + commands: { + 'deploy.toml': 'prompt = "Extension deploy command"', + }, + }, + [userCommandsDir]: { + 'deploy.toml': 'prompt = "User deploy command"', + }, + [projectCommandsDir]: { + 'deploy.toml': 'prompt = "Project deploy command"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + // Return all commands, even duplicates + expect(commands).toHaveLength(3); + + expect(commands[0].name).toBe('deploy'); + expect(commands[0].extensionName).toBeUndefined(); + const result0 = await commands[0].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result0?.type).toBe('submit_prompt'); + if (result0?.type === 'submit_prompt') { + expect(result0.content).toBe('User deploy command'); + } + + expect(commands[1].name).toBe('deploy'); + expect(commands[1].extensionName).toBeUndefined(); + const result1 = await commands[1].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result1?.type).toBe('submit_prompt'); + if (result1?.type === 'submit_prompt') { + expect(result1.content).toBe('Project deploy command'); + } + + expect(commands[2].name).toBe('deploy'); + expect(commands[2].extensionName).toBe('test-ext'); + expect(commands[2].description).toMatch(/^\[test-ext\]/); + const result2 = await commands[2].action?.( + createMockCommandContext({ + invocation: { + raw: '/deploy', + name: 'deploy', + args: '', + }, + }), + '', + ); + expect(result2?.type).toBe('submit_prompt'); + if (result2?.type === 'submit_prompt') { + expect(result2.content).toBe('Extension deploy command'); + } + }); + + it('only loads commands from active extensions', async () => { + const extensionDir1 = path.join( + process.cwd(), + '.gemini/extensions/active-ext', + ); + const extensionDir2 = path.join( + process.cwd(), + '.gemini/extensions/inactive-ext', + ); + + mock({ + [extensionDir1]: { + 'gemini-extension.json': JSON.stringify({ + name: 'active-ext', + version: '1.0.0', + }), + commands: { + 'active.toml': 'prompt = "Active extension command"', + }, + }, + [extensionDir2]: { + 'gemini-extension.json': JSON.stringify({ + name: 'inactive-ext', + version: '1.0.0', + }), + commands: { + 'inactive.toml': 'prompt = "Inactive extension command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'active-ext', + version: '1.0.0', + isActive: true, + path: extensionDir1, + }, + { + name: 'inactive-ext', + version: '1.0.0', + isActive: false, + path: extensionDir2, + }, + ]), + } as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].name).toBe('active'); + expect(commands[0].extensionName).toBe('active-ext'); + expect(commands[0].description).toMatch(/^\[active-ext\]/); + }); + + it('handles missing extension commands directory gracefully', async () => { + const extensionDir = path.join( + process.cwd(), + '.gemini/extensions/no-commands', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'no-commands', + version: '1.0.0', + }), + // No commands directory + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'no-commands', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + } as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + expect(commands).toHaveLength(0); + }); + + it('handles nested command structure in extensions', async () => { + const extensionDir = path.join(process.cwd(), '.gemini/extensions/a'); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + commands: { + b: { + 'c.toml': 'prompt = "Nested command from extension a"', + d: { + 'e.toml': 'prompt = "Deeply nested command"', + }, + }, + 'simple.toml': 'prompt = "Simple command"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { name: 'a', version: '1.0.0', isActive: true, path: extensionDir }, + ]), + } as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + + const commandNames = commands.map((cmd) => cmd.name).sort(); + expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']); + + const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); + expect(nestedCmd?.extensionName).toBe('a'); + expect(nestedCmd?.description).toMatch(/^\[a\]/); + expect(nestedCmd).toBeDefined(); + const result = await nestedCmd!.action?.( + createMockCommandContext({ + invocation: { + raw: '/b:c', + name: 'b:c', + args: '', + }, + }), + '', + ); + if (result?.type === 'submit_prompt') { + expect(result.content).toBe('Nested command from extension a'); + } else { + assert.fail('Incorrect action type'); + } + }); + }); + describe('Shorthand Argument Processor Integration', () => { it('correctly processes a command with {{args}}', async () => { const userCommandsDir = getUserCommandsDir(); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index c96acead..5b8d8c42 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -35,6 +35,11 @@ import { ShellProcessor, } from './prompt-processors/shellProcessor.js'; +interface CommandDirectory { + path: string; + extensionName?: string; +} + /** * Defines the Zod schema for a command definition file. This serves as the * single source of truth for both validation and type inference. @@ -65,13 +70,18 @@ export class FileCommandLoader implements ICommandLoader { } /** - * Loads all commands, applying the precedence rule where project-level - * commands override user-level commands with the same name. + * Loads all commands from user, project, and extension directories. + * Returns commands in order: user → project → extensions (alphabetically). + * + * Order is important for conflict resolution in CommandService: + * - User/project commands (without extensionName) use "last wins" strategy + * - Extension commands (with extensionName) get renamed if conflicts exist + * * @param signal An AbortSignal to cancel the loading process. - * @returns A promise that resolves to an array of loaded SlashCommands. + * @returns A promise that resolves to an array of all loaded SlashCommands. */ async loadCommands(signal: AbortSignal): Promise { - const commandMap = new Map(); + const allCommands: SlashCommand[] = []; const globOptions = { nodir: true, dot: true, @@ -79,54 +89,85 @@ export class FileCommandLoader implements ICommandLoader { follow: true, }; - try { - // User Commands - const userDir = getUserCommandsDir(); - const userFiles = await glob('**/*.toml', { - ...globOptions, - cwd: userDir, - }); - const userCommandPromises = userFiles.map((file) => - this.parseAndAdaptFile(path.join(userDir, file), userDir), - ); - const userCommands = (await Promise.all(userCommandPromises)).filter( - (cmd): cmd is SlashCommand => cmd !== null, - ); - for (const cmd of userCommands) { - commandMap.set(cmd.name, cmd); - } + // Load commands from each directory + const commandDirs = this.getCommandDirectories(); + for (const dirInfo of commandDirs) { + try { + const files = await glob('**/*.toml', { + ...globOptions, + cwd: dirInfo.path, + }); - // Project Commands (these intentionally override user commands) - const projectDir = getProjectCommandsDir(this.projectRoot); - const projectFiles = await glob('**/*.toml', { - ...globOptions, - cwd: projectDir, - }); - const projectCommandPromises = projectFiles.map((file) => - this.parseAndAdaptFile(path.join(projectDir, file), projectDir), - ); - const projectCommands = ( - await Promise.all(projectCommandPromises) - ).filter((cmd): cmd is SlashCommand => cmd !== null); - for (const cmd of projectCommands) { - commandMap.set(cmd.name, cmd); + const commandPromises = files.map((file) => + this.parseAndAdaptFile( + path.join(dirInfo.path, file), + dirInfo.path, + dirInfo.extensionName, + ), + ); + + const commands = (await Promise.all(commandPromises)).filter( + (cmd): cmd is SlashCommand => cmd !== null, + ); + + // Add all commands without deduplication + allCommands.push(...commands); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error( + `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, + error, + ); + } } - } catch (error) { - console.error(`[FileCommandLoader] Error during file search:`, error); } - return Array.from(commandMap.values()); + return allCommands; + } + + /** + * Get all command directories in order for loading. + * User commands → Project commands → Extension commands + * This order ensures extension commands can detect all conflicts. + */ + private getCommandDirectories(): CommandDirectory[] { + const dirs: CommandDirectory[] = []; + + // 1. User commands + dirs.push({ path: getUserCommandsDir() }); + + // 2. Project commands (override user commands) + dirs.push({ path: getProjectCommandsDir(this.projectRoot) }); + + // 3. Extension commands (processed last to detect all conflicts) + if (this.config) { + const activeExtensions = this.config + .getExtensions() + .filter((ext) => ext.isActive) + .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading + + const extensionCommandDirs = activeExtensions.map((ext) => ({ + path: path.join(ext.path, 'commands'), + extensionName: ext.name, + })); + + dirs.push(...extensionCommandDirs); + } + + return dirs; } /** * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, + extensionName?: string, ): Promise { let fileContent: string; try { @@ -167,7 +208,7 @@ export class FileCommandLoader implements ICommandLoader { 0, relativePathWithExt.length - 5, // length of '.toml' ); - const commandName = relativePath + const baseCommandName = relativePath .split(path.sep) // Sanitize each path segment to prevent ambiguity. Since ':' is our // namespace separator, we replace any literal colons in filenames @@ -175,11 +216,18 @@ export class FileCommandLoader implements ICommandLoader { .map((segment) => segment.replaceAll(':', '_')) .join(':'); + // Add extension name tag for extension commands + const defaultDescription = `Custom command from ${path.basename(filePath)}`; + let description = validDef.description || defaultDescription; + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + const processors: IPromptProcessor[] = []; // Add the Shell Processor if needed. if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) { - processors.push(new ShellProcessor(commandName)); + processors.push(new ShellProcessor(baseCommandName)); } // The presence of '{{args}}' is the switch that determines the behavior. @@ -190,18 +238,17 @@ export class FileCommandLoader implements ICommandLoader { } return { - name: commandName, - description: - validDef.description || - `Custom command from ${path.basename(filePath)}`, + name: baseCommandName, + description, kind: CommandKind.FILE, + extensionName, action: async ( context: CommandContext, _args: string, ): Promise => { if (!context.invocation) { console.error( - `[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`, + `[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`, ); return { type: 'submit_prompt', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2844177f..900be866 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -157,6 +157,9 @@ export interface SlashCommand { kind: CommandKind; + // Optional metadata for extension commands + extensionName?: string; + // The action to run. Optional for parent commands that only group sub-commands. action?: ( context: CommandContext, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 5b367cd4..42c2e277 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -74,11 +74,12 @@ describe('useSlashCommandProcessor', () => { const mockSetQuittingMessages = vi.fn(); const mockConfig = { - getProjectRoot: () => '/mock/cwd', - getSessionId: () => 'test-session', - getGeminiClient: () => ({ + getProjectRoot: vi.fn(() => '/mock/cwd'), + getSessionId: vi.fn(() => 'test-session'), + getGeminiClient: vi.fn(() => ({ setHistory: vi.fn().mockResolvedValue(undefined), - }), + })), + getExtensions: vi.fn(() => []), } as unknown as Config; const mockSettings = {} as LoadedSettings; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ee9067c6..c92fb623 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -81,6 +81,7 @@ export interface GeminiCLIExtension { name: string; version: string; isActive: boolean; + path: string; } export interface FileFilteringOptions { respectGitIgnore: boolean; From 80079cd2a5741d7024c8500853fe7a3af5e6ba0a Mon Sep 17 00:00:00 2001 From: shamso-goog Date: Tue, 29 Jul 2025 12:49:01 -0400 Subject: [PATCH 024/136] feat(cli): introduce /init command for GEMINI.md creation (#4852) Co-authored-by: matt korwel --- .../cli/src/services/BuiltinCommandLoader.ts | 4 +- .../cli/src/ui/commands/initCommand.test.ts | 102 ++++++++++++++++++ packages/cli/src/ui/commands/initCommand.ts | 93 ++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/initCommand.test.ts create mode 100644 packages/cli/src/ui/commands/initCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 7ba0d6bb..d3ad6cb2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -20,6 +20,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; +import { initCommand } from '../ui/commands/initCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; @@ -59,9 +60,10 @@ export class BuiltinCommandLoader implements ICommandLoader { extensionsCommand, helpCommand, ideCommand(this.config), + initCommand, + mcpCommand, memoryCommand, privacyCommand, - mcpCommand, quitCommand, restoreCommand(this.config), statsCommand, diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts new file mode 100644 index 00000000..83cea944 --- /dev/null +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { initCommand } from './initCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { type CommandContext } from './types.js'; + +// Mock the 'fs' module +vi.mock('fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +describe('initCommand', () => { + let mockContext: CommandContext; + const targetDir = '/test/dir'; + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + + beforeEach(() => { + // Create a fresh mock context for each test + mockContext = createMockCommandContext({ + services: { + config: { + getTargetDir: () => targetDir, + }, + }, + }); + }); + + afterEach(() => { + // Clear all mocks after each test + vi.clearAllMocks(); + }); + + it('should inform the user if GEMINI.md already exists', async () => { + // Arrange: Simulate that the file exists + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Act: Run the command's action + const result = await initCommand.action!(mockContext, ''); + + // Assert: Check for the correct informational message + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'A GEMINI.md file already exists in this directory. No changes were made.', + }); + // Assert: Ensure no file was written + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should create GEMINI.md and submit a prompt if it does not exist', async () => { + // Arrange: Simulate that the file does not exist + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act: Run the command's action + const result = await initCommand.action!(mockContext, ''); + + // Assert: Check that writeFileSync was called correctly + expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8'); + + // Assert: Check that an informational message was added to the UI + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + expect.any(Number), + ); + + // Assert: Check that the correct prompt is submitted + expect(result.type).toBe('submit_prompt'); + expect(result.content).toContain( + 'You are an AI agent that brings the power of Gemini', + ); + }); + + it('should return an error if config is not available', async () => { + // Arrange: Create a context without config + const noConfigContext = createMockCommandContext(); + if (noConfigContext.services) { + noConfigContext.services.config = null; + } + + // Act: Run the command's action + const result = await initCommand.action!(noConfigContext, ''); + + // Assert: Check for the correct error message + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts new file mode 100644 index 00000000..ad69d0da --- /dev/null +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + CommandContext, + SlashCommand, + SlashCommandActionReturn, + CommandKind, +} from './types.js'; + +export const initCommand: SlashCommand = { + name: 'init', + description: 'Analyzes the project and creates a tailored GEMINI.md file.', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + if (!context.services.config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + const targetDir = context.services.config.getTargetDir(); + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + + if (fs.existsSync(geminiMdPath)) { + return { + type: 'message', + messageType: 'info', + content: + 'A GEMINI.md file already exists in this directory. No changes were made.', + }; + } + + // Create an empty GEMINI.md file + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + context.ui.addItem( + { + type: 'info', + text: 'Empty GEMINI.md created. Now analyzing the project to populate it.', + }, + Date.now(), + ); + + return { + type: 'submit_prompt', + content: ` +You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions. + +**Analysis Process:** + +1. **Initial Exploration:** + * Start by listing the files and directories to get a high-level overview of the structure. + * Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start. + +2. **Iterative Deep Dive (up to 10 files):** + * Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation). + * Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration. + +3. **Identify Project Type:** + * **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project. + * **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else. + +**GEMINI.md Content Generation:** + +**For a Code Project:** + +* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture. +* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO. +* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase. + +**For a Non-Code Project:** + +* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold? +* **Key Files:** List the most important files and briefly explain what they contain. +* **Usage:** Explain how the contents of this directory are intended to be used. + +**Final Output:** + +Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown. +`, + }; + }, +}; From 293bb820193a41aee6f1421367a2d1fc6d836422 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Tue, 29 Jul 2025 16:20:37 -0400 Subject: [PATCH 025/136] Adds centralized support to log slash commands + sub commands (#5128) --- docs/telemetry.md | 5 + .../ui/hooks/slashCommandProcessor.test.ts | 94 +++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 15 +++ .../clearcut-logger/clearcut-logger.ts | 21 +++++ .../clearcut-logger/event-metadata-key.ts | 10 ++ packages/core/src/telemetry/constants.ts | 1 + packages/core/src/telemetry/index.ts | 2 + packages/core/src/telemetry/loggers.ts | 23 +++++ packages/core/src/telemetry/types.ts | 17 +++- 9 files changed, 187 insertions(+), 1 deletion(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index 2209ee0b..c7b88ba9 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -209,6 +209,11 @@ Logs are timestamped records of specific events. The following events are logged - **Attributes**: - `auth_type` +- `gemini_cli.slash_command`: This event occurs when a user executes a slash command. + - **Attributes**: + - `command` (string) + - `subcommand` (string, if applicable) + ### Metrics Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI: diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 42c2e277..30a14815 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -4,6 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({ + logSlashCommand: vi.fn(), + SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + logSlashCommand, + SlashCommandEvent, + }; +}); + const { mockProcessExit } = vi.hoisted(() => ({ mockProcessExit: vi.fn((_code?: number): never => undefined as never), })); @@ -814,4 +829,83 @@ describe('useSlashCommandProcessor', () => { expect(abortSpy).toHaveBeenCalledTimes(1); }); }); + + describe('Slash Command Logging', () => { + const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' }); + const loggingTestCommands: SlashCommand[] = [ + createTestCommand({ + name: 'logtest', + action: mockCommandAction, + }), + createTestCommand({ + name: 'logwithsub', + subCommands: [ + createTestCommand({ + name: 'sub', + action: mockCommandAction, + }), + ], + }), + createTestCommand({ + name: 'logalias', + altNames: ['la'], + action: mockCommandAction, + }), + ]; + + beforeEach(() => { + mockCommandAction.mockClear(); + vi.mocked(logSlashCommand).mockClear(); + vi.mocked(SlashCommandEvent).mockClear(); + }); + + it('should log a simple slash command', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/logtest'); + }); + + expect(logSlashCommand).toHaveBeenCalledTimes(1); + expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined); + }); + + it('should log a slash command with a subcommand', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/logwithsub sub'); + }); + + expect(logSlashCommand).toHaveBeenCalledTimes(1); + expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub'); + }); + + it('should log the command path when an alias is used', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/la'); + }); + expect(logSlashCommand).toHaveBeenCalledTimes(1); + expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined); + }); + + it('should not log for unknown commands', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/unknown'); + }); + expect(logSlashCommand).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index be32de11..e315ba97 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -13,6 +13,8 @@ import { Config, GitService, Logger, + logSlashCommand, + SlashCommandEvent, ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -233,6 +235,7 @@ export const useSlashCommandProcessor = ( let currentCommands = commands; let commandToExecute: SlashCommand | undefined; let pathIndex = 0; + const canonicalPath: string[] = []; for (const part of commandPath) { // TODO: For better performance and architectural clarity, this two-pass @@ -253,6 +256,7 @@ export const useSlashCommandProcessor = ( if (foundCommand) { commandToExecute = foundCommand; + canonicalPath.push(foundCommand.name); pathIndex++; if (foundCommand.subCommands) { currentCommands = foundCommand.subCommands; @@ -268,6 +272,17 @@ export const useSlashCommandProcessor = ( const args = parts.slice(pathIndex).join(' '); if (commandToExecute.action) { + if (config) { + const resolvedCommandPath = canonicalPath; + const event = new SlashCommandEvent( + resolvedCommandPath[0], + resolvedCommandPath.length > 1 + ? resolvedCommandPath.slice(1).join(' ') + : undefined, + ); + logSlashCommand(config, event); + } + const fullCommandContext: CommandContext = { ...commandContext, invocation: { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index fd5a9ab2..d221ef5e 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -19,6 +19,7 @@ import { FlashFallbackEvent, LoopDetectedEvent, FlashDecidedToContinueEvent, + SlashCommandEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; @@ -40,6 +41,7 @@ const end_session_event_name = 'end_session'; const flash_fallback_event_name = 'flash_fallback'; const loop_detected_event_name = 'loop_detected'; const flash_decided_to_continue_event_name = 'flash_decided_to_continue'; +const slash_command_event_name = 'slash_command'; export interface LogResponse { nextRequestWaitMs?: number; @@ -528,6 +530,25 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logSlashCommandEvent(event: SlashCommandEvent): void { + const data = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_NAME, + value: JSON.stringify(event.command), + }, + ]; + + if (event.subcommand) { + data.push({ + gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND, + value: JSON.stringify(event.subcommand), + }); + } + + this.enqueueLogEvent(this.createLogEvent(slash_command_event_name, data)); + this.flushIfNeeded(); + } + logEndSessionEvent(event: EndSessionEvent): void { const data = [ { 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 b34cc6ea..9a182f67 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -163,6 +163,16 @@ export enum EventMetadataKey { // Logs the type of loop detected. GEMINI_CLI_LOOP_DETECTED_TYPE = 38, + + // ========================================================================== + // Slash Command Event Keys + // =========================================================================== + + // Logs the name of the slash command. + GEMINI_CLI_SLASH_COMMAND_NAME = 41, + + // Logs the subcommand of the slash command. + GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42, } export function getEventMetadataKey( diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 316e827f..42572228 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'gemini_cli.config'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; export const EVENT_FLASH_DECIDED_TO_CONTINUE = 'gemini_cli.flash_decided_to_continue'; +export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command'; export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count'; export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 8da31727..6648b229 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -26,6 +26,7 @@ export { logApiError, logApiResponse, logFlashFallback, + logSlashCommand, } from './loggers.js'; export { StartSessionEvent, @@ -37,6 +38,7 @@ export { ApiResponseEvent, TelemetryEvent, FlashFallbackEvent, + SlashCommandEvent, } from './types.js'; export { SpanStatusCode, ValueType } from '@opentelemetry/api'; export { SemanticAttributes } from '@opentelemetry/semantic-conventions'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 073124f4..3ee806bb 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -17,6 +17,7 @@ import { EVENT_FLASH_FALLBACK, EVENT_FLASH_DECIDED_TO_CONTINUE, SERVICE_NAME, + EVENT_SLASH_COMMAND, } from './constants.js'; import { ApiErrorEvent, @@ -28,6 +29,7 @@ import { FlashFallbackEvent, FlashDecidedToContinueEvent, LoopDetectedEvent, + SlashCommandEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -332,3 +334,24 @@ export function logFlashDecidedToContinue( }; logger.emit(logRecord); } + +export function logSlashCommand( + config: Config, + event: SlashCommandEvent, +): void { + ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_SLASH_COMMAND, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Slash command: ${event.command}.`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 69dffb08..d29b97d2 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -278,6 +278,20 @@ export class FlashDecidedToContinueEvent { } } +export class SlashCommandEvent { + 'event.name': 'slash_command'; + 'event.timestamp': string; // ISO 8106 + command: string; + subcommand?: string; + + constructor(command: string, subcommand?: string) { + this['event.name'] = 'slash_command'; + this['event.timestamp'] = new Date().toISOString(); + this.command = command; + this.subcommand = subcommand; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -288,4 +302,5 @@ export type TelemetryEvent = | ApiResponseEvent | FlashFallbackEvent | LoopDetectedEvent - | FlashDecidedToContinueEvent; + | FlashDecidedToContinueEvent + | SlashCommandEvent; From 008051e42d6f4df01c14bfc138e1acaf97cb854a Mon Sep 17 00:00:00 2001 From: Srinath Padmanabhan <17151014+srithreepo@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:44:48 -0700 Subject: [PATCH 026/136] Update Triage Logic to improve issue categorization. (#5110) --- .../gemini-automated-issue-triage.yml | 96 +++++++++++-- .../gemini-scheduled-issue-triage.yml | 129 ++++++++++++++---- 2 files changed, 189 insertions(+), 36 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index ed465980..fa62a36c 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -47,17 +47,97 @@ jobs: "sandbox": false } prompt: | - You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. - + You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. Steps: 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. - 2. Review the issue title and body provided in the environment variables. - 3. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, and priority/*. - 4. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` - 5. If the issue has a "status/need-triage" label, remove it after applying the appropriate labels: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --remove-label "status/need-triage"` - + 2. Review the issue title, body and any comments provided in the environment variables. + 3. Ignore any existing priorities or tags on the issue. Just report your findings. + 4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case. + 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` + 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label + 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label + 9. Use Area definitions mentioned below to help you narrow down issues Guidelines: - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Triage only the current issue. - - Assign all applicable kind/*, area/*, and priority/* labels based on the issue content. + - Apply only one area/ label + - Apply only one kind/ label + - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. + - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. + Categorization Guidelines: + P0: Critical / Blocker + - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. + Impact: + - Blocks development or testing for the entire team. + - Major security vulnerability that could compromise user data or system integrity. + - Causes data loss or corruption with no workaround. + - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker? + Qualifier: Is the main function of the software broken? + Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. + P1: High + - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1. + Impact: + - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. + - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. + - Severe performance degradation making the application frustratingly slow. + - No straightforward workaround exists, or the workaround is difficult and non-obvious. + Qualifier: Is a key feature unusable or giving very wrong results? + Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. + P2: Medium + - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. + Impact: + - Affects a non-critical feature or a smaller, specific subset of users. + - An inconvenient but functional workaround is available and easy to execute. + - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). + Qualifier: Is it an annoying but non-blocking problem? + Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. + P3: Low + - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. + Impact: + - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. + - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. + Qualifier: Is it a "nice-to-fix" issue? + Example: Spelling mistakes etc. + Things you should know: + - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue + - This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests. + Definition of Areas + area/ux: + - Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance. + - I am seeing my screen flicker when using Gemini CLI + - I am seeing the output malformed + - Theme changes aren't taking effect + - My keyboard inputs arent' being recognzied + area/platform: + - Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework. + area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features. + area/models: + - i am not getting a response that is reasonable or expected. this can include things like + - I am calling a tool and the tool is not performing as expected. + - i am expecting a tool to be called and it is not getting called , + - Including experience when using + - built-in tools (e.g., web search, code interpreter, read file, writefile, etc..), + - Function calling issues should be under this area + - i am getting responses from the model that are malformed. + - Issues concerning Gemini quality of response and inference, + - Issues talking about unnecessary token consumption. + - Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues. + - Memory compression + - unexpected responses, + - poor quality of generated code + area/tools: + - These are primarily issues related to Model Context Protocol + - These are issues that mention MCP support + - feature requests asking for support for new tools. + area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality + area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure. + area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc.. + area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access. + area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc.. + area/performance: Issues focused on model performance + - Issues with running out of capacity, + - 429 errors etc.. + - could also pertain to latency, + - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. + - Switching models from one to the other unexpectedly. diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 781ae373..12ab44de 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -68,33 +68,106 @@ jobs: "sandbox": false } prompt: | - You are an issue triage assistant. Analyze issues and apply appropriate labels ONE AT A TIME. - - Repository: ${{ github.repository }} - + You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. Steps: - 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to see available labels - 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) - 3. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands: - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"` - - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"` - - Continue for each label separately - - IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed. - + 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. + 2. Review the issue title, body and any comments provided in the environment variables. + 3. Ignore any existing priorities or tags on the issue. Just report your findings. + 4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case. + 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` + 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 + - Anything more than 6 versions older than the most recent should add the status/need-retesting label + 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label Guidelines: - - Only use existing repository labels from step 1 - - Do not add comments to issues - - Triage each issue independently based on title and body content - - Focus on applying: kind/* (bug/enhancement/documentation), area/* (core/cli/testing/windows), and priority/* labels - - If an issue has insufficient information, consider applying "status/need-information" - - After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"` - - Execute one `gh issue edit` command per issue, wait for success before proceeding to the next - - Example triage logic: - - Issues with "bug", "error", "broken" → kind/bug - - Issues with "feature", "enhancement", "improve" → kind/enhancement - - Issues about Windows/performance → area/windows, area/performance - - Critical bugs → priority/p0, other bugs → priority/p1, enhancements → priority/p2 - - Process each issue sequentially and confirm each labeling operation before moving to the next issue. + - Only use labels that already exist in the repository. + - Do not add comments or modify the issue content. + - Triage only the current issue. + - Apply only one area/ label + - Apply only one kind/ label + - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. + - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. + Categorization Guidelines: + P0: Critical / Blocker + - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. + Impact: + - Blocks development or testing for the entire team. + - Major security vulnerability that could compromise user data or system integrity. + - Causes data loss or corruption with no workaround. + - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? + - Is it preventing contributors from contributing to the repository or is it a release blocker? + Qualifier: Is the main function of the software broken? + Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. + P1: High + - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. + - Feature requests are almost never P1. + Impact: + - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. + - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. + - Severe performance degradation making the application frustratingly slow. + - No straightforward workaround exists, or the workaround is difficult and non-obvious. + Qualifier: Is a key feature unusable or giving very wrong results? + Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. + P2: Medium + - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. + Impact: + - Affects a non-critical feature or a smaller, specific subset of users. + - An inconvenient but functional workaround is available and easy to execute. + - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). + Qualifier: Is it an annoying but non-blocking problem? + Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. + P3: Low + - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. + Impact: + - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. + - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. + Qualifier: Is it a "nice-to-fix" issue? + Example: Spelling mistakes etc. + Things you should know. + - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue + - This product is designed to use different models eg.. using pro, downgrading to flash etc. + - When users report that they dont expect the model to change those would be categorized as feature requests. + Definition of Areas + area/ux: + - Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance. + - I am seeing my screen flicker when using Gemini CLI + - I am seeing the output malformed + - Theme changes aren't taking effect + - My keyboard inputs arent' being recognzied + area/platform: + - Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework. + area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features. + area/models: + - i am not getting a response that is reasonable or expected. this can include things like + - I am calling a tool and the tool is not performing as expected. + - i am expecting a tool to be called and it is not getting called , + - Including experience when using + - built-in tools (e.g., web search, code interpreter, read file, writefile, etc..), + - Function calling issues should be under this area + - i am getting responses from the model that are malformed. + - Issues concerning Gemini quality of response and inference, + - Issues talking about unnecessary token consumption. + - Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues. + - Memory compression + - unexpected responses, + - poor quality of generated code + area/tools: + - These are primarily issues related to Model Context Protocol + - These are issues that mention MCP support + - feature requests asking for support for new tools. + area/core: + - Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality + area/contribution: + - Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure. + area/authentication: + - Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc.. + area/security-privacy: + - Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access. + area/extensibility: + - Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc.. + area/performance: + - Issues focused on model performance + - Issues with running out of capacity, + - 429 errors etc.. + - could also pertain to latency, + - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. + - Switching models from one to the other unexpectedly. From 327f915610406e4c159495eb734b3ffb0d32ffa4 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Tue, 29 Jul 2025 16:03:39 -0700 Subject: [PATCH 027/136] Fix typo in RFC 9728 impl (#5126) --- packages/core/src/mcp/oauth-utils.test.ts | 2 +- packages/core/src/mcp/oauth-utils.ts | 4 ++-- packages/core/src/tools/mcp-client.ts | 14 -------------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index b27d97b3..12871ff2 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -140,7 +140,7 @@ describe('OAuthUtils', () => { describe('parseWWWAuthenticateHeader', () => { it('should parse resource metadata URI from WWW-Authenticate header', () => { const header = - 'Bearer realm="example", resource_metadata_uri="https://example.com/.well-known/oauth-protected-resource"'; + 'Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"'; const result = OAuthUtils.parseWWWAuthenticateHeader(header); expect(result).toBe( 'https://example.com/.well-known/oauth-protected-resource', diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 6dad17c8..64fd68be 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -198,8 +198,8 @@ export class OAuthUtils { * @returns The resource metadata URI if found */ static parseWWWAuthenticateHeader(header: string): string | null { - // Parse Bearer realm and resource_metadata_uri - const match = header.match(/resource_metadata_uri="([^"]+)"/); + // Parse Bearer realm and resource_metadata + const match = header.match(/resource_metadata="([^"]+)"/); if (match) { return match[1]; } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index d175af1f..b87b2124 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -145,20 +145,6 @@ export function getMCPDiscoveryState(): MCPDiscoveryState { return mcpDiscoveryState; } -/** - * Parse www-authenticate header to extract OAuth metadata URI. - * - * @param wwwAuthenticate The www-authenticate header value - * @returns The resource metadata URI if found, null otherwise - */ -function _parseWWWAuthenticate(wwwAuthenticate: string): string | null { - // Parse header like: Bearer realm="MCP Server", resource_metadata_uri="https://..." - const resourceMetadataMatch = wwwAuthenticate.match( - /resource_metadata_uri="([^"]+)"/, - ); - return resourceMetadataMatch ? resourceMetadataMatch[1] : null; -} - /** * Extract WWW-Authenticate header from error message string. * This is a more robust approach than regex matching. From d64c3d6af8f9cc448e7a7b49257f5374f9e3a87c Mon Sep 17 00:00:00 2001 From: Ava <6314286+ava-cassiopeia@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:22:13 -0600 Subject: [PATCH 028/136] Add Starcraft ref to witty loading phrases (#5120) Co-authored-by: Jacob Richman --- packages/cli/src/ui/hooks/usePhraseCycler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index dc0993f0..83d68601 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -138,6 +138,7 @@ export const WITTY_LOADING_PHRASES = [ 'Enhancing... Enhancing... Still loading.', "It's not a bug, it's a feature... of this loading screen.", 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...', ]; export const PHRASE_CHANGE_INTERVAL_MS = 15000; From 091804c7507be25d8063a236210de0c31671783b Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 29 Jul 2025 16:41:31 -0700 Subject: [PATCH 029/136] feat(docs): document GEMINI.md import syntax (#5145) --- docs/cli/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 52f9855f..10d4536c 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -442,6 +442,7 @@ This example demonstrates how you can provide general project context, specific - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file. - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. - **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. +- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md). - **Commands for Memory Management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. From d5a1b717c258b3913dc41800f5a976ed9d60792a Mon Sep 17 00:00:00 2001 From: Sambhav Khanna <125531539+sambhavKhanna@users.noreply.github.com> Date: Tue, 29 Jul 2025 20:11:15 -0400 Subject: [PATCH 030/136] fix(update): correctly report new updates (#4821) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Jacob Richman --- packages/cli/src/ui/utils/updateCheck.test.ts | 47 ++++++++++++++++--- packages/cli/src/ui/utils/updateCheck.ts | 9 +++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 4985afe8..fa6f342e 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -5,7 +5,7 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { checkForUpdates } from './updateCheck.js'; +import { checkForUpdates, FETCH_TIMEOUT_MS } from './updateCheck.js'; const getPackageJson = vi.hoisted(() => vi.fn()); vi.mock('../../utils/package.js', () => ({ @@ -19,11 +19,17 @@ vi.mock('update-notifier', () => ({ describe('checkForUpdates', () => { beforeEach(() => { + vi.useFakeTimers(); vi.resetAllMocks(); // Clear DEV environment variable before each test delete process.env.DEV; }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('should return null when running from source (DEV=true)', async () => { process.env.DEV = 'true'; getPackageJson.mockResolvedValue({ @@ -31,7 +37,9 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.1.0' }, + fetchInfo: vi + .fn() + .mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -51,7 +59,7 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - fetchInfo: vi.fn(async () => null), + fetchInfo: vi.fn().mockResolvedValue(null), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -63,7 +71,9 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.1.0' })), + fetchInfo: vi + .fn() + .mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }), }); const result = await checkForUpdates(); @@ -77,7 +87,9 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.0.0' })), + fetchInfo: vi + .fn() + .mockResolvedValue({ current: '1.0.0', latest: '1.0.0' }), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -89,12 +101,35 @@ describe('checkForUpdates', () => { version: '1.1.0', }); updateNotifier.mockReturnValue({ - fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '0.09' })), + fetchInfo: vi + .fn() + .mockResolvedValue({ current: '1.1.0', latest: '1.0.0' }), }); const result = await checkForUpdates(); expect(result).toBeNull(); }); + it('should return null if fetchInfo times out', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.0.0', + }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn( + async () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ current: '1.0.0', latest: '1.1.0' }); + }, FETCH_TIMEOUT_MS + 1); + }), + ), + }); + const promise = checkForUpdates(); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); + const result = await promise; + expect(result).toBeNull(); + }); + it('should handle errors gracefully', async () => { getPackageJson.mockRejectedValue(new Error('test error')); const result = await checkForUpdates(); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index b0a0de1b..2fe5df39 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -8,6 +8,8 @@ import updateNotifier, { UpdateInfo } from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; +export const FETCH_TIMEOUT_MS = 2000; + export interface UpdateObject { message: string; update: UpdateInfo; @@ -34,8 +36,11 @@ export async function checkForUpdates(): Promise { // allow notifier to run in scripts shouldNotifyInNpmScript: true, }); - - const updateInfo = await notifier.fetchInfo(); + // avoid blocking by waiting at most FETCH_TIMEOUT_MS for fetchInfo to resolve + const timeout = new Promise((resolve) => + setTimeout(resolve, FETCH_TIMEOUT_MS, null), + ); + const updateInfo = await Promise.race([notifier.fetchInfo(), timeout]); if (updateInfo && semver.gt(updateInfo.latest, updateInfo.current)) { return { From 0ce89392b8d4b6a4af4eef132ac5ae6de86ac25e Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Tue, 29 Jul 2025 20:36:26 -0700 Subject: [PATCH 031/136] Docs: add documentation for .geminiignore (#5123) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/gemini-ignore.md | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/gemini-ignore.md diff --git a/docs/gemini-ignore.md b/docs/gemini-ignore.md new file mode 100644 index 00000000..8e8fdf20 --- /dev/null +++ b/docs/gemini-ignore.md @@ -0,0 +1,59 @@ +# Ignoring Files + +This document provides an overview of the Gemini Ignore (`.geminiignore`) feature of the Gemini CLI. + +The Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git). + +## How it works + +When you add a path to your `.geminiignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](./tools/multi-file.md) command, any paths in your `.geminiignore` file will be automatically excluded. + +For the most part, `.geminiignore` follows the conventions of `.gitignore` files: + +- Blank lines and lines starting with `#` are ignored. +- Standard glob patterns are supported (such as `*`, `?`, and `[]`). +- Putting a `/` at the end will only match directories. +- Putting a `/` at the beginning anchors the path relative to the `.geminiignore` file. +- `!` negates a pattern. + +You can update your `.geminiignore` file at any time. To apply the changes, you must restart your Gemini CLI session. + +## How to use `.geminiignore` + +To enable `.geminiignore`: + +1. Create a file named `.geminiignore` in the root of your project directory. + +To add a file or directory to `.geminiignore`: + +1. Open your `.geminiignore` file. +2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`. + +### `.geminiignore` examples + +You can use `.geminiignore` to ignore directories and files: + +``` +# Exclude your /packages/ directory and all subdirectories +/packages/ + +# Exclude your apikeys.txt file +apikeys.txt +``` + +You can use wildcards in your `.geminiignore` file with `*`: + +``` +# Exclude all .md files +*.md +``` + +Finally, you can exclude files and directories from exclusion with `!`: + +``` +# Exclude all .md files except README.md +*.md +!README.md +``` + +To remove paths from your `.geminiignore` file, delete the relevant lines. From 8985e489a5fd5251f3b41fe358797d7a2e90ac6a Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Tue, 29 Jul 2025 21:05:03 -0700 Subject: [PATCH 032/136] Skip and reset loop checking around code blocks (#5144) --- .../src/services/loopDetectionService.test.ts | 125 ++++++++++++++++-- .../core/src/services/loopDetectionService.ts | 20 +++ 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index 9f5d63a7..2ec32ae7 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -56,6 +56,15 @@ describe('LoopDetectionService', () => { value: content, }); + const createRepetitiveContent = (id: number, length: number): string => { + const baseString = `This is a unique sentence, id=${id}. `; + let content = ''; + while (content.length < length) { + content += baseString; + } + return content.slice(0, length); + }; + describe('Tool Call Loop Detection', () => { it(`should not detect a loop for fewer than TOOL_CALL_LOOP_THRESHOLD identical calls`, () => { const event = createToolCallRequestEvent('testTool', { param: 'value' }); @@ -149,13 +158,11 @@ describe('LoopDetectionService', () => { it('should detect a loop when a chunk of content repeats consecutively', () => { service.reset(''); - const repeatedContent = 'a'.repeat(CONTENT_CHUNK_SIZE); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { - for (const char of repeatedContent) { - isLoop = service.addAndCheck(createContentEvent(char)); - } + isLoop = service.addAndCheck(createContentEvent(repeatedContent)); } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); @@ -163,23 +170,119 @@ describe('LoopDetectionService', () => { it('should not detect a loop if repetitions are very far apart', () => { service.reset(''); - const repeatedContent = 'b'.repeat(CONTENT_CHUNK_SIZE); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); const fillerContent = generateRandomString(500); let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { - for (const char of repeatedContent) { - isLoop = service.addAndCheck(createContentEvent(char)); - } - for (const char of fillerContent) { - isLoop = service.addAndCheck(createContentEvent(char)); - } + isLoop = service.addAndCheck(createContentEvent(repeatedContent)); + isLoop = service.addAndCheck(createContentEvent(fillerContent)); } expect(isLoop).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); }); + describe('Content Loop Detection with Code Blocks', () => { + it('should not detect a loop when repetitive content is inside a code block', () => { + service.reset(''); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); + + service.addAndCheck(createContentEvent('```\n')); + + for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { + const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); + expect(isLoop).toBe(false); + } + + const isLoop = service.addAndCheck(createContentEvent('\n```')); + expect(isLoop).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('should detect a loop when repetitive content is outside a code block', () => { + service.reset(''); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); + + service.addAndCheck(createContentEvent('```')); + service.addAndCheck(createContentEvent('\nsome code\n')); + service.addAndCheck(createContentEvent('```')); + + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { + isLoop = service.addAndCheck(createContentEvent(repeatedContent)); + } + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should handle content with multiple code blocks and no loops', () => { + service.reset(''); + service.addAndCheck(createContentEvent('```\ncode1\n```')); + service.addAndCheck(createContentEvent('\nsome text\n')); + const isLoop = service.addAndCheck(createContentEvent('```\ncode2\n```')); + + expect(isLoop).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('should handle content with mixed code blocks and looping text', () => { + service.reset(''); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); + + service.addAndCheck(createContentEvent('```')); + service.addAndCheck(createContentEvent('\ncode1\n')); + service.addAndCheck(createContentEvent('```')); + + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { + isLoop = service.addAndCheck(createContentEvent(repeatedContent)); + } + + expect(isLoop).toBe(true); + expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); + }); + + it('should not detect a loop for a long code block with some repeating tokens', () => { + service.reset(''); + const repeatingTokens = + 'for (let i = 0; i < 10; i++) { console.log(i); }'; + + service.addAndCheck(createContentEvent('```\n')); + + for (let i = 0; i < 20; i++) { + const isLoop = service.addAndCheck(createContentEvent(repeatingTokens)); + expect(isLoop).toBe(false); + } + + const isLoop = service.addAndCheck(createContentEvent('\n```')); + expect(isLoop).toBe(false); + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + + it('should reset tracking when a code fence is found', () => { + service.reset(''); + const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); + + for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) { + service.addAndCheck(createContentEvent(repeatedContent)); + } + + // This should not trigger a loop because of the reset + service.addAndCheck(createContentEvent('```')); + + // We are now in a code block, so loop detection should be off. + // Let's add the repeated content again, it should not trigger a loop. + let isLoop = false; + for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { + isLoop = service.addAndCheck(createContentEvent(repeatedContent)); + expect(isLoop).toBe(false); + } + + expect(loggers.logLoopDetected).not.toHaveBeenCalled(); + }); + }); + describe('Edge Cases', () => { it('should handle empty content', () => { const event = createContentEvent(''); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 7b3da20b..f71b8434 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -61,6 +61,7 @@ export class LoopDetectionService { private contentStats = new Map(); private lastContentIndex = 0; private loopDetected = false; + private inCodeBlock = false; // LLM loop track tracking private turnsInCurrentPrompt = 0; @@ -156,8 +157,27 @@ export class LoopDetectionService { * 2. Truncating history if it exceeds the maximum length * 3. Analyzing content chunks for repetitive patterns using hashing * 4. Detecting loops when identical chunks appear frequently within a short distance + * 5. Disabling loop detection within code blocks to prevent false positives, + * as repetitive code structures are common and not necessarily loops. */ private checkContentLoop(content: string): boolean { + // Code blocks can often contain repetitive syntax that is not indicative of a loop. + // To avoid false positives, we detect when we are inside a code block and + // temporarily disable loop detection. + const numFences = (content.match(/```/g) ?? []).length; + if (numFences) { + // Reset tracking when a code fence is detected to avoid analyzing content + // that spans across code block boundaries. + this.resetContentTracking(); + } + + const wasInCodeBlock = this.inCodeBlock; + this.inCodeBlock = + numFences % 2 === 0 ? this.inCodeBlock : !this.inCodeBlock; + if (wasInCodeBlock) { + return false; + } + this.streamContentHistory += content; this.truncateAndUpdate(); From fd434626c5d2d41f22d6755efccee48ab2afd46f Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 29 Jul 2025 22:03:54 -0700 Subject: [PATCH 033/136] chore(release): v0.1.15 (#5163) --- package-lock.json | 10 +++++----- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f315c9b..bb6e0a50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.1.13", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.1.13", + "version": "0.1.15", "workspaces": [ "packages/*" ], @@ -11639,7 +11639,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", @@ -11840,7 +11840,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@google/genai": "1.9.0", "@modelcontextprotocol/sdk": "^1.11.0", @@ -11934,7 +11934,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "99.99.99", + "version": "0.1.15", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 83620193..66933212 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.13", + "version": "0.1.15", "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.1.13" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.15" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index ce672d22..e0518041 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.13", + "version": "0.1.15", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.13" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.15" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index ba4735ea..a07d717f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.1.13", + "version": "0.1.15", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 7f02a65d..78968125 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 VS Code workspace.", - "version": "99.99.99", + "version": "0.1.15", "publisher": "google", "icon": "assets/icon.png", "repository": { From b447c329db52ae10b7e6c15aa87aa2b2d0098171 Mon Sep 17 00:00:00 2001 From: yaksh gandhi <95672067+yaksh1@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:31:08 +0530 Subject: [PATCH 034/136] docs: Update chat command documentation with checkpoint locations (#5027) Co-authored-by: Bryan Morgan Co-authored-by: F. Hinkelmann --- docs/cli/commands.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index af4b8de8..50d18de4 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -17,6 +17,11 @@ Slash commands provide meta-level control over the CLI itself. - **`save`** - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. - **Usage:** `/chat save ` + - **Details on Checkpoint Location:** The default locations for saved chat checkpoints are: + - Linux/macOS: `~/.config/google-generative-ai/checkpoints/` + - Windows: `C:\Users\\AppData\Roaming\google-generative-ai\checkpoints\` + - When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints. + - **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md). - **`resume`** - **Description:** Resumes a conversation from a previous save. - **Usage:** `/chat resume ` From bc23009f610629f5e9cf834f6a71239fb5aa1401 Mon Sep 17 00:00:00 2001 From: Olcan Date: Wed, 30 Jul 2025 10:21:15 -0700 Subject: [PATCH 035/136] do not mention GEMINI.md in system prompt as it is not fixed and can confuse model as it is not mentioned by memory tool and memory file paths are generally not exposed to model (yet) (#5202) --- .../core/__snapshots__/prompts.test.ts.snap | 18 +++++++++--------- packages/core/src/core/prompts.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index a69911cc..47674d6c 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -66,7 +66,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -247,7 +247,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -438,7 +438,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -614,7 +614,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -790,7 +790,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -966,7 +966,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1142,7 +1142,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1318,7 +1318,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1494,7 +1494,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index b97264d7..95c55143 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -112,7 +112,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Command Execution:** Use the '${ShellTool.Name}' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. -- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details From bcce1e7b84f68383af54b58c04da84d859b60950 Mon Sep 17 00:00:00 2001 From: Hyunsu Shin <58941022+scato3@users.noreply.github.com> Date: Thu, 31 Jul 2025 02:32:03 +0900 Subject: [PATCH 036/136] perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) --- packages/core/src/utils/bfsFileSearch.test.ts | 75 +++++++++++++++ packages/core/src/utils/bfsFileSearch.ts | 96 ++++++++++++------- 2 files changed, 138 insertions(+), 33 deletions(-) diff --git a/packages/core/src/utils/bfsFileSearch.test.ts b/packages/core/src/utils/bfsFileSearch.test.ts index 63198a8d..3d5a0010 100644 --- a/packages/core/src/utils/bfsFileSearch.test.ts +++ b/packages/core/src/utils/bfsFileSearch.test.ts @@ -189,4 +189,79 @@ describe('bfsFileSearch', () => { expect(result.sort()).toEqual([target1, target2].sort()); }); }); + + it('should perform parallel directory scanning efficiently (performance test)', async () => { + // Create a more complex directory structure for performance testing + console.log('\n🚀 Testing Parallel BFS Performance...'); + + // Create 50 directories with multiple levels for faster test execution + for (let i = 0; i < 50; i++) { + await createEmptyDir(`dir${i}`); + await createEmptyDir(`dir${i}`, 'subdir1'); + await createEmptyDir(`dir${i}`, 'subdir2'); + await createEmptyDir(`dir${i}`, 'subdir1', 'deep'); + if (i < 10) { + // Add target files in some directories + await createTestFile('content', `dir${i}`, 'GEMINI.md'); + await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md'); + } + } + + // Run multiple iterations to ensure consistency + const iterations = 3; + const durations: number[] = []; + let foundFiles = 0; + let firstResultSorted: string[] | undefined; + + for (let i = 0; i < iterations; i++) { + const searchStartTime = performance.now(); + const result = await bfsFileSearch(testRootDir, { + fileName: 'GEMINI.md', + maxDirs: 200, + debug: false, + }); + const duration = performance.now() - searchStartTime; + durations.push(duration); + + // Verify consistency: all iterations should find the exact same files + if (firstResultSorted === undefined) { + foundFiles = result.length; + firstResultSorted = result.sort(); + } else { + expect(result.sort()).toEqual(firstResultSorted); + } + + console.log(`📊 Iteration ${i + 1}: ${duration.toFixed(2)}ms`); + } + + const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length; + const maxDuration = Math.max(...durations); + const minDuration = Math.min(...durations); + + console.log(`📊 Average Duration: ${avgDuration.toFixed(2)}ms`); + console.log( + `📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`, + ); + console.log(`📁 Found ${foundFiles} GEMINI.md files`); + console.log( + `🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`, + ); + + // Verify we found the expected files + expect(foundFiles).toBe(20); // 10 dirs * 2 files each + + // Performance expectation: check consistency rather than absolute time + const variance = maxDuration - minDuration; + const consistencyRatio = variance / avgDuration; + + // Ensure reasonable performance (generous limit for CI environments) + expect(avgDuration).toBeLessThan(2000); // Very generous limit + + // Ensure consistency across runs (variance should not be too high) + expect(consistencyRatio).toBeLessThan(1.5); // Max variance should be less than 150% of average + + console.log( + `✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}%`, + ); + }); }); diff --git a/packages/core/src/utils/bfsFileSearch.ts b/packages/core/src/utils/bfsFileSearch.ts index 790521e0..c5b82f2f 100644 --- a/packages/core/src/utils/bfsFileSearch.ts +++ b/packages/core/src/utils/bfsFileSearch.ts @@ -6,7 +6,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { Dirent } from 'fs'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileFilteringOptions } from '../config/config.js'; // Simple console logger for now. @@ -47,45 +46,76 @@ export async function bfsFileSearch( const queue: string[] = [rootDir]; const visited = new Set(); let scannedDirCount = 0; + let queueHead = 0; // Pointer-based queue head to avoid expensive splice operations - while (queue.length > 0 && scannedDirCount < maxDirs) { - const currentDir = queue.shift()!; - if (visited.has(currentDir)) { - continue; + // Convert ignoreDirs array to Set for O(1) lookup performance + const ignoreDirsSet = new Set(ignoreDirs); + + // Process directories in parallel batches for maximum performance + const PARALLEL_BATCH_SIZE = 15; // Parallel processing batch size for optimal performance + + while (queueHead < queue.length && scannedDirCount < maxDirs) { + // Fill batch with unvisited directories up to the desired size + const batchSize = Math.min(PARALLEL_BATCH_SIZE, maxDirs - scannedDirCount); + const currentBatch = []; + while (currentBatch.length < batchSize && queueHead < queue.length) { + const currentDir = queue[queueHead]; + queueHead++; + if (!visited.has(currentDir)) { + visited.add(currentDir); + currentBatch.push(currentDir); + } } - visited.add(currentDir); - scannedDirCount++; + scannedDirCount += currentBatch.length; + + if (currentBatch.length === 0) continue; if (debug) { - logger.debug(`Scanning [${scannedDirCount}/${maxDirs}]: ${currentDir}`); + logger.debug( + `Scanning [${scannedDirCount}/${maxDirs}]: batch of ${currentBatch.length}`, + ); } - let entries: Dirent[]; - try { - entries = await fs.readdir(currentDir, { withFileTypes: true }); - } catch { - // Ignore errors for directories we can't read (e.g., permissions) - continue; - } - - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - if ( - fileService?.shouldIgnoreFile(fullPath, { - respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore, - respectGeminiIgnore: - options.fileFilteringOptions?.respectGeminiIgnore, - }) - ) { - continue; - } - - if (entry.isDirectory()) { - if (!ignoreDirs.includes(entry.name)) { - queue.push(fullPath); + // Read directories in parallel instead of one by one + const readPromises = currentBatch.map(async (currentDir) => { + try { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + return { currentDir, entries }; + } catch (error) { + // Warn user that a directory could not be read, as this affects search results. + const message = (error as Error)?.message ?? 'Unknown error'; + console.warn( + `[WARN] Skipping unreadable directory: ${currentDir} (${message})`, + ); + if (debug) { + logger.debug(`Full error for ${currentDir}:`, error); + } + return { currentDir, entries: [] }; + } + }); + + const results = await Promise.all(readPromises); + + for (const { currentDir, entries } of results) { + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if ( + fileService?.shouldIgnoreFile(fullPath, { + respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore, + respectGeminiIgnore: + options.fileFilteringOptions?.respectGeminiIgnore, + }) + ) { + continue; + } + + if (entry.isDirectory()) { + if (!ignoreDirsSet.has(entry.name)) { + queue.push(fullPath); + } + } else if (entry.isFile() && entry.name === fileName) { + foundFiles.push(fullPath); } - } else if (entry.isFile() && entry.name === fileName) { - foundFiles.push(fullPath); } } } From 32b1ef37791ef5cf94c1b80941ad899e1924feec Mon Sep 17 00:00:00 2001 From: shamso-goog Date: Wed, 30 Jul 2025 16:37:51 -0400 Subject: [PATCH 037/136] feat(ui): Update tool confirmation cancel button text (#4820) Co-authored-by: Jacob Richman --- .../messages/ToolConfirmationMessage.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index c1a313d5..197a922c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -118,7 +118,10 @@ export const ToolConfirmationMessage: React.FC< label: 'Modify with external editor', value: ToolConfirmationOutcome.ModifyWithEditor, }, - { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, + { + label: 'No, suggest changes (esc)', + value: ToolConfirmationOutcome.Cancel, + }, ); bodyContent = ( Date: Wed, 30 Jul 2025 13:38:02 -0700 Subject: [PATCH 038/136] Srithreepo Fixes for Scheduled triage (#5158) --- .../gemini-scheduled-issue-triage.yml | 186 ++++++++++-------- 1 file changed, 102 insertions(+), 84 deletions(-) diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 12ab44de..7e083c84 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -59,7 +59,8 @@ jobs: "run_shell_command(echo)", "run_shell_command(gh label list)", "run_shell_command(gh issue edit)", - "run_shell_command(gh issue list)" + "run_shell_command(gh issue list)", + "run_shell_command(gh issue view)" ], "telemetry": { "enabled": true, @@ -68,106 +69,123 @@ jobs: "sandbox": false } prompt: | - You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. + You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Steps: 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. - 2. Review the issue title, body and any comments provided in the environment variables. - 3. Ignore any existing priorities or tags on the issue. Just report your findings. - 4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case. - 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` - 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 - - Anything more than 6 versions older than the most recent should add the status/need-retesting label - 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label + 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) + 3. Review the issue title, body and any comments provided in the environment variables. + 4. Ignore any existing priorities or tags on the issue. + 5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. + 6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}' + 7. For area/* and kind/* limit yourself to only the single most applicable label in each case. + 8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation + 9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands: + - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"` + - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"` + - Continue for each label separately + - IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed. + - Make sure after you apply labels there is only one area/* and one kind/* label per issue. + - To do this look for labels found in step 6 that no longer apply remove them one at a time using + - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"` + - `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"` + - IMPORTANT: Remove each label one at a time, one command per issue if needed. + 10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 + - Anything more than 6 versions older than the most recent should add the status/need-retesting label + 11. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label + - After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"` + - Execute one `gh issue edit` command per issue, wait for success before proceeding to the next + Process each issue sequentially and confirm each labeling operation before moving to the next issue. Guidelines: - - Only use labels that already exist in the repository. - - Do not add comments or modify the issue content. - - Triage only the current issue. - - Apply only one area/ label - - Apply only one kind/ label - - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. - - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. + - Only use labels that already exist in the repository. + - Do not add comments or modify the issue content. + - Do not remove labels titled help wanted or good first issue. + - Triage only the current issue. + - Apply only one area/ label + - Apply only one kind/ label (Do not apply kind/duplicate or kind/parent-issue) + - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. + - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. Categorization Guidelines: P0: Critical / Blocker - - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. - Impact: - - Blocks development or testing for the entire team. - - Major security vulnerability that could compromise user data or system integrity. - - Causes data loss or corruption with no workaround. - - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? - - Is it preventing contributors from contributing to the repository or is it a release blocker? - Qualifier: Is the main function of the software broken? - Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. + - A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself. + Impact: + - Blocks development or testing for the entire team. + - Major security vulnerability that could compromise user data or system integrity. + - Causes data loss or corruption with no workaround. + - Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? + - Is it preventing contributors from contributing to the repository or is it a release blocker? + Qualifier: Is the main function of the software broken? + Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI. P1: High - - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. - - Feature requests are almost never P1. - Impact: - - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. - - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. - - Severe performance degradation making the application frustratingly slow. - - No straightforward workaround exists, or the workaround is difficult and non-obvious. - Qualifier: Is a key feature unusable or giving very wrong results? - Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. + - A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. + - Feature requests are almost never P1. + Impact: + - A core feature is broken or behaving incorrectly for a large number of users or large number of use cases. + - Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases. + - Severe performance degradation making the application frustratingly slow. + - No straightforward workaround exists, or the workaround is difficult and non-obvious. + Qualifier: Is a key feature unusable or giving very wrong results? + Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable. P2: Medium - - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. - Impact: - - Affects a non-critical feature or a smaller, specific subset of users. - - An inconvenient but functional workaround is available and easy to execute. - - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). - Qualifier: Is it an annoying but non-blocking problem? - Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. + - A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality. + Impact: + - Affects a non-critical feature or a smaller, specific subset of users. + - An inconvenient but functional workaround is available and easy to execute. + - Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping). + Qualifier: Is it an annoying but non-blocking problem? + Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow. P3: Low - - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. - Impact: - - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. - - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. - Qualifier: Is it a "nice-to-fix" issue? - Example: Spelling mistakes etc. - Things you should know. - - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue - - This product is designed to use different models eg.. using pro, downgrading to flash etc. - - When users report that they dont expect the model to change those would be categorized as feature requests. + - A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application. + Impact: + - Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page. + - An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. + Qualifier: Is it a "nice-to-fix" issue? + Example: Spelling mistakes etc. + Additional Context: + - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue + - This product is designed to use different models eg.. using pro, downgrading to flash etc. + - When users report that they dont expect the model to change those would be categorized as feature requests. Definition of Areas area/ux: - - Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance. - - I am seeing my screen flicker when using Gemini CLI - - I am seeing the output malformed - - Theme changes aren't taking effect - - My keyboard inputs arent' being recognzied + - Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance. + - I am seeing my screen flicker when using Gemini CLI + - I am seeing the output malformed + - Theme changes aren't taking effect + - My keyboard inputs arent' being recognzied area/platform: - - Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework. + - Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework. area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features. area/models: - - i am not getting a response that is reasonable or expected. this can include things like - - I am calling a tool and the tool is not performing as expected. - - i am expecting a tool to be called and it is not getting called , - - Including experience when using - - built-in tools (e.g., web search, code interpreter, read file, writefile, etc..), - - Function calling issues should be under this area - - i am getting responses from the model that are malformed. - - Issues concerning Gemini quality of response and inference, - - Issues talking about unnecessary token consumption. - - Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues. - - Memory compression - - unexpected responses, - - poor quality of generated code + - i am not getting a response that is reasonable or expected. this can include things like + - I am calling a tool and the tool is not performing as expected. + - i am expecting a tool to be called and it is not getting called , + - Including experience when using + - built-in tools (e.g., web search, code interpreter, read file, writefile, etc..), + - Function calling issues should be under this area + - i am getting responses from the model that are malformed. + - Issues concerning Gemini quality of response and inference, + - Issues talking about unnecessary token consumption. + - Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues. + - Memory compression + - unexpected responses, + - poor quality of generated code area/tools: - - These are primarily issues related to Model Context Protocol - - These are issues that mention MCP support - - feature requests asking for support for new tools. + - These are primarily issues related to Model Context Protocol + - These are issues that mention MCP support + - feature requests asking for support for new tools. area/core: - - Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality + - Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality area/contribution: - - Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure. + - Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure. area/authentication: - - Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc.. + - Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc.. area/security-privacy: - - Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access. + - Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access. area/extensibility: - - Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc.. + - Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc.. area/performance: - - Issues focused on model performance - - Issues with running out of capacity, - - 429 errors etc.. - - could also pertain to latency, - - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. - - Switching models from one to the other unexpectedly. + - Issues focused on model performance + - Issues with running out of capacity, + - 429 errors etc.. + - could also pertain to latency, + - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. + - Switching models from one to the other unexpectedly. From c1fe6889569610878c45216556fb99424b5bcba4 Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Thu, 31 Jul 2025 05:38:20 +0900 Subject: [PATCH 039/136] feat: Multi-Directory Workspace Support (part1: add `--include-directories` option) (#4605) Co-authored-by: Allen Hutchison --- docs/cli/configuration.md | 5 + packages/cli/src/config/config.ts | 11 + packages/cli/src/gemini.tsx | 2 +- packages/cli/src/ui/App.test.tsx | 10 + .../cli/src/ui/commands/aboutCommand.test.ts | 1 + .../src/ui/components/InputPrompt.test.tsx | 17 + .../cli/src/ui/components/InputPrompt.tsx | 11 + .../src/ui/hooks/atCommandProcessor.test.ts | 4 + .../cli/src/ui/hooks/atCommandProcessor.ts | 138 ++--- .../cli/src/ui/hooks/useCompletion.test.ts | 41 ++ packages/cli/src/ui/hooks/useCompletion.ts | 145 ++--- .../utils/sandbox-macos-permissive-closed.sb | 6 + .../utils/sandbox-macos-permissive-open.sb | 6 + .../utils/sandbox-macos-permissive-proxied.sb | 6 + .../utils/sandbox-macos-restrictive-closed.sb | 6 + .../utils/sandbox-macos-restrictive-open.sb | 6 + .../sandbox-macos-restrictive-proxied.sb | 6 + packages/cli/src/utils/sandbox.ts | 37 +- packages/core/src/config/config.test.ts | 29 + packages/core/src/config/config.ts | 11 + .../core/src/config/flashFallback.test.ts | 9 +- packages/core/src/core/client.test.ts | 3 + packages/core/src/core/client.ts | 30 +- .../src/test-utils/mockWorkspaceContext.ts | 33 ++ packages/core/src/tools/edit.test.ts | 32 +- packages/core/src/tools/edit.ts | 7 +- packages/core/src/tools/glob.test.ts | 37 +- packages/core/src/tools/glob.ts | 77 ++- packages/core/src/tools/grep.test.ts | 119 ++++- packages/core/src/tools/grep.ts | 121 +++-- packages/core/src/tools/ls.test.ts | 496 ++++++++++++++++++ packages/core/src/tools/ls.ts | 8 +- packages/core/src/tools/read-file.test.ts | 39 +- packages/core/src/tools/read-file.ts | 14 +- .../core/src/tools/read-many-files.test.ts | 60 ++- packages/core/src/tools/read-many-files.ts | 43 +- packages/core/src/tools/shell.test.ts | 38 +- packages/core/src/tools/shell.ts | 17 +- packages/core/src/tools/tool-registry.test.ts | 7 + packages/core/src/tools/write-file.test.ts | 52 +- packages/core/src/tools/write-file.ts | 9 +- .../utils/flashFallback.integration.test.ts | 7 + .../core/src/utils/workspaceContext.test.ts | 283 ++++++++++ packages/core/src/utils/workspaceContext.ts | 127 +++++ 44 files changed, 1913 insertions(+), 253 deletions(-) create mode 100644 packages/core/src/test-utils/mockWorkspaceContext.ts create mode 100644 packages/core/src/tools/ls.test.ts create mode 100644 packages/core/src/utils/workspaceContext.test.ts create mode 100644 packages/core/src/utils/workspaceContext.ts diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 10d4536c..695a7c53 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -387,6 +387,11 @@ Arguments passed directly when running the CLI can override other configurations - **`--proxy`**: - Sets the proxy for the CLI. - Example: `--proxy http://localhost:7890`. +- **`--include-directories `**: + - Includes additional directories in the workspace for multi-directory support. + - Can be specified multiple times or as comma-separated values. + - 5 directories can be added at maximum. + - Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` - **`--version`**: - Displays the version of the CLI. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 27e3ec09..1dd8519c 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -61,6 +61,7 @@ export interface CliArgs { listExtensions: boolean | undefined; ideMode: boolean | undefined; proxy: string | undefined; + includeDirectories: string[] | undefined; } export async function parseArguments(): Promise { @@ -199,6 +200,15 @@ export async function parseArguments(): Promise { description: 'Proxy for gemini client, like schema://user:password@host:port', }) + .option('include-directories', { + type: 'array', + string: true, + description: + 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + coerce: (dirs: string[]) => + // Handle comma-separated values + dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), + }) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() @@ -366,6 +376,7 @@ export async function loadCliConfig( embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: process.cwd(), + includeDirectories: argv.includeDirectories, debugMode, question: argv.promptInteractive || argv.prompt || '', fullContext: argv.allFiles || argv.all_files || false, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index a31c4b2f..b4b70b61 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -199,7 +199,7 @@ export async function main() { process.exit(1); } } - await start_sandbox(sandboxConfig, memoryArgs); + await start_sandbox(sandboxConfig, memoryArgs, config); process.exit(0); } else { // Not in a sandbox and not entering one, so relaunch with additional diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index fef4106a..13ddb77d 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -152,6 +152,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { getSessionId: vi.fn(() => 'test-session-id'), getUserTier: vi.fn().mockResolvedValue(undefined), getIdeMode: vi.fn(() => false), + getWorkspaceContext: vi.fn(() => ({ + getDirectories: vi.fn(() => []), + })), }; }); @@ -292,6 +295,13 @@ describe('App UI', () => { // Ensure a theme is set so the theme dialog does not appear. mockSettings = createMockSettings({ workspace: { theme: 'Default' } }); + + // Ensure getWorkspaceContext is available if not added by the constructor + if (!mockConfig.getWorkspaceContext) { + mockConfig.getWorkspaceContext = vi.fn(() => ({ + getDirectories: vi.fn(() => ['/test/dir']), + })); + } vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined); }); diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 48dd6db3..43cd59ec 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -62,6 +62,7 @@ describe('aboutCommand', () => { }); it('should call addItem with all version info', async () => { + process.env.SANDBOX = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1d0b868f..e0d967da 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -172,6 +172,9 @@ describe('InputPrompt', () => { getProjectRoot: () => path.join('test', 'project'), getTargetDir: () => path.join('test', 'project', 'src'), getVimMode: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => ['/test/project/src'], + }), } as unknown as Config, slashCommands: mockSlashCommands, commandContext: mockCommandContext, @@ -731,6 +734,7 @@ describe('InputPrompt', () => { // Verify useCompletion was called with correct signature expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -756,6 +760,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -781,6 +786,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -806,6 +812,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -831,6 +838,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -857,6 +865,7 @@ describe('InputPrompt', () => { // Verify useCompletion was called with the buffer expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -882,6 +891,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -908,6 +918,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -934,6 +945,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -960,6 +972,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -986,6 +999,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -1014,6 +1028,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -1040,6 +1055,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, @@ -1068,6 +1084,7 @@ describe('InputPrompt', () => { expect(mockedUseCompletion).toHaveBeenCalledWith( mockBuffer, + ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 17b7694e..5a7b6353 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -60,8 +60,19 @@ export const InputPrompt: React.FC = ({ }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); + const [dirs, setDirs] = useState( + config.getWorkspaceContext().getDirectories(), + ); + const dirsChanged = config.getWorkspaceContext().getDirectories(); + useEffect(() => { + if (dirs.length !== dirsChanged.length) { + setDirs(dirsChanged); + } + }, [dirs.length, dirsChanged]); + const completion = useCompletion( buffer, + dirs, config.getTargetDir(), slashCommands, commandContext, diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index de05667e..2b4c81a3 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -57,6 +57,10 @@ describe('handleAtCommand', () => { respectGeminiIgnore: true, }), getEnableRecursiveFileSearch: vi.fn(() => true), + getWorkspaceContext: () => ({ + isPathWithinWorkspace: () => true, + getDirectories: () => [testRootDir], + }), } as unknown as Config; const registry = new ToolRegistry(mockConfig); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 237d983f..7b9005fa 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -188,6 +188,14 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options + const workspaceContext = config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(pathName)) { + onDebugMessage( + `Path ${pathName} is not in the workspace and will be skipped.`, + ); + continue; + } + const gitIgnored = respectFileIgnore.respectGitIgnore && fileDiscovery.shouldIgnoreFile(pathName, { @@ -215,90 +223,88 @@ export async function handleAtCommand({ continue; } - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - - try { - const absolutePath = path.resolve(config.getTargetDir(), pathName); - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = - pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); - onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); - } else { - onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); - } - resolvedSuccessfully = true; - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (config.getEnableRecursiveFileSearch() && globTool) { + for (const dir of config.getWorkspaceContext().getDirectories()) { + let currentPathSpec = pathName; + let resolvedSuccessfully = false; + try { + const absolutePath = path.resolve(dir, pathName); + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = + pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); onDebugMessage( - `Path ${pathName} not found directly, attempting glob search.`, + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, ); - try { - const globResult = await globTool.execute( - { - pattern: `**/*${pathName}*`, - path: config.getTargetDir(), - }, - signal, + } else { + onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); + } + resolvedSuccessfully = true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (config.getEnableRecursiveFileSearch() && globTool) { + onDebugMessage( + `Path ${pathName} not found directly, attempting glob search.`, ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative( - config.getTargetDir(), - firstMatchAbsolute, - ); - onDebugMessage( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; + try { + const globResult = await globTool.execute( + { + pattern: `**/*${pathName}*`, + path: dir, + }, + signal, + ); + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + currentPathSpec = path.relative(dir, firstMatchAbsolute); + onDebugMessage( + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + ); + resolvedSuccessfully = true; + } else { + onDebugMessage( + `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + ); + } } else { onDebugMessage( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, ); } - } else { + } catch (globError) { + console.error( + `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, + ); onDebugMessage( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, + `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, ); } - } catch (globError) { - console.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); + } else { onDebugMessage( - `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, + `Glob tool not found. Path ${pathName} will be skipped.`, ); } } else { + console.error( + `Error stating path ${pathName}: ${getErrorMessage(error)}`, + ); onDebugMessage( - `Glob tool not found. Path ${pathName} will be skipped.`, + `Error stating path ${pathName}. Path ${pathName} will be skipped.`, ); } - } else { - console.error( - `Error stating path ${pathName}: ${getErrorMessage(error)}`, - ); - onDebugMessage( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, - ); } - } - - if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); - atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec); - contentLabelsForDisplay.push(pathName); + if (resolvedSuccessfully) { + pathSpecsToRead.push(currentPathSpec); + atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec); + contentLabelsForDisplay.push(pathName); + break; + } } } diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index da6a7ab3..f876eea1 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -22,6 +22,7 @@ describe('useCompletion', () => { // A minimal mock is sufficient for these tests. const mockCommandContext = {} as CommandContext; + let testDirs: string[]; async function createEmptyDir(...pathSegments: string[]) { const fullPath = path.join(testRootDir, ...pathSegments); @@ -51,8 +52,12 @@ describe('useCompletion', () => { testRootDir = await fs.mkdtemp( path.join(os.tmpdir(), 'completion-unit-test-'), ); + testDirs = [testRootDir]; mockConfig = { getTargetDir: () => testRootDir, + getWorkspaceContext: () => ({ + getDirectories: () => testDirs, + }), getProjectRoot: () => testRootDir, getFileFilteringOptions: vi.fn(() => ({ respectGitIgnore: true, @@ -79,6 +84,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -108,6 +114,7 @@ describe('useCompletion', () => { const textBuffer = useTextBufferForTest(text); return useCompletion( textBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -138,6 +145,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/help'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -170,6 +178,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -191,6 +200,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -215,6 +225,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/h'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -242,6 +253,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/h'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -270,6 +282,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -315,6 +328,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/command'), + testDirs, testRootDir, largeMockCommands, mockCommandContext, @@ -372,6 +386,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -394,6 +409,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/mem'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -417,6 +433,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/usag'), // part of the word "usage" + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -443,6 +460,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/clear'), // No trailing space + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -474,6 +492,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(query), + testDirs, testRootDir, mockSlashCommands, mockCommandContext, @@ -494,6 +513,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/clear '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -514,6 +534,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/unknown-command'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -547,6 +568,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory'), // Note: no trailing space + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -584,6 +606,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -619,6 +642,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory a'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -650,6 +674,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory dothisnow'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -692,6 +717,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume my-ch'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -735,6 +761,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -769,6 +796,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -796,6 +824,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@s'), + testDirs, testRootDir, [], mockCommandContext, @@ -829,6 +858,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@src/comp'), + testDirs, testRootDir, [], mockCommandContext, @@ -854,6 +884,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@.'), + testDirs, testRootDir, [], mockCommandContext, @@ -885,6 +916,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@d'), + testDirs, testRootDir, [], mockCommandContext, @@ -910,6 +942,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -944,6 +977,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -974,6 +1008,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@d'), + testDirs, testRootDir, [], mockCommandContext, @@ -1007,6 +1042,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -1039,6 +1075,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@t'), + testDirs, testRootDir, [], mockCommandContext, @@ -1085,6 +1122,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1128,6 +1166,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1173,6 +1212,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1221,6 +1261,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 10724c21..4b106c1b 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -43,6 +43,7 @@ export interface UseCompletionReturn { export function useCompletion( buffer: TextBuffer, + dirs: readonly string[], cwd: string, slashCommands: readonly SlashCommand[], commandContext: CommandContext, @@ -328,8 +329,6 @@ export function useCompletion( : partialPath.substring(lastSlashIndex + 1), ); - const baseDirAbsolute = path.resolve(cwd, baseDirRelative); - let isMounted = true; const findFilesRecursively = async ( @@ -358,7 +357,7 @@ export function useCompletion( const entryPathRelative = path.join(currentRelativePath, entry.name); const entryPathFromRoot = path.relative( - cwd, + startDir, path.join(startDir, entry.name), ); @@ -417,29 +416,31 @@ export function useCompletion( respectGitIgnore?: boolean; respectGeminiIgnore?: boolean; }, + searchDir: string, maxResults = 50, ): Promise => { const globPattern = `**/${searchPrefix}*`; const files = await glob(globPattern, { - cwd, + cwd: searchDir, dot: searchPrefix.startsWith('.'), nocase: true, }); const suggestions: Suggestion[] = files - .map((file: string) => ({ - label: file, - value: escapePath(file), - })) - .filter((s) => { + .filter((file) => { if (fileDiscoveryService) { - return !fileDiscoveryService.shouldIgnoreFile( - s.label, - filterOptions, - ); // relative path + return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); } return true; }) + .map((file: string) => { + const absolutePath = path.resolve(searchDir, file); + const label = path.relative(cwd, absolutePath); + return { + label, + value: escapePath(label), + }; + }) .slice(0, maxResults); return suggestions; @@ -456,63 +457,78 @@ export function useCompletion( config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; try { - // If there's no slash, or it's the root, do a recursive search from cwd - if ( - partialPath.indexOf('/') === -1 && - prefix && - enableRecursiveSearch - ) { - if (fileDiscoveryService) { - fetchedSuggestions = await findFilesWithGlob( - prefix, - fileDiscoveryService, - filterOptions, - ); + // If there's no slash, or it's the root, do a recursive search from workspace directories + for (const dir of dirs) { + let fetchedSuggestionsPerDir: Suggestion[] = []; + if ( + partialPath.indexOf('/') === -1 && + prefix && + enableRecursiveSearch + ) { + if (fileDiscoveryService) { + fetchedSuggestionsPerDir = await findFilesWithGlob( + prefix, + fileDiscoveryService, + filterOptions, + dir, + ); + } else { + fetchedSuggestionsPerDir = await findFilesRecursively( + dir, + prefix, + null, + filterOptions, + ); + } } else { - fetchedSuggestions = await findFilesRecursively( - cwd, - prefix, - null, - filterOptions, - ); - } - } else { - // Original behavior: list files in the specific directory - const lowerPrefix = prefix.toLowerCase(); - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, - }); + // Original behavior: list files in the specific directory + const lowerPrefix = prefix.toLowerCase(); + const baseDirAbsolute = path.resolve(dir, baseDirRelative); + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); - // Filter entries using git-aware filtering - const filteredEntries = []; - for (const entry of entries) { - // Conditionally ignore dotfiles - if (!prefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; + // Filter entries using git-aware filtering + const filteredEntries = []; + for (const entry of entries) { + // Conditionally ignore dotfiles + if (!prefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - const relativePath = path.relative( - cwd, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile(relativePath, filterOptions) - ) { - continue; + const relativePath = path.relative( + dir, + path.join(baseDirAbsolute, entry.name), + ); + if ( + fileDiscoveryService && + fileDiscoveryService.shouldIgnoreFile( + relativePath, + filterOptions, + ) + ) { + continue; + } + + filteredEntries.push(entry); } - filteredEntries.push(entry); + fetchedSuggestionsPerDir = filteredEntries.map((entry) => { + const absolutePath = path.resolve(baseDirAbsolute, entry.name); + const label = + cwd === dir ? entry.name : path.relative(cwd, absolutePath); + const suggestionLabel = entry.isDirectory() ? label + '/' : label; + return { + label: suggestionLabel, + value: escapePath(suggestionLabel), + }; + }); } - - fetchedSuggestions = filteredEntries.map((entry) => { - const label = entry.isDirectory() ? entry.name + '/' : entry.name; - return { - label, - value: escapePath(label), // Value for completion should be just the name part - }; - }); + fetchedSuggestions = [ + ...fetchedSuggestions, + ...fetchedSuggestionsPerDir, + ]; } // Like glob, we always return forwardslashes, even in windows. @@ -585,6 +601,7 @@ export function useCompletion( }; }, [ buffer.text, + dirs, cwd, isActive, resetCompletionState, diff --git a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb index 36d88995..cf64da94 100644 --- a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb +++ b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb @@ -13,6 +13,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox-macos-permissive-open.sb b/packages/cli/src/utils/sandbox-macos-permissive-open.sb index 552efcd4..50d21a1f 100644 --- a/packages/cli/src/utils/sandbox-macos-permissive-open.sb +++ b/packages/cli/src/utils/sandbox-macos-permissive-open.sb @@ -13,6 +13,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb index 4410776b..8becc8cb 100644 --- a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb +++ b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb @@ -13,6 +13,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb index 9ce68e9d..17d0c073 100644 --- a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb +++ b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb @@ -71,6 +71,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb index e89b8090..17f27224 100644 --- a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb +++ b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb @@ -71,6 +71,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb index a49712a3..c07c1496 100644 --- a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb +++ b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb @@ -71,6 +71,12 @@ (subpath (string-append (param "HOME_DIR") "/.npm")) (subpath (string-append (param "HOME_DIR") "/.cache")) (subpath (string-append (param "HOME_DIR") "/.gitconfig")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/null") diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 84fdc8f7..72b5e56b 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -15,7 +15,7 @@ import { SETTINGS_DIRECTORY_NAME, } from '../config/settings.js'; import { promisify } from 'util'; -import { SandboxConfig } from '@google/gemini-cli-core'; +import { Config, SandboxConfig } from '@google/gemini-cli-core'; const execAsync = promisify(exec); @@ -183,6 +183,7 @@ function entrypoint(workdir: string): string[] { export async function start_sandbox( config: SandboxConfig, nodeArgs: string[] = [], + cliConfig?: Config, ) { if (config.command === 'sandbox-exec') { // disallow BUILD_SANDBOX @@ -223,6 +224,38 @@ export async function start_sandbox( `HOME_DIR=${fs.realpathSync(os.homedir())}`, '-D', `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, + ]; + + // Add included directories from the workspace context + // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them + const MAX_INCLUDE_DIRS = 5; + const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); + const includedDirs: string[] = []; + + if (cliConfig) { + const workspaceContext = cliConfig.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Filter out TARGET_DIR + for (const dir of directories) { + const realDir = fs.realpathSync(dir); + if (realDir !== targetDir) { + includedDirs.push(realDir); + } + } + } + + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { + let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + + if (i < includedDirs.length) { + dirPath = includedDirs[i]; + } + + args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + } + + args.push( '-f', profileFile, 'sh', @@ -232,7 +265,7 @@ export async function start_sandbox( `NODE_OPTIONS="${nodeOptions}"`, ...process.argv.map((arg) => quote([arg])), ].join(' '), - ]; + ); // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; let proxyProcess: ChildProcess | undefined = undefined; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index f2169790..dcc81b4f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -19,6 +19,18 @@ import { import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }), + realpathSync: vi.fn((path) => path), + }; +}); + // Mock dependencies that might be called during Config construction or createServerConfig vi.mock('../tools/tool-registry', () => { const ToolRegistryMock = vi.fn(); @@ -219,6 +231,23 @@ describe('Server Config (config.ts)', () => { expect(config.getFileFilteringRespectGitIgnore()).toBe(false); }); + it('should initialize WorkspaceContext with includeDirectories', () => { + const includeDirectories = ['/path/to/dir1', '/path/to/dir2']; + const paramsWithIncludeDirs: ConfigParameters = { + ...baseParams, + includeDirectories, + }; + const config = new Config(paramsWithIncludeDirs); + const workspaceContext = config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Should include the target directory plus the included directories + expect(directories).toHaveLength(3); + expect(directories).toContain(path.resolve(baseParams.targetDir)); + expect(directories).toContain('/path/to/dir1'); + expect(directories).toContain('/path/to/dir2'); + }); + it('Config constructor should set telemetry to true when provided as true', () => { const paramsWithTelemetry: ConfigParameters = { ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c92fb623..d8bce341 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -50,6 +50,7 @@ import { IdeClient } from '../ide/ide-client.js'; // Re-export OAuth config type export type { MCPOAuthConfig }; +import { WorkspaceContext } from '../utils/workspaceContext.js'; export enum ApprovalMode { DEFAULT = 'default', @@ -172,6 +173,7 @@ export interface ConfigParameters { proxy?: string; cwd: string; fileDiscoveryService?: FileDiscoveryService; + includeDirectories?: string[]; bugCommand?: BugCommandSettings; model: string; extensionContextFilePaths?: string[]; @@ -194,6 +196,7 @@ export class Config { private readonly embeddingModel: string; private readonly sandbox: SandboxConfig | undefined; private readonly targetDir: string; + private readonly workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; @@ -248,6 +251,10 @@ export class Config { params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; this.sandbox = params.sandbox; this.targetDir = path.resolve(params.targetDir); + this.workspaceContext = new WorkspaceContext( + this.targetDir, + params.includeDirectories ?? [], + ); this.debugMode = params.debugMode; this.question = params.question; this.fullContext = params.fullContext ?? false; @@ -392,6 +399,10 @@ export class Config { return this.targetDir; } + getWorkspaceContext(): WorkspaceContext { + return this.workspaceContext; + } + getToolRegistry(): Promise { return Promise.resolve(this.toolRegistry); } diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index cd78cd34..a0034ea1 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -4,14 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from './config.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; +import fs from 'node:fs'; + +vi.mock('node:fs'); describe('Flash Model Fallback Configuration', () => { let config: Config; beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); config = new Config({ sessionId: 'test-session', targetDir: '/test', diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 1da355f4..96ddec1c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -200,6 +200,9 @@ describe('Gemini Client (client.ts)', () => { getNoBrowser: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getIdeMode: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(['/test/dir']), + }), getGeminiClient: vi.fn(), setFallbackMode: vi.fn(), }; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6337bac2..ecc7c242 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -172,7 +172,6 @@ export class GeminiClient { } private async getEnvironment(): Promise { - const cwd = this.config.getWorkingDir(); const today = new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', @@ -180,14 +179,35 @@ export class GeminiClient { day: 'numeric', }); const platform = process.platform; - const folderStructure = await getFolderStructure(cwd, { - fileService: this.config.getFileService(), - }); + + const workspaceContext = this.config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + const folderStructures = await Promise.all( + workspaceDirectories.map((dir) => + getFolderStructure(dir, { + fileService: this.config.getFileService(), + }), + ), + ); + + const folderStructure = folderStructures.join('\n'); + + let workingDirPreamble: string; + if (workspaceDirectories.length === 1) { + workingDirPreamble = `I'm currently working in the directory: ${workspaceDirectories[0]}`; + } else { + const dirList = workspaceDirectories + .map((dir) => ` - ${dir}`) + .join('\n'); + workingDirPreamble = `I'm currently working in the following directories:\n${dirList}`; + } + const context = ` This is the Gemini CLI. We are setting up the context for our chat. Today's date is ${today}. My operating system is: ${platform} - I'm currently working in the directory: ${cwd} + ${workingDirPreamble} ${folderStructure} `.trim(); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts new file mode 100644 index 00000000..61497b3e --- /dev/null +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { WorkspaceContext } from '../utils/workspaceContext.js'; + +/** + * Creates a mock WorkspaceContext for testing + * @param rootDir The root directory to use for the mock + * @param additionalDirs Optional additional directories to include in the workspace + * @returns A mock WorkspaceContext instance + */ +export function createMockWorkspaceContext( + rootDir: string, + additionalDirs: string[] = [], +): WorkspaceContext { + const allDirs = [rootDir, ...additionalDirs]; + + const mockWorkspaceContext = { + addDirectory: vi.fn(), + getDirectories: vi.fn().mockReturnValue(allDirs), + isPathWithinWorkspace: vi + .fn() + .mockImplementation((path: string) => + allDirs.some((dir) => path.startsWith(dir)), + ), + } as unknown as WorkspaceContext; + + return mockWorkspaceContext; +} diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 4ff33ff4..b44d7e6f 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -32,6 +32,7 @@ import fs from 'fs'; import os from 'os'; import { ApprovalMode, Config } from '../config/config.js'; import { Content, Part, SchemaUnion } from '@google/genai'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; describe('EditTool', () => { let tool: EditTool; @@ -41,6 +42,7 @@ describe('EditTool', () => { let geminiClient: any; beforeEach(() => { + vi.restoreAllMocks(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-')); rootDir = path.join(tempDir, 'root'); fs.mkdirSync(rootDir); @@ -54,6 +56,7 @@ describe('EditTool', () => { getTargetDir: () => rootDir, getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method // Add other properties/methods of Config if EditTool uses them // Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses: @@ -215,8 +218,9 @@ describe('EditTool', () => { old_string: 'old', new_string: 'new', }; - expect(tool.validateToolParams(params)).toMatch( - /File path must be within the root directory/, + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', ); }); }); @@ -675,4 +679,28 @@ describe('EditTool', () => { ); }); }); + + describe('workspace boundary validation', () => { + it('should validate paths are within workspace root', () => { + const validPath = { + file_path: path.join(rootDir, 'file.txt'), + old_string: 'old', + new_string: 'new', + }; + expect(tool.validateToolParams(validPath)).toBeNull(); + }); + + it('should reject paths outside workspace root', () => { + const invalidPath = { + file_path: '/etc/passwd', + old_string: 'root', + new_string: 'hacked', + }; + const error = tool.validateToolParams(invalidPath); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + expect(error).toContain(rootDir); + }); + }); }); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index fd936611..ff2bc204 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -26,7 +26,6 @@ import { ensureCorrectEdit } from '../utils/editCorrector.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; -import { isWithinRoot } from '../utils/fileUtils.js'; /** * Parameters for the Edit tool @@ -137,8 +136,10 @@ Expectation for required parameters: return `File path must be absolute: ${params.file_path}`; } - if (!isWithinRoot(params.file_path, this.config.getTargetDir())) { - return `File path must be within the root directory (${this.config.getTargetDir()}): ${params.file_path}`; + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(params.file_path)) { + const directories = workspaceContext.getDirectories(); + return `File path must be within one of the workspace directories: ${directories.join(', ')}`; } return null; diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 51effe4e..0ee6c0ee 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -9,9 +9,10 @@ import { partListUnionToString } from '../core/geminiRequest.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // Removed vi +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { Config } from '../config/config.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; describe('GlobTool', () => { let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance @@ -23,6 +24,7 @@ describe('GlobTool', () => { getFileService: () => new FileDiscoveryService(tempRootDir), getFileFilteringRespectGitIgnore: () => true, getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), } as unknown as Config; beforeEach(async () => { @@ -243,7 +245,7 @@ describe('GlobTool', () => { path: '../../../../../../../../../../tmp', }; // Definitely outside expect(specificGlobTool.validateToolParams(paramsOutside)).toContain( - "resolves outside the tool's root directory", + 'resolves outside the allowed workspace directories', ); }); @@ -264,6 +266,37 @@ describe('GlobTool', () => { ); }); }); + + describe('workspace boundary validation', () => { + it('should validate search paths are within workspace boundaries', () => { + const validPath = { pattern: '*.ts', path: 'sub' }; + const invalidPath = { pattern: '*.ts', path: '../..' }; + + expect(globTool.validateToolParams(validPath)).toBeNull(); + expect(globTool.validateToolParams(invalidPath)).toContain( + 'resolves outside the allowed workspace directories', + ); + }); + + it('should provide clear error messages when path is outside workspace', () => { + const invalidPath = { pattern: '*.ts', path: '/etc' }; + const error = globTool.validateToolParams(invalidPath); + + expect(error).toContain( + 'resolves outside the allowed workspace directories', + ); + expect(error).toContain(tempRootDir); + }); + + it('should work with paths in workspace subdirectories', async () => { + const params: GlobToolParams = { pattern: '*.md', path: 'sub' }; + const result = await globTool.execute(params, abortSignal); + + expect(result.llmContent).toContain('Found 2 file(s)'); + expect(result.llmContent).toContain('fileC.md'); + expect(result.llmContent).toContain('FileD.MD'); + }); + }); }); describe('sortFileEntries', () => { diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 417495fe..5bcb9778 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -11,7 +11,6 @@ import { SchemaValidator } from '../utils/schemaValidator.js'; import { BaseTool, Icon, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { shortenPath, makeRelative } from '../utils/paths.js'; -import { isWithinRoot } from '../utils/fileUtils.js'; import { Config } from '../config/config.js'; // Subset of 'Path' interface provided by 'glob' that we can implement for testing @@ -130,8 +129,10 @@ export class GlobTool extends BaseTool { params.path || '.', ); - if (!isWithinRoot(searchDirAbsolute, this.config.getTargetDir())) { - return `Search path ("${searchDirAbsolute}") resolves outside the tool's root directory ("${this.config.getTargetDir()}").`; + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { + const directories = workspaceContext.getDirectories(); + return `Search path ("${searchDirAbsolute}") resolves outside the allowed workspace directories: ${directories.join(', ')}`; } const targetDir = searchDirAbsolute || this.config.getTargetDir(); @@ -189,10 +190,27 @@ export class GlobTool extends BaseTool { } try { - const searchDirAbsolute = path.resolve( - this.config.getTargetDir(), - params.path || '.', - ); + const workspaceContext = this.config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + // If a specific path is provided, resolve it and check if it's within workspace + let searchDirectories: readonly string[]; + if (params.path) { + const searchDirAbsolute = path.resolve( + this.config.getTargetDir(), + params.path, + ); + if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { + return { + llmContent: `Error: Path "${params.path}" is not within any workspace directory`, + returnDisplay: `Path is not within workspace`, + }; + } + searchDirectories = [searchDirAbsolute]; + } else { + // Search across all workspace directories + searchDirectories = workspaceDirectories; + } // Get centralized file discovery service const respectGitIgnore = @@ -200,17 +218,26 @@ export class GlobTool extends BaseTool { this.config.getFileFilteringRespectGitIgnore(); const fileDiscovery = this.config.getFileService(); - const entries = (await glob(params.pattern, { - cwd: searchDirAbsolute, - withFileTypes: true, - nodir: true, - stat: true, - nocase: !params.case_sensitive, - dot: true, - ignore: ['**/node_modules/**', '**/.git/**'], - follow: false, - signal, - })) as GlobPath[]; + // Collect entries from all search directories + let allEntries: GlobPath[] = []; + + for (const searchDir of searchDirectories) { + const entries = (await glob(params.pattern, { + cwd: searchDir, + withFileTypes: true, + nodir: true, + stat: true, + nocase: !params.case_sensitive, + dot: true, + ignore: ['**/node_modules/**', '**/.git/**'], + follow: false, + signal, + })) as GlobPath[]; + + allEntries = allEntries.concat(entries); + } + + const entries = allEntries; // Apply git-aware filtering if enabled and in git repository let filteredEntries = entries; @@ -236,7 +263,12 @@ export class GlobTool extends BaseTool { } if (!filteredEntries || filteredEntries.length === 0) { - let message = `No files found matching pattern "${params.pattern}" within ${searchDirAbsolute}.`; + let message = `No files found matching pattern "${params.pattern}"`; + if (searchDirectories.length === 1) { + message += ` within ${searchDirectories[0]}`; + } else { + message += ` within ${searchDirectories.length} workspace directories`; + } if (gitIgnoredCount > 0) { message += ` (${gitIgnoredCount} files were git-ignored)`; } @@ -263,7 +295,12 @@ export class GlobTool extends BaseTool { const fileListDescription = sortedAbsolutePaths.join('\n'); const fileCount = sortedAbsolutePaths.length; - let resultMessage = `Found ${fileCount} file(s) matching "${params.pattern}" within ${searchDirAbsolute}`; + let resultMessage = `Found ${fileCount} file(s) matching "${params.pattern}"`; + if (searchDirectories.length === 1) { + resultMessage += ` within ${searchDirectories[0]}`; + } else { + resultMessage += ` across ${searchDirectories.length} workspace directories`; + } if (gitIgnoredCount > 0) { resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index 01295083..aadc93ae 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -10,6 +10,7 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import { Config } from '../config/config.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; // Mock the child_process module to control grep/git grep behavior vi.mock('child_process', () => ({ @@ -33,6 +34,7 @@ describe('GrepTool', () => { const mockConfig = { getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), } as unknown as Config; beforeEach(async () => { @@ -120,7 +122,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'world' }; const result = await grepTool.execute(params, abortSignal); expect(result.llmContent).toContain( - 'Found 3 matches for pattern "world" in path "."', + 'Found 3 matches for pattern "world" in the workspace directory', ); expect(result.llmContent).toContain('File: fileA.txt'); expect(result.llmContent).toContain('L1: hello world'); @@ -147,7 +149,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'hello', include: '*.js' }; const result = await grepTool.execute(params, abortSignal); expect(result.llmContent).toContain( - 'Found 1 match for pattern "hello" in path "." (filter: "*.js")', + 'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):', ); expect(result.llmContent).toContain('File: fileB.js'); expect(result.llmContent).toContain( @@ -179,7 +181,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'nonexistentpattern' }; const result = await grepTool.execute(params, abortSignal); expect(result.llmContent).toContain( - 'No matches found for pattern "nonexistentpattern" in path "."', + 'No matches found for pattern "nonexistentpattern" in the workspace directory.', ); expect(result.returnDisplay).toBe('No matches found'); }); @@ -188,7 +190,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";' const result = await grepTool.execute(params, abortSignal); expect(result.llmContent).toContain( - 'Found 1 match for pattern "foo.*bar" in path "."', + 'Found 1 match for pattern "foo.*bar" in the workspace directory:', ); expect(result.llmContent).toContain('File: fileB.js'); expect(result.llmContent).toContain('L1: const foo = "bar";'); @@ -198,7 +200,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'HELLO' }; const result = await grepTool.execute(params, abortSignal); expect(result.llmContent).toContain( - 'Found 2 matches for pattern "HELLO" in path "."', + 'Found 2 matches for pattern "HELLO" in the workspace directory:', ); expect(result.llmContent).toContain('File: fileA.txt'); expect(result.llmContent).toContain('L1: hello world'); @@ -220,6 +222,98 @@ describe('GrepTool', () => { }); }); + describe('multi-directory workspace', () => { + it('should search across all workspace directories when no path is specified', async () => { + // Create additional directory with test files + const secondDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'grep-tool-second-'), + ); + await fs.writeFile( + path.join(secondDir, 'other.txt'), + 'hello from second directory\nworld in second', + ); + await fs.writeFile( + path.join(secondDir, 'another.js'), + 'function world() { return "test"; }', + ); + + // Create a mock config with multiple directories + const multiDirConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(tempRootDir, [secondDir]), + } as unknown as Config; + + const multiDirGrepTool = new GrepTool(multiDirConfig); + const params: GrepToolParams = { pattern: 'world' }; + const result = await multiDirGrepTool.execute(params, abortSignal); + + // Should find matches in both directories + expect(result.llmContent).toContain( + 'Found 5 matches for pattern "world"', + ); + + // Matches from first directory + expect(result.llmContent).toContain('fileA.txt'); + expect(result.llmContent).toContain('L1: hello world'); + expect(result.llmContent).toContain('L2: second line with world'); + expect(result.llmContent).toContain('fileC.txt'); + expect(result.llmContent).toContain('L1: another world in sub dir'); + + // Matches from second directory (with directory name prefix) + const secondDirName = path.basename(secondDir); + expect(result.llmContent).toContain( + `File: ${path.join(secondDirName, 'other.txt')}`, + ); + expect(result.llmContent).toContain('L2: world in second'); + expect(result.llmContent).toContain( + `File: ${path.join(secondDirName, 'another.js')}`, + ); + expect(result.llmContent).toContain('L1: function world()'); + + // Clean up + await fs.rm(secondDir, { recursive: true, force: true }); + }); + + it('should search only specified path within workspace directories', async () => { + // Create additional directory + const secondDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'grep-tool-second-'), + ); + await fs.mkdir(path.join(secondDir, 'sub')); + await fs.writeFile( + path.join(secondDir, 'sub', 'test.txt'), + 'hello from second sub directory', + ); + + // Create a mock config with multiple directories + const multiDirConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(tempRootDir, [secondDir]), + } as unknown as Config; + + const multiDirGrepTool = new GrepTool(multiDirConfig); + + // Search only in the 'sub' directory of the first workspace + const params: GrepToolParams = { pattern: 'world', path: 'sub' }; + const result = await multiDirGrepTool.execute(params, abortSignal); + + // Should only find matches in the specified sub directory + expect(result.llmContent).toContain( + 'Found 1 match for pattern "world" in path "sub"', + ); + expect(result.llmContent).toContain('File: fileC.txt'); + expect(result.llmContent).toContain('L1: another world in sub dir'); + + // Should not contain matches from second directory + expect(result.llmContent).not.toContain('test.txt'); + + // Clean up + await fs.rm(secondDir, { recursive: true, force: true }); + }); + }); + describe('getDescription', () => { it('should generate correct description with pattern only', () => { const params: GrepToolParams = { pattern: 'testPattern' }; @@ -246,6 +340,21 @@ describe('GrepTool', () => { ); }); + it('should indicate searching across all workspace directories when no path specified', () => { + // Create a mock config with multiple directories + const multiDirConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(tempRootDir, ['/another/dir']), + } as unknown as Config; + + const multiDirGrepTool = new GrepTool(multiDirConfig); + const params: GrepToolParams = { pattern: 'testPattern' }; + expect(multiDirGrepTool.getDescription(params)).toBe( + "'testPattern' across all workspace directories", + ); + }); + it('should generate correct description with pattern, include, and path', () => { const params: GrepToolParams = { pattern: 'testPattern', diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 177bd1aa..027ab1b1 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -92,22 +92,23 @@ export class GrepTool extends BaseTool { /** * Checks if a path is within the root directory and resolves it. * @param relativePath Path relative to the root directory (or undefined for root). - * @returns The absolute path if valid and exists. + * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. */ - private resolveAndValidatePath(relativePath?: string): string { - const targetPath = path.resolve( - this.config.getTargetDir(), - relativePath || '.', - ); + private resolveAndValidatePath(relativePath?: string): string | null { + // If no path specified, return null to indicate searching all workspace directories + if (!relativePath) { + return null; + } - // Security Check: Ensure the resolved path is still within the root directory. - if ( - !targetPath.startsWith(this.config.getTargetDir()) && - targetPath !== this.config.getTargetDir() - ) { + const targetPath = path.resolve(this.config.getTargetDir(), relativePath); + + // Security Check: Ensure the resolved path is within workspace boundaries + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(targetPath)) { + const directories = workspaceContext.getDirectories(); throw new Error( - `Path validation failed: Attempted path "${relativePath || '.'}" resolves outside the allowed root directory "${this.config.getTargetDir()}".`, + `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, ); } @@ -146,10 +147,13 @@ export class GrepTool extends BaseTool { return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; } - try { - this.resolveAndValidatePath(params.path); - } catch (error) { - return getErrorMessage(error); + // Only validate path if one is provided + if (params.path) { + try { + this.resolveAndValidatePath(params.path); + } catch (error) { + return getErrorMessage(error); + } } return null; // Parameters are valid @@ -174,44 +178,78 @@ export class GrepTool extends BaseTool { }; } - let searchDirAbs: string; try { - searchDirAbs = this.resolveAndValidatePath(params.path); + const workspaceContext = this.config.getWorkspaceContext(); + const searchDirAbs = this.resolveAndValidatePath(params.path); const searchDirDisplay = params.path || '.'; - const matches: GrepMatch[] = await this.performGrepSearch({ - pattern: params.pattern, - path: searchDirAbs, - include: params.include, - signal, - }); + // Determine which directories to search + let searchDirectories: readonly string[]; + if (searchDirAbs === null) { + // No path specified - search all workspace directories + searchDirectories = workspaceContext.getDirectories(); + } else { + // Specific path provided - search only that directory + searchDirectories = [searchDirAbs]; + } - if (matches.length === 0) { - const noMatchMsg = `No matches found for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}.`; + // Collect matches from all search directories + let allMatches: GrepMatch[] = []; + for (const searchDir of searchDirectories) { + const matches = await this.performGrepSearch({ + pattern: params.pattern, + path: searchDir, + include: params.include, + signal, + }); + + // Add directory prefix if searching multiple directories + if (searchDirectories.length > 1) { + const dirName = path.basename(searchDir); + matches.forEach((match) => { + match.filePath = path.join(dirName, match.filePath); + }); + } + + allMatches = allMatches.concat(matches); + } + + let searchLocationDescription: string; + if (searchDirAbs === null) { + const numDirs = workspaceContext.getDirectories().length; + searchLocationDescription = + numDirs > 1 + ? `across ${numDirs} workspace directories` + : `in the workspace directory`; + } else { + searchLocationDescription = `in path "${searchDirDisplay}"`; + } + + if (allMatches.length === 0) { + const noMatchMsg = `No matches found for pattern "${params.pattern}" ${searchLocationDescription}${params.include ? ` (filter: "${params.include}")` : ''}.`; return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; } - const matchesByFile = matches.reduce( + // Group matches by file + const matchesByFile = allMatches.reduce( (acc, match) => { - const relativeFilePath = - path.relative( - searchDirAbs, - path.resolve(searchDirAbs, match.filePath), - ) || path.basename(match.filePath); - if (!acc[relativeFilePath]) { - acc[relativeFilePath] = []; + const fileKey = match.filePath; + if (!acc[fileKey]) { + acc[fileKey] = []; } - acc[relativeFilePath].push(match); - acc[relativeFilePath].sort((a, b) => a.lineNumber - b.lineNumber); + acc[fileKey].push(match); + acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); return acc; }, {} as Record, ); - const matchCount = matches.length; + const matchCount = allMatches.length; const matchTerm = matchCount === 1 ? 'match' : 'matches'; - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${params.pattern}" in path "${searchDirDisplay}"${params.include ? ` (filter: "${params.include}")` : ''}:\n---\n`; + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${params.pattern}" ${searchLocationDescription}${params.include ? ` (filter: "${params.include}")` : ''}: +--- +`; for (const filePath in matchesByFile) { llmContent += `File: ${filePath}\n`; @@ -334,6 +372,13 @@ export class GrepTool extends BaseTool { ); description += ` within ${shortenPath(relativePath)}`; } + } else { + // When no path is specified, indicate searching all workspace directories + const workspaceContext = this.config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + if (directories.length > 1) { + description += ` across all workspace directories`; + } } return description; } diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts new file mode 100644 index 00000000..fb99d829 --- /dev/null +++ b/packages/core/src/tools/ls.test.ts @@ -0,0 +1,496 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +vi.mock('fs', () => ({ + default: { + statSync: vi.fn(), + readdirSync: vi.fn(), + }, + statSync: vi.fn(), + readdirSync: vi.fn(), +})); +import { LSTool } from './ls.js'; +import { Config } from '../config/config.js'; +import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; + +describe('LSTool', () => { + let lsTool: LSTool; + let mockConfig: Config; + let mockWorkspaceContext: WorkspaceContext; + let mockFileService: FileDiscoveryService; + const mockPrimaryDir = '/home/user/project'; + const mockSecondaryDir = '/home/user/other-project'; + + beforeEach(() => { + vi.resetAllMocks(); + + // Mock WorkspaceContext + mockWorkspaceContext = { + getDirectories: vi + .fn() + .mockReturnValue([mockPrimaryDir, mockSecondaryDir]), + isPathWithinWorkspace: vi + .fn() + .mockImplementation( + (path) => + path.startsWith(mockPrimaryDir) || + path.startsWith(mockSecondaryDir), + ), + addDirectory: vi.fn(), + } as unknown as WorkspaceContext; + + // Mock FileService + mockFileService = { + shouldGitIgnoreFile: vi.fn().mockReturnValue(false), + shouldGeminiIgnoreFile: vi.fn().mockReturnValue(false), + } as unknown as FileDiscoveryService; + + // Mock Config + mockConfig = { + getTargetDir: vi.fn().mockReturnValue(mockPrimaryDir), + getWorkspaceContext: vi.fn().mockReturnValue(mockWorkspaceContext), + getFileService: vi.fn().mockReturnValue(mockFileService), + getFileFilteringOptions: vi.fn().mockReturnValue({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + } as unknown as Config; + + lsTool = new LSTool(mockConfig); + }); + + describe('parameter validation', () => { + it('should accept valid absolute paths within workspace', () => { + const params = { + path: '/home/user/project/src', + }; + + const error = lsTool.validateToolParams(params); + expect(error).toBeNull(); + }); + + it('should reject relative paths', () => { + const params = { + path: './src', + }; + + const error = lsTool.validateToolParams(params); + expect(error).toBe('Path must be absolute: ./src'); + }); + + it('should reject paths outside workspace with clear error message', () => { + const params = { + path: '/etc/passwd', + }; + + const error = lsTool.validateToolParams(params); + expect(error).toBe( + 'Path must be within one of the workspace directories: /home/user/project, /home/user/other-project', + ); + }); + + it('should accept paths in secondary workspace directory', () => { + const params = { + path: '/home/user/other-project/lib', + }; + + const error = lsTool.validateToolParams(params); + expect(error).toBeNull(); + }); + }); + + describe('execute', () => { + it('should list files in a directory', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['file1.ts', 'file2.ts', 'subdir']; + const mockStats = { + isDirectory: vi.fn(), + mtime: new Date(), + size: 1024, + }; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + const pathStr = path.toString(); + if (pathStr === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + // For individual files + if (pathStr.toString().endsWith('subdir')) { + return { ...mockStats, isDirectory: () => true, size: 0 } as fs.Stats; + } + return { ...mockStats, isDirectory: () => false } as fs.Stats; + }); + + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('[DIR] subdir'); + expect(result.llmContent).toContain('file1.ts'); + expect(result.llmContent).toContain('file2.ts'); + expect(result.returnDisplay).toBe('Listed 3 item(s).'); + }); + + it('should list files from secondary workspace directory', async () => { + const testPath = '/home/user/other-project/lib'; + const mockFiles = ['module1.js', 'module2.js']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + if (path.toString() === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 2048, + } as fs.Stats; + }); + + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('module1.js'); + expect(result.llmContent).toContain('module2.js'); + expect(result.returnDisplay).toBe('Listed 2 item(s).'); + }); + + it('should handle empty directories', async () => { + const testPath = '/home/user/project/empty'; + + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fs.readdirSync).mockReturnValue([]); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toBe( + 'Directory /home/user/project/empty is empty.', + ); + expect(result.returnDisplay).toBe('Directory is empty.'); + }); + + it('should respect ignore patterns', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['test.js', 'test.spec.js', 'index.js']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + const pathStr = path.toString(); + if (pathStr === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 1024, + } as fs.Stats; + }); + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + const result = await lsTool.execute( + { path: testPath, ignore: ['*.spec.js'] }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('test.js'); + expect(result.llmContent).toContain('index.js'); + expect(result.llmContent).not.toContain('test.spec.js'); + expect(result.returnDisplay).toBe('Listed 2 item(s).'); + }); + + it('should respect gitignore patterns', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['file1.js', 'file2.js', 'ignored.js']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + const pathStr = path.toString(); + if (pathStr === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 1024, + } as fs.Stats; + }); + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + (mockFileService.shouldGitIgnoreFile as any).mockImplementation( + (path: string) => path.includes('ignored.js'), + ); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('file1.js'); + expect(result.llmContent).toContain('file2.js'); + expect(result.llmContent).not.toContain('ignored.js'); + expect(result.returnDisplay).toBe('Listed 2 item(s). (1 git-ignored)'); + }); + + it('should respect geminiignore patterns', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['file1.js', 'file2.js', 'private.js']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + const pathStr = path.toString(); + if (pathStr === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 1024, + } as fs.Stats; + }); + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + (mockFileService.shouldGeminiIgnoreFile as any).mockImplementation( + (path: string) => path.includes('private.js'), + ); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('file1.js'); + expect(result.llmContent).toContain('file2.js'); + expect(result.llmContent).not.toContain('private.js'); + expect(result.returnDisplay).toBe('Listed 2 item(s). (1 gemini-ignored)'); + }); + + it('should handle non-directory paths', async () => { + const testPath = '/home/user/project/file.txt'; + + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + } as fs.Stats); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('Path is not a directory'); + expect(result.returnDisplay).toBe('Error: Path is not a directory.'); + }); + + it('should handle non-existent paths', async () => { + const testPath = '/home/user/project/does-not-exist'; + + vi.mocked(fs.statSync).mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('Error listing directory'); + expect(result.returnDisplay).toBe('Error: Failed to list directory.'); + }); + + it('should sort directories first, then files alphabetically', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['z-file.ts', 'a-dir', 'b-file.ts', 'c-dir']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + if (path.toString() === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + if (path.toString().endsWith('-dir')) { + return { + isDirectory: () => true, + mtime: new Date(), + size: 0, + } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 1024, + } as fs.Stats; + }); + + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + const lines = ( + typeof result.llmContent === 'string' ? result.llmContent : '' + ).split('\n'); + const entries = lines.slice(1).filter((line: string) => line.trim()); // Skip header + expect(entries[0]).toBe('[DIR] a-dir'); + expect(entries[1]).toBe('[DIR] c-dir'); + expect(entries[2]).toBe('b-file.ts'); + expect(entries[3]).toBe('z-file.ts'); + }); + + it('should handle permission errors gracefully', async () => { + const testPath = '/home/user/project/restricted'; + + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fs.readdirSync).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('Error listing directory'); + expect(result.llmContent).toContain('permission denied'); + expect(result.returnDisplay).toBe('Error: Failed to list directory.'); + }); + + it('should validate parameters and return error for invalid params', async () => { + const result = await lsTool.execute( + { path: '../outside' }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('Invalid parameters provided'); + expect(result.returnDisplay).toBe('Error: Failed to execute tool.'); + }); + + it('should handle errors accessing individual files during listing', async () => { + const testPath = '/home/user/project/src'; + const mockFiles = ['accessible.ts', 'inaccessible.ts']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + if (path.toString() === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + if (path.toString().endsWith('inaccessible.ts')) { + throw new Error('EACCES: permission denied'); + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 1024, + } as fs.Stats; + }); + + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + // Spy on console.error to verify it's called + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + // Should still list the accessible file + expect(result.llmContent).toContain('accessible.ts'); + expect(result.llmContent).not.toContain('inaccessible.ts'); + expect(result.returnDisplay).toBe('Listed 1 item(s).'); + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error accessing'), + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getDescription', () => { + it('should return shortened relative path', () => { + const params = { + path: path.join(mockPrimaryDir, 'deeply', 'nested', 'directory'), + }; + + const description = lsTool.getDescription(params); + expect(description).toBe(path.join('deeply', 'nested', 'directory')); + }); + + it('should handle paths in secondary workspace', () => { + const params = { + path: path.join(mockSecondaryDir, 'lib'), + }; + + const description = lsTool.getDescription(params); + expect(description).toBe(path.join('..', 'other-project', 'lib')); + }); + }); + + describe('workspace boundary validation', () => { + it('should accept paths in primary workspace directory', () => { + const params = { path: `${mockPrimaryDir}/src` }; + expect(lsTool.validateToolParams(params)).toBeNull(); + }); + + it('should accept paths in secondary workspace directory', () => { + const params = { path: `${mockSecondaryDir}/lib` }; + expect(lsTool.validateToolParams(params)).toBeNull(); + }); + + it('should reject paths outside all workspace directories', () => { + const params = { path: '/etc/passwd' }; + const error = lsTool.validateToolParams(params); + expect(error).toContain( + 'Path must be within one of the workspace directories', + ); + expect(error).toContain(mockPrimaryDir); + expect(error).toContain(mockSecondaryDir); + }); + + it('should list files from secondary workspace directory', async () => { + const testPath = `${mockSecondaryDir}/tests`; + const mockFiles = ['test1.spec.ts', 'test2.spec.ts']; + + vi.mocked(fs.statSync).mockImplementation((path: any) => { + if (path.toString() === testPath) { + return { isDirectory: () => true } as fs.Stats; + } + return { + isDirectory: () => false, + mtime: new Date(), + size: 512, + } as fs.Stats; + }); + + vi.mocked(fs.readdirSync).mockReturnValue(mockFiles as any); + + const result = await lsTool.execute( + { path: testPath }, + new AbortController().signal, + ); + + expect(result.llmContent).toContain('test1.spec.ts'); + expect(result.llmContent).toContain('test2.spec.ts'); + expect(result.returnDisplay).toBe('Listed 2 item(s).'); + }); + }); +}); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 68a69101..8490f18a 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -11,7 +11,6 @@ import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js'; -import { isWithinRoot } from '../utils/fileUtils.js'; /** * Parameters for the LS tool @@ -129,8 +128,11 @@ export class LSTool extends BaseTool { if (!path.isAbsolute(params.path)) { return `Path must be absolute: ${params.path}`; } - if (!isWithinRoot(params.path, this.config.getTargetDir())) { - return `Path must be within the root directory (${this.config.getTargetDir()}): ${params.path}`; + + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(params.path)) { + const directories = workspaceContext.getDirectories(); + return `Path must be within one of the workspace directories: ${directories.join(', ')}`; } return null; } diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index e06c353a..f4086a2b 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -12,6 +12,7 @@ import fs from 'fs'; import fsp from 'fs/promises'; import { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; describe('ReadFileTool', () => { let tempRootDir: string; @@ -27,6 +28,7 @@ describe('ReadFileTool', () => { const mockConfigInstance = { getFileService: () => new FileDiscoveryService(tempRootDir), getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), } as unknown as Config; tool = new ReadFileTool(mockConfigInstance); }); @@ -65,8 +67,9 @@ describe('ReadFileTool', () => { it('should return error for path outside root', () => { const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt'); const params: ReadFileToolParams = { absolute_path: outsidePath }; - expect(tool.validateToolParams(params)).toMatch( - /File path must be within the root directory/, + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', ); }); @@ -261,4 +264,36 @@ describe('ReadFileTool', () => { }); }); }); + + describe('workspace boundary validation', () => { + it('should validate paths are within workspace root', () => { + const params: ReadFileToolParams = { + absolute_path: path.join(tempRootDir, 'file.txt'), + }; + expect(tool.validateToolParams(params)).toBeNull(); + }); + + it('should reject paths outside workspace root', () => { + const params: ReadFileToolParams = { + absolute_path: '/etc/passwd', + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + expect(error).toContain(tempRootDir); + }); + + it('should provide clear error message with workspace directories', () => { + const outsidePath = path.join(os.tmpdir(), 'outside-workspace.txt'); + const params: ReadFileToolParams = { + absolute_path: outsidePath, + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + expect(error).toContain(tempRootDir); + }); + }); }); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 9ba80672..31282c20 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -10,7 +10,6 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import { BaseTool, Icon, ToolLocation, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { - isWithinRoot, processSingleFileContent, getSpecificMimeType, } from '../utils/fileUtils.js'; @@ -86,8 +85,11 @@ export class ReadFileTool extends BaseTool { if (!path.isAbsolute(filePath)) { return `File path must be absolute, but was relative: ${filePath}. You must provide an absolute path.`; } - if (!isWithinRoot(filePath, this.config.getTargetDir())) { - return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`; + + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(filePath)) { + const directories = workspaceContext.getDirectories(); + return `File path must be within one of the workspace directories: ${directories.join(', ')}`; } if (params.offset !== undefined && params.offset < 0) { return 'Offset must be a non-negative number'; @@ -145,7 +147,7 @@ export class ReadFileTool extends BaseTool { if (result.error) { return { llmContent: result.error, // The detailed error for LLM - returnDisplay: result.returnDisplay, // User-friendly error + returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error }; } @@ -163,8 +165,8 @@ export class ReadFileTool extends BaseTool { ); return { - llmContent: result.llmContent, - returnDisplay: result.returnDisplay, + llmContent: result.llmContent || '', + returnDisplay: result.returnDisplay || '', }; } } diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 641aa705..68bb9b0e 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -13,6 +13,7 @@ import path from 'path'; import fs from 'fs'; // Actual fs for setup import os from 'os'; import { Config } from '../config/config.js'; +import { WorkspaceContext } from '../utils/workspaceContext.js'; vi.mock('mime-types', () => { const lookup = (filename: string) => { @@ -48,11 +49,11 @@ describe('ReadManyFilesTool', () => { let mockReadFileFn: Mock; beforeEach(async () => { - tempRootDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'read-many-files-root-'), + tempRootDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'read-many-files-root-')), ); - tempDirOutsideRoot = fs.mkdtempSync( - path.join(os.tmpdir(), 'read-many-files-external-'), + tempDirOutsideRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'read-many-files-external-')), ); fs.writeFileSync(path.join(tempRootDir, '.geminiignore'), 'foo.*'); const fileService = new FileDiscoveryService(tempRootDir); @@ -64,6 +65,8 @@ describe('ReadManyFilesTool', () => { respectGeminiIgnore: true, }), getTargetDir: () => tempRootDir, + getWorkspaceDirs: () => [tempRootDir], + getWorkspaceContext: () => new WorkspaceContext(tempRootDir), } as Partial as Config; tool = new ReadManyFilesTool(mockConfig); @@ -424,5 +427,54 @@ describe('ReadManyFilesTool', () => { expect(result.returnDisplay).not.toContain('foo.quux'); expect(result.returnDisplay).toContain('bar.ts'); }); + + it('should read files from multiple workspace directories', async () => { + const tempDir1 = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'multi-dir-1-')), + ); + const tempDir2 = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'multi-dir-2-')), + ); + const fileService = new FileDiscoveryService(tempDir1); + const mockConfig = { + getFileService: () => fileService, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getWorkspaceContext: () => new WorkspaceContext(tempDir1, [tempDir2]), + getTargetDir: () => tempDir1, + } as Partial as Config; + tool = new ReadManyFilesTool(mockConfig); + + fs.writeFileSync(path.join(tempDir1, 'file1.txt'), 'Content1'); + fs.writeFileSync(path.join(tempDir2, 'file2.txt'), 'Content2'); + + const params = { paths: ['*.txt'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + if (!Array.isArray(content)) { + throw new Error(`llmContent is not an array: ${content}`); + } + const expectedPath1 = path.join(tempDir1, 'file1.txt'); + const expectedPath2 = path.join(tempDir2, 'file2.txt'); + + expect( + content.some((c) => + c.includes(`--- ${expectedPath1} ---\n\nContent1\n\n`), + ), + ).toBe(true); + expect( + content.some((c) => + c.includes(`--- ${expectedPath2} ---\n\nContent2\n\n`), + ), + ).toBe(true); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **2 file(s)**', + ); + + fs.rmSync(tempDir1, { recursive: true, force: true }); + fs.rmSync(tempDir2, { recursive: true, force: true }); + }); }); }); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index fb160a79..771577ec 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -302,18 +302,27 @@ Use this tool when the user's query implies needing the content of several files } try { - const entries = await glob( - searchPatterns.map((p) => p.replace(/\\/g, '/')), - { - cwd: this.config.getTargetDir(), - ignore: effectiveExcludes, - nodir: true, - dot: true, - absolute: true, - nocase: true, - signal, - }, - ); + const allEntries = new Set(); + const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); + + for (const dir of workspaceDirs) { + const entriesInDir = await glob( + searchPatterns.map((p) => p.replace(/\\/g, '/')), + { + cwd: dir, + ignore: effectiveExcludes, + nodir: true, + dot: true, + absolute: true, + nocase: true, + signal, + }, + ); + for (const entry of entriesInDir) { + allEntries.add(entry); + } + } + const entries = Array.from(allEntries); const gitFilteredEntries = fileFilteringOptions.respectGitIgnore ? fileDiscovery @@ -346,11 +355,15 @@ Use this tool when the user's query implies needing the content of several files let geminiIgnoredCount = 0; for (const absoluteFilePath of entries) { - // Security check: ensure the glob library didn't return something outside targetDir. - if (!absoluteFilePath.startsWith(this.config.getTargetDir())) { + // Security check: ensure the glob library didn't return something outside the workspace. + if ( + !this.config + .getWorkspaceContext() + .isPathWithinWorkspace(absoluteFilePath) + ) { skippedFiles.push({ path: absoluteFilePath, - reason: `Security: Glob library returned path outside target directory. Base: ${this.config.getTargetDir()}, Path: ${absoluteFilePath}`, + reason: `Security: Glob library returned path outside workspace. Path: ${absoluteFilePath}`, }); continue; } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 55364197..7f237e3d 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -37,6 +37,7 @@ import * as crypto from 'crypto'; import * as summarizer from '../utils/summarizer.js'; import { ToolConfirmationOutcome } from './tools.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; describe('ShellTool', () => { let shellTool: ShellTool; @@ -53,6 +54,7 @@ describe('ShellTool', () => { getDebugMode: vi.fn().mockReturnValue(false), getTargetDir: vi.fn().mockReturnValue('/test/dir'), getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), + getWorkspaceContext: () => createMockWorkspaceContext('.'), getGeminiClient: vi.fn(), } as unknown as Config; @@ -105,7 +107,7 @@ describe('ShellTool', () => { vi.mocked(fs.existsSync).mockReturnValue(false); expect( shellTool.validateToolParams({ command: 'ls', directory: 'rel/path' }), - ).toBe('Directory must exist.'); + ).toBe("Directory 'rel/path' is not a registered workspace directory."); }); }); @@ -385,3 +387,37 @@ describe('ShellTool', () => { }); }); }); + +describe('validateToolParams', () => { + it('should return null for valid directory', () => { + const config = { + getCoreTools: () => undefined, + getExcludeTools: () => undefined, + getTargetDir: () => '/root', + getWorkspaceContext: () => + createMockWorkspaceContext('/root', ['/users/test']), + } as unknown as Config; + const shellTool = new ShellTool(config); + const result = shellTool.validateToolParams({ + command: 'ls', + directory: 'test', + }); + expect(result).toBeNull(); + }); + + it('should return error for directory outside workspace', () => { + const config = { + getCoreTools: () => undefined, + getExcludeTools: () => undefined, + getTargetDir: () => '/root', + getWorkspaceContext: () => + createMockWorkspaceContext('/root', ['/users/test']), + } as unknown as Config; + const shellTool = new ShellTool(config); + const result = shellTool.validateToolParams({ + command: 'ls', + directory: 'test2', + }); + expect(result).toContain('is not a registered workspace directory'); + }); +}); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 02fcbb7f..96423af1 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -124,14 +124,19 @@ export class ShellTool extends BaseTool { } if (params.directory) { if (path.isAbsolute(params.directory)) { - return 'Directory cannot be absolute. Must be relative to the project root directory.'; + return 'Directory cannot be absolute. Please refer to workspace directories by their name.'; } - const directory = path.resolve( - this.config.getTargetDir(), - params.directory, + const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); + const matchingDirs = workspaceDirs.filter( + (dir) => path.basename(dir) === params.directory, ); - if (!fs.existsSync(directory)) { - return 'Directory must exist.'; + + if (matchingDirs.length === 0) { + return `Directory '${params.directory}' is not a registered workspace directory.`; + } + + if (matchingDirs.length > 1) { + return `Directory name '${params.directory}' is ambiguous as it matches multiple workspace directories.`; } } return null; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index b3fdd7a3..27a7c28b 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -30,6 +30,9 @@ import { Schema, } from '@google/genai'; import { spawn } from 'node:child_process'; +import fs from 'node:fs'; + +vi.mock('node:fs'); // Use vi.hoisted to define the mock function so it can be used in the vi.mock factory const mockDiscoverMcpTools = vi.hoisted(() => vi.fn()); @@ -144,6 +147,10 @@ describe('ToolRegistry', () => { let mockConfigGetToolDiscoveryCommand: ReturnType; beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); config = new Config(baseConfigParams); toolRegistry = new ToolRegistry(config); vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index c33b5fa2..965e9476 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -31,6 +31,7 @@ import { ensureCorrectFileContent, CorrectedEditResult, } from '../utils/editCorrector.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root'); @@ -54,6 +55,7 @@ const mockConfigInternal = { getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), getGeminiClient: vi.fn(), // Initialize as a plain mock function + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), getApiKey: () => 'test-key', getModel: () => 'test-model', getSandbox: () => false, @@ -83,6 +85,7 @@ describe('WriteFileTool', () => { let tempDir: string; beforeEach(() => { + vi.clearAllMocks(); // Create a unique temporary directory for files created outside the root tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'write-file-test-external-'), @@ -98,6 +101,11 @@ describe('WriteFileTool', () => { ) as Mocked; vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance); + vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit); + vi.mocked(ensureCorrectFileContent).mockImplementation( + mockEnsureCorrectFileContent, + ); + // Now that mockGeminiClientInstance is initialized, set the mock implementation for getGeminiClient mockConfigInternal.getGeminiClient.mockReturnValue( mockGeminiClientInstance, @@ -177,8 +185,9 @@ describe('WriteFileTool', () => { file_path: outsidePath, content: 'hello', }; - expect(tool.validateToolParams(params)).toMatch( - /File path must be within the root directory/, + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', ); }); @@ -427,8 +436,8 @@ describe('WriteFileTool', () => { const params = { file_path: outsidePath, content: 'test' }; const result = await tool.execute(params, abortSignal); expect(result.llmContent).toMatch(/Error: Invalid parameters provided/); - expect(result.returnDisplay).toMatch( - /Error: File path must be within the root directory/, + expect(result.returnDisplay).toContain( + 'Error: File path must be within one of the workspace directories', ); }); @@ -616,4 +625,39 @@ describe('WriteFileTool', () => { expect(result.llmContent).not.toMatch(/User modified the `content`/); }); }); + + describe('workspace boundary validation', () => { + it('should validate paths are within workspace root', () => { + const params = { + file_path: path.join(rootDir, 'file.txt'), + content: 'test content', + }; + expect(tool.validateToolParams(params)).toBeNull(); + }); + + it('should reject paths outside workspace root', () => { + const params = { + file_path: '/etc/passwd', + content: 'malicious', + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + expect(error).toContain(rootDir); + }); + + it('should provide clear error message with workspace directories', () => { + const outsidePath = path.join(tempDir, 'outside-root.txt'); + const params = { + file_path: outsidePath, + content: 'test', + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + expect(error).toContain(rootDir); + }); + }); }); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index ae37ca8a..470adf31 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -27,7 +27,7 @@ import { } from '../utils/editCorrector.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; -import { getSpecificMimeType, isWithinRoot } from '../utils/fileUtils.js'; +import { getSpecificMimeType } from '../utils/fileUtils.js'; import { recordFileOperationMetric, FileOperation, @@ -105,8 +105,11 @@ export class WriteFileTool if (!path.isAbsolute(filePath)) { return `File path must be absolute: ${filePath}`; } - if (!isWithinRoot(filePath, this.config.getTargetDir())) { - return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`; + + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(filePath)) { + const directories = workspaceContext.getDirectories(); + return `File path must be within one of the workspace directories: ${directories.join(', ')}`; } try { diff --git a/packages/core/src/utils/flashFallback.integration.test.ts b/packages/core/src/utils/flashFallback.integration.test.ts index f5e354a0..9211ad2f 100644 --- a/packages/core/src/utils/flashFallback.integration.test.ts +++ b/packages/core/src/utils/flashFallback.integration.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from '../config/config.js'; +import fs from 'node:fs'; import { setSimulate429, disableSimulationAfterFallback, @@ -17,10 +18,16 @@ import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { retryWithBackoff } from './retry.js'; import { AuthType } from '../core/contentGenerator.js'; +vi.mock('node:fs'); + describe('Flash Fallback Integration', () => { let config: Config; beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); config = new Config({ sessionId: 'test-session', targetDir: '/test', diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts new file mode 100644 index 00000000..67d06b62 --- /dev/null +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WorkspaceContext } from './workspaceContext.js'; + +vi.mock('fs'); + +describe('WorkspaceContext', () => { + let workspaceContext: WorkspaceContext; + // Use path module to create platform-agnostic paths + const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); + const mockExistingDir = path.resolve( + path.sep, + 'home', + 'user', + 'other-project', + ); + const mockNonExistentDir = path.resolve( + path.sep, + 'home', + 'user', + 'does-not-exist', + ); + const mockSymlinkDir = path.resolve(path.sep, 'home', 'user', 'symlink'); + const mockRealPath = path.resolve(path.sep, 'home', 'user', 'real-directory'); + + beforeEach(() => { + vi.resetAllMocks(); + + // Mock fs.existsSync + vi.mocked(fs.existsSync).mockImplementation((path) => { + const pathStr = path.toString(); + return ( + pathStr === mockCwd || + pathStr === mockExistingDir || + pathStr === mockSymlinkDir || + pathStr === mockRealPath + ); + }); + + // Mock fs.statSync + vi.mocked(fs.statSync).mockImplementation((path) => { + const pathStr = path.toString(); + if (pathStr === mockNonExistentDir) { + throw new Error('ENOENT'); + } + return { + isDirectory: () => true, + } as fs.Stats; + }); + + // Mock fs.realpathSync + vi.mocked(fs.realpathSync).mockImplementation((path) => { + const pathStr = path.toString(); + if (pathStr === mockSymlinkDir) { + return mockRealPath; + } + return pathStr; + }); + }); + + describe('initialization', () => { + it('should initialize with a single directory (cwd)', () => { + workspaceContext = new WorkspaceContext(mockCwd); + const directories = workspaceContext.getDirectories(); + expect(directories).toHaveLength(1); + expect(directories[0]).toBe(mockCwd); + }); + + it('should validate and resolve directories to absolute paths', () => { + const absolutePath = path.join(mockCwd, 'subdir'); + vi.mocked(fs.existsSync).mockImplementation( + (p) => p === mockCwd || p === absolutePath, + ); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); + + workspaceContext = new WorkspaceContext(mockCwd, [absolutePath]); + const directories = workspaceContext.getDirectories(); + expect(directories).toContain(absolutePath); + }); + + it('should reject non-existent directories', () => { + expect(() => { + new WorkspaceContext(mockCwd, [mockNonExistentDir]); + }).toThrow('Directory does not exist'); + }); + + it('should handle empty initialization', () => { + workspaceContext = new WorkspaceContext(mockCwd, []); + const directories = workspaceContext.getDirectories(); + expect(directories).toHaveLength(1); + expect(directories[0]).toBe(mockCwd); + }); + }); + + describe('adding directories', () => { + beforeEach(() => { + workspaceContext = new WorkspaceContext(mockCwd); + }); + + it('should add valid directories', () => { + workspaceContext.addDirectory(mockExistingDir); + const directories = workspaceContext.getDirectories(); + expect(directories).toHaveLength(2); + expect(directories).toContain(mockExistingDir); + }); + + it('should resolve relative paths to absolute', () => { + // Since we can't mock path.resolve, we'll test with absolute paths + workspaceContext.addDirectory(mockExistingDir); + const directories = workspaceContext.getDirectories(); + expect(directories).toContain(mockExistingDir); + }); + + it('should reject non-existent directories', () => { + expect(() => { + workspaceContext.addDirectory(mockNonExistentDir); + }).toThrow('Directory does not exist'); + }); + + it('should prevent duplicate directories', () => { + workspaceContext.addDirectory(mockExistingDir); + workspaceContext.addDirectory(mockExistingDir); + const directories = workspaceContext.getDirectories(); + expect(directories.filter((d) => d === mockExistingDir)).toHaveLength(1); + }); + + it('should handle symbolic links correctly', () => { + workspaceContext.addDirectory(mockSymlinkDir); + const directories = workspaceContext.getDirectories(); + expect(directories).toContain(mockRealPath); + expect(directories).not.toContain(mockSymlinkDir); + }); + }); + + describe('path validation', () => { + beforeEach(() => { + workspaceContext = new WorkspaceContext(mockCwd, [mockExistingDir]); + }); + + it('should accept paths within workspace directories', () => { + const validPath1 = path.join(mockCwd, 'src', 'file.ts'); + const validPath2 = path.join(mockExistingDir, 'lib', 'module.js'); + + expect(workspaceContext.isPathWithinWorkspace(validPath1)).toBe(true); + expect(workspaceContext.isPathWithinWorkspace(validPath2)).toBe(true); + }); + + it('should reject paths outside workspace', () => { + const invalidPath = path.resolve( + path.dirname(mockCwd), + 'outside-workspace', + 'file.txt', + ); + expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false); + }); + + it('should resolve symbolic links before validation', () => { + const symlinkPath = path.join(mockCwd, 'symlink-file'); + const realPath = path.join(mockCwd, 'real-file'); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === symlinkPath) { + return realPath; + } + return p.toString(); + }); + + expect(workspaceContext.isPathWithinWorkspace(symlinkPath)).toBe(true); + }); + + it('should handle nested directories correctly', () => { + const nestedPath = path.join( + mockCwd, + 'deeply', + 'nested', + 'path', + 'file.txt', + ); + expect(workspaceContext.isPathWithinWorkspace(nestedPath)).toBe(true); + }); + + it('should handle edge cases (root, parent references)', () => { + const rootPath = '/'; + const parentPath = path.dirname(mockCwd); + + expect(workspaceContext.isPathWithinWorkspace(rootPath)).toBe(false); + expect(workspaceContext.isPathWithinWorkspace(parentPath)).toBe(false); + }); + + it('should handle non-existent paths correctly', () => { + const nonExistentPath = path.join(mockCwd, 'does-not-exist.txt'); + vi.mocked(fs.existsSync).mockImplementation((p) => p !== nonExistentPath); + + // Should still validate based on path structure + expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe( + true, + ); + }); + }); + + describe('getDirectories', () => { + it('should return a copy of directories array', () => { + workspaceContext = new WorkspaceContext(mockCwd); + const dirs1 = workspaceContext.getDirectories(); + const dirs2 = workspaceContext.getDirectories(); + + expect(dirs1).not.toBe(dirs2); // Different array instances + expect(dirs1).toEqual(dirs2); // Same content + }); + }); + + describe('symbolic link security', () => { + beforeEach(() => { + workspaceContext = new WorkspaceContext(mockCwd); + }); + + it('should follow symlinks but validate resolved path', () => { + const symlinkInsideWorkspace = path.join(mockCwd, 'link-to-subdir'); + const resolvedInsideWorkspace = path.join(mockCwd, 'subdir'); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === symlinkInsideWorkspace) { + return resolvedInsideWorkspace; + } + return p.toString(); + }); + + expect( + workspaceContext.isPathWithinWorkspace(symlinkInsideWorkspace), + ).toBe(true); + }); + + it('should prevent sandbox escape via symlinks', () => { + const symlinkEscape = path.join(mockCwd, 'escape-link'); + const resolvedOutside = path.resolve(mockCwd, '..', 'outside-file'); + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p.toString(); + return ( + pathStr === symlinkEscape || + pathStr === resolvedOutside || + pathStr === mockCwd + ); + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p.toString() === symlinkEscape) { + return resolvedOutside; + } + return p.toString(); + }); + vi.mocked(fs.statSync).mockImplementation( + (p) => + ({ + isDirectory: () => p.toString() !== resolvedOutside, + }) as fs.Stats, + ); + + workspaceContext = new WorkspaceContext(mockCwd); + expect(workspaceContext.isPathWithinWorkspace(symlinkEscape)).toBe(false); + }); + + it('should handle circular symlinks', () => { + const circularLink = path.join(mockCwd, 'circular'); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.realpathSync).mockImplementation(() => { + throw new Error('ELOOP: too many symbolic links encountered'); + }); + + // Should handle the error gracefully + expect(workspaceContext.isPathWithinWorkspace(circularLink)).toBe(false); + }); + }); +}); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts new file mode 100644 index 00000000..16d1b4c9 --- /dev/null +++ b/packages/core/src/utils/workspaceContext.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * WorkspaceContext manages multiple workspace directories and validates paths + * against them. This allows the CLI to operate on files from multiple directories + * in a single session. + */ +export class WorkspaceContext { + private directories: Set; + + /** + * Creates a new WorkspaceContext with the given initial directory and optional additional directories. + * @param initialDirectory The initial working directory (usually cwd) + * @param additionalDirectories Optional array of additional directories to include + */ + constructor(initialDirectory: string, additionalDirectories: string[] = []) { + this.directories = new Set(); + + this.addDirectoryInternal(initialDirectory); + + for (const dir of additionalDirectories) { + this.addDirectoryInternal(dir); + } + } + + /** + * Adds a directory to the workspace. + * @param directory The directory path to add (can be relative or absolute) + * @param basePath Optional base path for resolving relative paths (defaults to cwd) + */ + addDirectory(directory: string, basePath: string = process.cwd()): void { + this.addDirectoryInternal(directory, basePath); + } + + /** + * Internal method to add a directory with validation. + */ + private addDirectoryInternal( + directory: string, + basePath: string = process.cwd(), + ): void { + const absolutePath = path.isAbsolute(directory) + ? directory + : path.resolve(basePath, directory); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Directory does not exist: ${absolutePath}`); + } + + const stats = fs.statSync(absolutePath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${absolutePath}`); + } + + let realPath: string; + try { + realPath = fs.realpathSync(absolutePath); + } catch (_error) { + throw new Error(`Failed to resolve path: ${absolutePath}`); + } + + this.directories.add(realPath); + } + + /** + * Gets a copy of all workspace directories. + * @returns Array of absolute directory paths + */ + getDirectories(): readonly string[] { + return Array.from(this.directories); + } + + /** + * Checks if a given path is within any of the workspace directories. + * @param pathToCheck The path to validate + * @returns True if the path is within the workspace, false otherwise + */ + isPathWithinWorkspace(pathToCheck: string): boolean { + try { + const absolutePath = path.resolve(pathToCheck); + + let resolvedPath = absolutePath; + if (fs.existsSync(absolutePath)) { + try { + resolvedPath = fs.realpathSync(absolutePath); + } catch (_error) { + return false; + } + } + + for (const dir of this.directories) { + if (this.isPathWithinRoot(resolvedPath, dir)) { + return true; + } + } + + return false; + } catch (_error) { + return false; + } + } + + /** + * Checks if a path is within a given root directory. + * @param pathToCheck The absolute path to check + * @param rootDirectory The absolute root directory + * @returns True if the path is within the root directory, false otherwise + */ + private isPathWithinRoot( + pathToCheck: string, + rootDirectory: string, + ): boolean { + const relative = path.relative(rootDirectory, pathToCheck); + return ( + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); + } +} From 7bc876654254d9a11d66135735ad10f1066ad213 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 30 Jul 2025 21:26:31 +0000 Subject: [PATCH 040/136] Introduce IDE mode installer (#4877) --- packages/cli/src/config/config.test.ts | 81 +-------- packages/cli/src/config/config.ts | 9 +- packages/cli/src/ui/App.tsx | 7 +- .../cli/src/ui/commands/ideCommand.test.ts | 148 +++++----------- packages/cli/src/ui/commands/ideCommand.ts | 105 +++--------- .../ui/components/IDEContextDetailDisplay.tsx | 9 +- .../ui/hooks/slashCommandProcessor.test.ts | 14 +- packages/core/src/config/config.test.ts | 2 + packages/core/src/config/config.ts | 6 +- .../core/src/config/flashFallback.test.ts | 4 + packages/core/src/ide/detect-ide.ts | 25 +++ packages/core/src/ide/ide-client.ts | 40 ++++- packages/core/src/ide/ide-installer.test.ts | 90 ++++++++++ packages/core/src/ide/ide-installer.ts | 162 ++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/telemetry/telemetry.test.ts | 2 + packages/core/src/tools/tool-registry.test.ts | 2 + .../utils/flashFallback.integration.test.ts | 2 + 18 files changed, 433 insertions(+), 277 deletions(-) create mode 100644 packages/core/src/ide/detect-ide.ts create mode 100644 packages/core/src/ide/ide-installer.test.ts create mode 100644 packages/core/src/ide/ide-installer.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 7f47660d..1dd09f4b 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -35,11 +35,13 @@ vi.mock('@google/gemini-cli-core', async () => { ); return { ...actualServer, - IdeClient: vi.fn().mockImplementation(() => ({ - getConnectionStatus: vi.fn(), - initialize: vi.fn(), - shutdown: vi.fn(), - })), + IdeClient: { + getInstance: vi.fn().mockReturnValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( (cwd, debug, fileService, extensionPaths, _maxDirs) => @@ -922,8 +924,6 @@ describe('loadCliConfig ideMode', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); process.env.GEMINI_API_KEY = 'test-api-key'; - // Explicitly delete TERM_PROGRAM and SANDBOX before each test - delete process.env.TERM_PROGRAM; delete process.env.SANDBOX; delete process.env.GEMINI_CLI_IDE_SERVER_PORT; }); @@ -942,72 +942,7 @@ describe('loadCliConfig ideMode', () => { expect(config.getIdeMode()).toBe(false); }); - it('should be false if --ide-mode is true but TERM_PROGRAM is not vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const settings: Settings = {}; - const argv = await parseArguments(); - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false if settings.ideMode is true but TERM_PROGRAM is not vscode', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be true when --ide-mode is set and TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should be true when settings.ideMode is true and TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should prioritize --ide-mode (true) over settings (false) when TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = { ideMode: false }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should prioritize --no-ide-mode (false) over settings (true) even when TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--no-ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false when --ide-mode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.SANDBOX = 'true'; - const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false when settings.ideMode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => { + it('should be false when settings.ideMode is true, but SANDBOX is set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); process.env.TERM_PROGRAM = 'vscode'; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1dd8519c..1cc78888 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -269,14 +269,9 @@ export async function loadCliConfig( ); const ideMode = - (argv.ideMode ?? settings.ideMode ?? false) && - process.env.TERM_PROGRAM === 'vscode' && - !process.env.SANDBOX; + (argv.ideMode ?? settings.ideMode ?? false) && !process.env.SANDBOX; - let ideClient: IdeClient | undefined; - if (ideMode) { - ideClient = new IdeClient(); - } + const ideClient = IdeClient.getInstance(ideMode); const allExtensions = annotateActiveExtensions( extensions, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 7ac6936c..2e899cc1 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -967,7 +967,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showIDEContextDetail && ( - + )} {showErrorDetails && ( diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index d1d72466..3c73f52c 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -15,24 +15,16 @@ import { } from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; -import { type Config } from '@google/gemini-cli-core'; -import * as child_process from 'child_process'; -import { glob } from 'glob'; - -import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; +import { type Config, DetectedIde } from '@google/gemini-cli-core'; +import * as core from '@google/gemini-cli-core'; vi.mock('child_process'); vi.mock('glob'); - -function regexEscape(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +vi.mock('@google/gemini-cli-core'); describe('ideCommand', () => { let mockContext: CommandContext; let mockConfig: Config; - let execSyncSpy: MockInstance; - let globSyncSpy: MockInstance; let platformSpy: MockInstance; beforeEach(() => { @@ -47,8 +39,6 @@ describe('ideCommand', () => { getIdeClient: vi.fn(), } as unknown as Config; - execSyncSpy = vi.spyOn(child_process, 'execSync'); - globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); }); @@ -64,6 +54,9 @@ describe('ideCommand', () => { it('should return the ide command if ideMode is enabled', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getCurrentIde: () => DetectedIde.VSCode, + } as ReturnType); const command = ideCommand(mockConfig); expect(command).not.toBeNull(); expect(command?.name).toBe('ide'); @@ -78,12 +71,13 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getConnectionStatus: mockGetConnectionStatus, + getCurrentIde: () => DetectedIde.VSCode, } as ReturnType); }); it('should show connected status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Connected, + status: core.IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -97,7 +91,7 @@ describe('ideCommand', () => { it('should show connecting status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Connecting, + status: core.IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -110,7 +104,7 @@ describe('ideCommand', () => { }); it('should show disconnected status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Disconnected, + status: core.IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -125,7 +119,7 @@ describe('ideCommand', () => { it('should show disconnected status with details', () => { const details = 'Something went wrong'; mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Disconnected, + status: core.IDEConnectionStatus.Disconnected, details, }); const command = ideCommand(mockConfig); @@ -140,128 +134,68 @@ describe('ideCommand', () => { }); describe('install subcommand', () => { + const mockInstall = vi.fn(); beforeEach(() => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getCurrentIde: () => DetectedIde.VSCode, + } as ReturnType); + vi.mocked(core.getIdeInstaller).mockReturnValue({ + install: mockInstall, + isInstalled: vi.fn(), + }); platformSpy.mockReturnValue('linux'); }); - it('should show an error if VSCode is not installed', async () => { - execSyncSpy.mockImplementation(() => { - throw new Error('Command not found'); + it('should install the extension', async () => { + mockInstall.mockResolvedValue({ + success: true, + message: 'Successfully installed.', }); - const command = ideCommand(mockConfig); - - await command!.subCommands![1].action!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - text: expect.stringMatching(/VS Code command-line tool .* not found/), - }), - expect.any(Number), - ); - }); - - it('should show an error if the VSIX file is not found', async () => { - execSyncSpy.mockReturnValue(''); // VSCode is installed - globSyncSpy.mockReturnValue([]); // No .vsix file found - const command = ideCommand(mockConfig); await command!.subCommands![1].action!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.', - }), - expect.any(Number), - ); - }); - - it('should install the extension if found in the bundle directory', async () => { - const vsixPath = '/path/to/bundle/gemini.vsix'; - execSyncSpy.mockReturnValue(''); // VSCode is installed - globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file - - const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); - - expect(globSyncSpy).toHaveBeenCalledWith( - expect.stringContaining('.vsix'), - ); - expect(execSyncSpy).toHaveBeenCalledWith( - expect.stringMatching( - new RegExp( - `code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`, - ), - ), - { stdio: 'pipe' }, - ); + expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); + expect(mockInstall).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: `Installing VS Code companion extension...`, + text: `Installing IDE companion extension...`, }), expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', - }), - expect.any(Number), - ); - }); - - it('should install the extension if found in the dev directory', async () => { - const vsixPath = '/path/to/dev/gemini.vsix'; - execSyncSpy.mockReturnValue(''); // VSCode is installed - // First glob call for bundle returns nothing, second for dev returns path. - globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]); - - const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); - - expect(globSyncSpy).toHaveBeenCalledTimes(2); - expect(execSyncSpy).toHaveBeenCalledWith( - expect.stringMatching( - new RegExp( - `code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`, - ), - ), - { stdio: 'pipe' }, - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', + text: 'Successfully installed.', }), expect.any(Number), ); }); it('should show an error if installation fails', async () => { - const vsixPath = '/path/to/bundle/gemini.vsix'; - const errorMessage = 'Installation failed'; - execSyncSpy - .mockReturnValueOnce('') // VSCode is installed check - .mockImplementation(() => { - // Installation command - const error: Error & { stderr?: Buffer } = new Error( - 'Command failed', - ); - error.stderr = Buffer.from(errorMessage); - throw error; - }); - globSyncSpy.mockReturnValue([vsixPath]); + mockInstall.mockResolvedValue({ + success: false, + message: 'Installation failed.', + }); const command = ideCommand(mockConfig); await command!.subCommands![1].action!(mockContext, ''); + expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); + expect(mockInstall).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: `Installing IDE companion extension...`, + }), + expect.any(Number), + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', - text: `Failed to install VS Code companion extension.`, + text: 'Installation failed.', }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 31f2371f..26b0f57d 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -4,40 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fileURLToPath } from 'url'; -import { Config, IDEConnectionStatus } from '@google/gemini-cli-core'; +import { + Config, + getIdeDisplayName, + getIdeInstaller, + IDEConnectionStatus, +} from '@google/gemini-cli-core'; import { CommandContext, SlashCommand, SlashCommandActionReturn, CommandKind, } from './types.js'; -import * as child_process from 'child_process'; -import * as process from 'process'; -import { glob } from 'glob'; -import * as path from 'path'; - -const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code'; -const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion'; - -function isVSCodeInstalled(): boolean { - try { - child_process.execSync( - process.platform === 'win32' - ? `where.exe ${VSCODE_COMMAND}` - : `command -v ${VSCODE_COMMAND}`, - { stdio: 'ignore' }, - ); - return true; - } catch { - return false; - } -} export const ideCommand = (config: Config | null): SlashCommand | null => { if (!config?.getIdeMode()) { return null; } + const currentIDE = config.getIdeClient().getCurrentIde(); + if (!currentIDE) { + throw new Error( + 'IDE slash command should not be available if not running in an IDE', + ); + } return { name: 'ide', @@ -49,7 +38,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = config.getIdeClient()?.getConnectionStatus(); + const connection = config.getIdeClient().getConnectionStatus(); switch (connection?.status) { case IDEConnectionStatus.Connected: return { @@ -79,77 +68,37 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { }, { name: 'install', - description: 'install required VS Code companion extension', + description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `, kind: CommandKind.BUILT_IN, action: async (context) => { - if (!isVSCodeInstalled()) { + const installer = getIdeInstaller(currentIDE); + if (!installer) { context.ui.addItem( { type: 'error', - text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`, + text: 'No installer available for your configured IDE.', }, Date.now(), ); return; } - const bundleDir = path.dirname(fileURLToPath(import.meta.url)); - // The VSIX file is copied to the bundle directory as part of the build. - let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix')); - if (vsixFiles.length === 0) { - // If the VSIX file is not in the bundle, it might be a dev - // environment running with `npm start`. Look for it in the original - // package location, relative to the bundle dir. - const devPath = path.join( - bundleDir, - '..', - '..', - '..', - '..', - '..', - VSCODE_COMPANION_EXTENSION_FOLDER, - '*.vsix', - ); - vsixFiles = glob.sync(devPath); - } - if (vsixFiles.length === 0) { - context.ui.addItem( - { - type: 'error', - text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.', - }, - Date.now(), - ); - return; - } - - const vsixPath = vsixFiles[0]; - const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`; context.ui.addItem( { type: 'info', - text: `Installing VS Code companion extension...`, + text: `Installing IDE companion extension...`, + }, + Date.now(), + ); + + const result = await installer.install(); + context.ui.addItem( + { + type: result.success ? 'info' : 'error', + text: result.message, }, Date.now(), ); - try { - child_process.execSync(command, { stdio: 'pipe' }); - context.ui.addItem( - { - type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', - }, - Date.now(), - ); - } catch (_error) { - context.ui.addItem( - { - type: 'error', - text: `Failed to install VS Code companion extension.`, - }, - Date.now(), - ); - } }, }, ], diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx index f535c40a..a1739227 100644 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx +++ b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx @@ -4,17 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Text } from 'ink'; import { type File, type IdeContext } from '@google/gemini-cli-core'; -import { Colors } from '../colors.js'; +import { Box, Text } from 'ink'; import path from 'node:path'; +import { Colors } from '../colors.js'; interface IDEContextDetailDisplayProps { ideContext: IdeContext | undefined; + detectedIdeDisplay: string | undefined; } export function IDEContextDetailDisplay({ ideContext, + detectedIdeDisplay, }: IDEContextDetailDisplayProps) { const openFiles = ideContext?.workspaceState?.openFiles; if (!openFiles || openFiles.length === 0) { @@ -30,7 +32,8 @@ export function IDEContextDetailDisplay({ paddingX={1} > - IDE Context (ctrl+e to toggle) + {detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to + toggle) {openFiles.length > 0 && ( diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 30a14815..2dc206d7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -16,6 +16,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...original, logSlashCommand, SlashCommandEvent, + getIdeInstaller: vi.fn().mockReturnValue(null), }; }); @@ -23,11 +24,16 @@ const { mockProcessExit } = vi.hoisted(() => ({ mockProcessExit: vi.fn((_code?: number): never => undefined as never), })); -vi.mock('node:process', () => ({ - default: { +vi.mock('node:process', () => { + const mockProcess = { exit: mockProcessExit, - }, -})); + platform: 'test-platform', + }; + return { + ...mockProcess, + default: mockProcess, + }; +}); const mockBuiltinLoadCommands = vi.fn(); vi.mock('../../services/BuiltinCommandLoader.js', () => ({ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index dcc81b4f..165d2882 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -18,6 +18,7 @@ import { } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -119,6 +120,7 @@ describe('Server Config (config.ts)', () => { telemetry: TELEMETRY_SETTINGS, sessionId: SESSION_ID, model: MODEL, + ideClient: IdeClient.getInstance(false), }; beforeEach(() => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d8bce341..e62e2962 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -185,7 +185,7 @@ export interface ConfigParameters { noBrowser?: boolean; summarizeToolOutput?: Record; ideMode?: boolean; - ideClient?: IdeClient; + ideClient: IdeClient; } export class Config { @@ -229,7 +229,7 @@ export class Config { private readonly extensionContextFilePaths: string[]; private readonly noBrowser: boolean; private readonly ideMode: boolean; - private readonly ideClient: IdeClient | undefined; + private readonly ideClient: IdeClient; private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; @@ -593,7 +593,7 @@ export class Config { return this.ideMode; } - getIdeClient(): IdeClient | undefined { + getIdeClient(): IdeClient { return this.ideClient; } diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index a0034ea1..0b68f993 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from './config.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; +import { IdeClient } from '../ide/ide-client.js'; import fs from 'node:fs'; vi.mock('node:fs'); @@ -25,6 +26,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, + ideClient: IdeClient.getInstance(false), }); // Initialize contentGeneratorConfig for testing @@ -49,6 +51,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, + ideClient: IdeClient.getInstance(false), }); // Should not crash when contentGeneratorConfig is undefined @@ -72,6 +75,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: 'custom-model', + ideClient: IdeClient.getInstance(false), }); expect(newConfig.getModel()).toBe('custom-model'); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts new file mode 100644 index 00000000..ae46789e --- /dev/null +++ b/packages/core/src/ide/detect-ide.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum DetectedIde { + VSCode = 'vscode', +} + +export function getIdeDisplayName(ide: DetectedIde): string { + switch (ide) { + case DetectedIde.VSCode: + return 'VSCode'; + default: + throw new Error(`Unsupported IDE: ${ide}`); + } +} + +export function detectIde(): DetectedIde | undefined { + if (process.env.TERM_PROGRAM === 'vscode') { + return DetectedIde.VSCode; + } + return undefined; +} diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 3c670d54..4dd720dd 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + detectIde, + DetectedIde, + getIdeDisplayName, +} from '../ide/detect-ide.js'; import { ideContext, IdeContextNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -32,13 +37,34 @@ export class IdeClient { private state: IDEConnectionState = { status: IDEConnectionStatus.Disconnected, }; + private static instance: IdeClient; + private readonly currentIde: DetectedIde | undefined; + private readonly currentIdeDisplayName: string | undefined; - constructor() { + private constructor(ideMode: boolean) { + if (!ideMode) { + return; + } + this.currentIde = detectIde(); + if (this.currentIde) { + this.currentIdeDisplayName = getIdeDisplayName(this.currentIde); + } this.init().catch((err) => { logger.debug('Failed to initialize IdeClient:', err); }); } + static getInstance(ideMode: boolean): IdeClient { + if (!IdeClient.instance) { + IdeClient.instance = new IdeClient(ideMode); + } + return IdeClient.instance; + } + + getCurrentIde(): DetectedIde | undefined { + return this.currentIde; + } + getConnectionStatus(): IDEConnectionState { return this.state; } @@ -141,6 +167,14 @@ export class IdeClient { if (this.state.status === IDEConnectionStatus.Connected) { return; } + if (!this.currentIde) { + this.setState( + IDEConnectionStatus.Disconnected, + 'Not running in a supported IDE, skipping connection.', + ); + return; + } + this.setState(IDEConnectionStatus.Connecting); if (!this.validateWorkspacePath()) { @@ -154,4 +188,8 @@ export class IdeClient { await this.establishConnection(port); } + + getDetectedIdeDisplayName(): string | undefined { + return this.currentIdeDisplayName; + } } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts new file mode 100644 index 00000000..83459d6b --- /dev/null +++ b/packages/core/src/ide/ide-installer.test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getIdeInstaller, IdeInstaller } from './ide-installer.js'; +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import { DetectedIde } from './detect-ide.js'; + +vi.mock('child_process'); +vi.mock('fs'); +vi.mock('os'); + +describe('ide-installer', () => { + describe('getIdeInstaller', () => { + it('should return a VsCodeInstaller for "vscode"', () => { + const installer = getIdeInstaller(DetectedIde.VSCode); + expect(installer).not.toBeNull(); + // A more specific check might be needed if we export the class + expect(installer).toBeInstanceOf(Object); + }); + + it('should return null for an unknown IDE', () => { + const installer = getIdeInstaller('unknown' as DetectedIde); + expect(installer).toBeNull(); + }); + }); + + describe('VsCodeInstaller', () => { + let installer: IdeInstaller; + + beforeEach(() => { + // We get a new installer for each test to reset the find command logic + installer = getIdeInstaller(DetectedIde.VSCode)!; + vi.spyOn(child_process, 'execSync').mockImplementation(() => ''); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isInstalled', () => { + it('should return true if command is in PATH', async () => { + expect(await installer.isInstalled()).toBe(true); + }); + + it('should return true if command is in a known location', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + expect(await installer.isInstalled()).toBe(true); + }); + + it('should return false if command is not found', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + expect(await installer.isInstalled()).toBe(false); + }); + }); + + describe('install', () => { + it('should return a failure message if VS Code is not installed', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + const result = await installer.install(); + expect(result.success).toBe(false); + expect(result.message).toContain( + 'not found in your PATH or common installation locations', + ); + }); + }); + }); +}); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts new file mode 100644 index 00000000..725f4f7c --- /dev/null +++ b/packages/core/src/ide/ide-installer.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as child_process from 'child_process'; +import * as process from 'process'; +import { glob } from 'glob'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { fileURLToPath } from 'url'; +import { DetectedIde } from './detect-ide.js'; + +const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code'; +const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion'; + +export interface IdeInstaller { + install(): Promise; + isInstalled(): Promise; +} + +export interface InstallResult { + success: boolean; + message: string; +} + +async function findVsCodeCommand(): Promise { + // 1. Check PATH first. + try { + child_process.execSync( + process.platform === 'win32' + ? `where.exe ${VSCODE_COMMAND}` + : `command -v ${VSCODE_COMMAND}`, + { stdio: 'ignore' }, + ); + return VSCODE_COMMAND; + } catch { + // Not in PATH, continue to check common locations. + } + + // 2. Check common installation locations. + const locations: string[] = []; + const platform = process.platform; + const homeDir = os.homedir(); + + if (platform === 'darwin') { + // macOS + locations.push( + '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', + path.join(homeDir, 'Library/Application Support/Code/bin/code'), + ); + } else if (platform === 'linux') { + // Linux + locations.push( + '/usr/share/code/bin/code', + '/snap/bin/code', + path.join(homeDir, '.local/share/code/bin/code'), + ); + } else if (platform === 'win32') { + // Windows + locations.push( + path.join( + process.env.ProgramFiles || 'C:\\Program Files', + 'Microsoft VS Code', + 'bin', + 'code.cmd', + ), + path.join( + homeDir, + 'AppData', + 'Local', + 'Programs', + 'Microsoft VS Code', + 'bin', + 'code.cmd', + ), + ); + } + + for (const location of locations) { + if (fs.existsSync(location)) { + return location; + } + } + + return null; +} + +class VsCodeInstaller implements IdeInstaller { + private vsCodeCommand: Promise; + + constructor() { + this.vsCodeCommand = findVsCodeCommand(); + } + + async isInstalled(): Promise { + return (await this.vsCodeCommand) !== null; + } + + async install(): Promise { + const commandPath = await this.vsCodeCommand; + if (!commandPath) { + return { + success: false, + message: `VS Code command-line tool not found in your PATH or common installation locations.`, + }; + } + + const bundleDir = path.dirname(fileURLToPath(import.meta.url)); + // The VSIX file is copied to the bundle directory as part of the build. + let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix')); + if (vsixFiles.length === 0) { + // If the VSIX file is not in the bundle, it might be a dev + // environment running with `npm start`. Look for it in the original + // package location, relative to the bundle dir. + const devPath = path.join( + bundleDir, // .../packages/core/dist/src/ide + '..', // .../packages/core/dist/src + '..', // .../packages/core/dist + '..', // .../packages/core + '..', // .../packages + VSCODE_COMPANION_EXTENSION_FOLDER, + '*.vsix', + ); + vsixFiles = glob.sync(devPath); + } + if (vsixFiles.length === 0) { + return { + success: false, + message: + 'Could not find the required VS Code companion extension. Please file a bug via /bug.', + }; + } + + const vsixPath = vsixFiles[0]; + const command = `"${commandPath}" --install-extension "${vsixPath}" --force`; + try { + child_process.execSync(command, { stdio: 'pipe' }); + return { + success: true, + message: + 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', + }; + } catch (_error) { + return { + success: false, + message: 'Failed to install VS Code companion extension.', + }; + } + } +} + +export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null { + switch (ide) { + case 'vscode': + return new VsCodeInstaller(); + default: + return null; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ecc408fe..93862c12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,6 +48,8 @@ export * from './services/gitService.js'; // Export IDE specific logic export * from './ide/ide-client.js'; export * from './ide/ideContext.js'; +export * from './ide/ide-installer.js'; +export { getIdeDisplayName, DetectedIde } from './ide/detect-ide.js'; // Export Shell Execution Service export * from './services/shellExecutionService.js'; diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 9734e382..8ebb3d9a 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -12,6 +12,7 @@ import { } from './sdk.js'; import { Config } from '../config/config.js'; import { NodeSDK } from '@opentelemetry/sdk-node'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('@opentelemetry/sdk-node'); vi.mock('../config/config.js'); @@ -29,6 +30,7 @@ describe('telemetry', () => { targetDir: '/test/dir', debugMode: false, cwd: '/test/dir', + ideClient: IdeClient.getInstance(false), }); vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true); vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 27a7c28b..de7c6309 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -30,6 +30,7 @@ import { Schema, } from '@google/genai'; import { spawn } from 'node:child_process'; +import { IdeClient } from '../ide/ide-client.js'; import fs from 'node:fs'; vi.mock('node:fs'); @@ -139,6 +140,7 @@ const baseConfigParams: ConfigParameters = { geminiMdFileCount: 0, approvalMode: ApprovalMode.DEFAULT, sessionId: 'test-session-id', + ideClient: IdeClient.getInstance(false), }; describe('ToolRegistry', () => { diff --git a/packages/core/src/utils/flashFallback.integration.test.ts b/packages/core/src/utils/flashFallback.integration.test.ts index 9211ad2f..7f18b24f 100644 --- a/packages/core/src/utils/flashFallback.integration.test.ts +++ b/packages/core/src/utils/flashFallback.integration.test.ts @@ -17,6 +17,7 @@ import { import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { retryWithBackoff } from './retry.js'; import { AuthType } from '../core/contentGenerator.js'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('node:fs'); @@ -34,6 +35,7 @@ describe('Flash Fallback Integration', () => { debugMode: false, cwd: '/test', model: 'gemini-2.5-pro', + ideClient: IdeClient.getInstance(false), }); // Reset simulation state for each test From 498edb57abc9c047e2bd1ea828cc591618745bc4 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Wed, 30 Jul 2025 15:09:32 -0700 Subject: [PATCH 041/136] fix(testing): make ModelStatsDisplay snapshot test deterministic (#5236) Co-authored-by: Jacob Richman --- .../src/ui/components/ModelStatsDisplay.test.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index 57382d91..6adf2652 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -5,7 +5,7 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; import { SessionMetrics } from '../contexts/SessionContext.js'; @@ -38,6 +38,19 @@ const renderWithMockedStats = (metrics: SessionMetrics) => { }; describe('', () => { + beforeAll(() => { + vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function ( + this: number, + ) { + // Use a stable 'en-US' format for test consistency. + return new Intl.NumberFormat('en-US').format(this); + }); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + it('should render "no API calls" message when there are no active models', () => { const { lastFrame } = renderWithMockedStats({ models: {}, From ac1bb5ee4275e508dfc2256bbd5ca012e4a4f469 Mon Sep 17 00:00:00 2001 From: Olcan Date: Wed, 30 Jul 2025 15:21:31 -0700 Subject: [PATCH 042/136] confirm save_memory tool, with ability to see diff and edit manually for advanced changes that may override past memories (#5237) --- packages/core/src/tools/memoryTool.test.ts | 166 ++++++++++++++++- packages/core/src/tools/memoryTool.ts | 198 +++++++++++++++++++-- 2 files changed, 343 insertions(+), 21 deletions(-) diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index aff0cc2e..5a9b5f26 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -15,6 +15,7 @@ import { import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; +import { ToolConfirmationOutcome } from './tools.js'; // Mock dependencies vi.mock('fs/promises'); @@ -46,7 +47,7 @@ describe('MemoryTool', () => { }; beforeEach(() => { - vi.mocked(os.homedir).mockReturnValue('/mock/home'); + vi.mocked(os.homedir).mockReturnValue(path.join('/mock', 'home')); mockFsAdapter.readFile.mockReset(); mockFsAdapter.writeFile.mockReset().mockResolvedValue(undefined); mockFsAdapter.mkdir @@ -85,11 +86,15 @@ describe('MemoryTool', () => { }); describe('performAddMemoryEntry (static method)', () => { - const testFilePath = path.join( - '/mock/home', - '.gemini', - DEFAULT_CONTEXT_FILENAME, // Use the default for basic tests - ); + let testFilePath: string; + + beforeEach(() => { + testFilePath = path.join( + os.homedir(), + '.gemini', + DEFAULT_CONTEXT_FILENAME, + ); + }); it('should create section and save a fact if file does not exist', async () => { mockFsAdapter.readFile.mockRejectedValue({ code: 'ENOENT' }); // Simulate file not found @@ -206,7 +211,7 @@ describe('MemoryTool', () => { const result = await memoryTool.execute(params, mockAbortSignal); // Use getCurrentGeminiMdFilename for the default expectation before any setGeminiMdFilename calls in a test const expectedFilePath = path.join( - '/mock/home', + os.homedir(), '.gemini', getCurrentGeminiMdFilename(), // This will be DEFAULT_CONTEXT_FILENAME unless changed by a test ); @@ -262,4 +267,151 @@ describe('MemoryTool', () => { ); }); }); + + describe('shouldConfirmExecute', () => { + let memoryTool: MemoryTool; + + beforeEach(() => { + memoryTool = new MemoryTool(); + // Clear the allowlist before each test + (MemoryTool as unknown as { allowlist: Set }).allowlist.clear(); + // Mock fs.readFile to return empty string (file doesn't exist) + vi.mocked(fs.readFile).mockResolvedValue(''); + }); + + it('should return confirmation details when memory file is not allowlisted', async () => { + const params = { fact: 'Test fact' }; + const result = await memoryTool.shouldConfirmExecute( + params, + mockAbortSignal, + ); + + expect(result).toBeDefined(); + expect(result).not.toBe(false); + + if (result && result.type === 'edit') { + const expectedPath = path.join('~', '.gemini', 'GEMINI.md'); + expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`); + expect(result.fileName).toContain(path.join('mock', 'home', '.gemini')); + expect(result.fileName).toContain('GEMINI.md'); + expect(result.fileDiff).toContain('Index: GEMINI.md'); + expect(result.fileDiff).toContain('+## Gemini Added Memories'); + expect(result.fileDiff).toContain('+- Test fact'); + expect(result.originalContent).toBe(''); + expect(result.newContent).toContain('## Gemini Added Memories'); + expect(result.newContent).toContain('- Test fact'); + } + }); + + it('should return false when memory file is already allowlisted', async () => { + const params = { fact: 'Test fact' }; + const memoryFilePath = path.join( + os.homedir(), + '.gemini', + getCurrentGeminiMdFilename(), + ); + + // Add the memory file to the allowlist + (MemoryTool as unknown as { allowlist: Set }).allowlist.add( + memoryFilePath, + ); + + const result = await memoryTool.shouldConfirmExecute( + params, + mockAbortSignal, + ); + + expect(result).toBe(false); + }); + + it('should add memory file to allowlist when ProceedAlways is confirmed', async () => { + const params = { fact: 'Test fact' }; + const memoryFilePath = path.join( + os.homedir(), + '.gemini', + getCurrentGeminiMdFilename(), + ); + + const result = await memoryTool.shouldConfirmExecute( + params, + mockAbortSignal, + ); + + expect(result).toBeDefined(); + expect(result).not.toBe(false); + + if (result && result.type === 'edit') { + // Simulate the onConfirm callback + await result.onConfirm(ToolConfirmationOutcome.ProceedAlways); + + // Check that the memory file was added to the allowlist + expect( + (MemoryTool as unknown as { allowlist: Set }).allowlist.has( + memoryFilePath, + ), + ).toBe(true); + } + }); + + it('should not add memory file to allowlist when other outcomes are confirmed', async () => { + const params = { fact: 'Test fact' }; + const memoryFilePath = path.join( + os.homedir(), + '.gemini', + getCurrentGeminiMdFilename(), + ); + + const result = await memoryTool.shouldConfirmExecute( + params, + mockAbortSignal, + ); + + expect(result).toBeDefined(); + expect(result).not.toBe(false); + + if (result && result.type === 'edit') { + // Simulate the onConfirm callback with different outcomes + await result.onConfirm(ToolConfirmationOutcome.ProceedOnce); + expect( + (MemoryTool as unknown as { allowlist: Set }).allowlist.has( + memoryFilePath, + ), + ).toBe(false); + + await result.onConfirm(ToolConfirmationOutcome.Cancel); + expect( + (MemoryTool as unknown as { allowlist: Set }).allowlist.has( + memoryFilePath, + ), + ).toBe(false); + } + }); + + it('should handle existing memory file with content', async () => { + const params = { fact: 'New fact' }; + const existingContent = + 'Some existing content.\n\n## Gemini Added Memories\n- Old fact\n'; + + // Mock fs.readFile to return existing content + vi.mocked(fs.readFile).mockResolvedValue(existingContent); + + const result = await memoryTool.shouldConfirmExecute( + params, + mockAbortSignal, + ); + + expect(result).toBeDefined(); + expect(result).not.toBe(false); + + if (result && result.type === 'edit') { + const expectedPath = path.join('~', '.gemini', 'GEMINI.md'); + expect(result.title).toBe(`Confirm Memory Save: ${expectedPath}`); + expect(result.fileDiff).toContain('Index: GEMINI.md'); + expect(result.fileDiff).toContain('+- New fact'); + expect(result.originalContent).toBe(existingContent); + expect(result.newContent).toContain('- Old fact'); + expect(result.newContent).toContain('- New fact'); + } + }); + }); }); diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index f0f1e16b..96509f79 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -4,11 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BaseTool, Icon, ToolResult } from './tools.js'; +import { + BaseTool, + ToolResult, + ToolEditConfirmationDetails, + ToolConfirmationOutcome, + Icon, +} from './tools.js'; import { FunctionDeclaration, Type } from '@google/genai'; import * as fs from 'fs/promises'; import * as path from 'path'; import { homedir } from 'os'; +import * as Diff from 'diff'; +import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; +import { tildeifyPath } from '../utils/paths.js'; +import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; const memoryToolSchemaData: FunctionDeclaration = { name: 'save_memory', @@ -80,6 +90,8 @@ export function getAllGeminiMdFilenames(): string[] { interface SaveMemoryParams { fact: string; + modified_by_user?: boolean; + modified_content?: string; } function getGlobalMemoryFilePath(): string { @@ -98,7 +110,12 @@ function ensureNewlineSeparation(currentContent: string): string { return '\n\n'; } -export class MemoryTool extends BaseTool { +export class MemoryTool + extends BaseTool + implements ModifiableTool +{ + private static readonly allowlist: Set = new Set(); + static readonly Name: string = memoryToolSchemaData.name!; constructor() { super( @@ -110,6 +127,111 @@ export class MemoryTool extends BaseTool { ); } + getDescription(_params: SaveMemoryParams): string { + const memoryFilePath = getGlobalMemoryFilePath(); + return `in ${tildeifyPath(memoryFilePath)}`; + } + + /** + * Reads the current content of the memory file + */ + private async readMemoryFileContent(): Promise { + try { + return await fs.readFile(getGlobalMemoryFilePath(), 'utf-8'); + } catch (err) { + const error = err as Error & { code?: string }; + if (!(error instanceof Error) || error.code !== 'ENOENT') throw err; + return ''; + } + } + + /** + * Computes the new content that would result from adding a memory entry + */ + private computeNewContent(currentContent: string, fact: string): string { + let processedText = fact.trim(); + processedText = processedText.replace(/^(-+\s*)+/, '').trim(); + const newMemoryItem = `- ${processedText}`; + + const headerIndex = currentContent.indexOf(MEMORY_SECTION_HEADER); + + if (headerIndex === -1) { + // Header not found, append header and then the entry + const separator = ensureNewlineSeparation(currentContent); + return ( + currentContent + + `${separator}${MEMORY_SECTION_HEADER}\n${newMemoryItem}\n` + ); + } else { + // Header found, find where to insert the new memory entry + const startOfSectionContent = headerIndex + MEMORY_SECTION_HEADER.length; + let endOfSectionIndex = currentContent.indexOf( + '\n## ', + startOfSectionContent, + ); + if (endOfSectionIndex === -1) { + endOfSectionIndex = currentContent.length; // End of file + } + + const beforeSectionMarker = currentContent + .substring(0, startOfSectionContent) + .trimEnd(); + let sectionContent = currentContent + .substring(startOfSectionContent, endOfSectionIndex) + .trimEnd(); + const afterSectionMarker = currentContent.substring(endOfSectionIndex); + + sectionContent += `\n${newMemoryItem}`; + return ( + `${beforeSectionMarker}\n${sectionContent.trimStart()}\n${afterSectionMarker}`.trimEnd() + + '\n' + ); + } + } + + async shouldConfirmExecute( + params: SaveMemoryParams, + _abortSignal: AbortSignal, + ): Promise { + const memoryFilePath = getGlobalMemoryFilePath(); + const allowlistKey = memoryFilePath; + + if (MemoryTool.allowlist.has(allowlistKey)) { + return false; + } + + // Read current content of the memory file + const currentContent = await this.readMemoryFileContent(); + + // Calculate the new content that will be written to the memory file + const newContent = this.computeNewContent(currentContent, params.fact); + + const fileName = path.basename(memoryFilePath); + const fileDiff = Diff.createPatch( + fileName, + currentContent, + newContent, + 'Current', + 'Proposed', + DEFAULT_DIFF_OPTIONS, + ); + + const confirmationDetails: ToolEditConfirmationDetails = { + type: 'edit', + title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)}`, + fileName: memoryFilePath, + fileDiff, + originalContent: currentContent, + newContent, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + MemoryTool.allowlist.add(allowlistKey); + } + }, + }; + return confirmationDetails; + } + static async performAddMemoryEntry( text: string, memoryFilePath: string, @@ -184,7 +306,7 @@ export class MemoryTool extends BaseTool { params: SaveMemoryParams, _signal: AbortSignal, ): Promise { - const { fact } = params; + const { fact, modified_by_user, modified_content } = params; if (!fact || typeof fact !== 'string' || fact.trim() === '') { const errorMessage = 'Parameter "fact" must be a non-empty string.'; @@ -195,17 +317,44 @@ export class MemoryTool extends BaseTool { } try { - // Use the static method with actual fs promises - await MemoryTool.performAddMemoryEntry(fact, getGlobalMemoryFilePath(), { - readFile: fs.readFile, - writeFile: fs.writeFile, - mkdir: fs.mkdir, - }); - const successMessage = `Okay, I've remembered that: "${fact}"`; - return { - llmContent: JSON.stringify({ success: true, message: successMessage }), - returnDisplay: successMessage, - }; + if (modified_by_user && modified_content !== undefined) { + // User modified the content in external editor, write it directly + await fs.mkdir(path.dirname(getGlobalMemoryFilePath()), { + recursive: true, + }); + await fs.writeFile( + getGlobalMemoryFilePath(), + modified_content, + 'utf-8', + ); + const successMessage = `Okay, I've updated the memory file with your modifications.`; + return { + llmContent: JSON.stringify({ + success: true, + message: successMessage, + }), + returnDisplay: successMessage, + }; + } else { + // Use the normal memory entry logic + await MemoryTool.performAddMemoryEntry( + fact, + getGlobalMemoryFilePath(), + { + readFile: fs.readFile, + writeFile: fs.writeFile, + mkdir: fs.mkdir, + }, + ); + const successMessage = `Okay, I've remembered that: "${fact}"`; + return { + llmContent: JSON.stringify({ + success: true, + message: successMessage, + }), + returnDisplay: successMessage, + }; + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -221,4 +370,25 @@ export class MemoryTool extends BaseTool { }; } } + + getModifyContext(_abortSignal: AbortSignal): ModifyContext { + return { + getFilePath: (_params: SaveMemoryParams) => getGlobalMemoryFilePath(), + getCurrentContent: async (_params: SaveMemoryParams): Promise => + this.readMemoryFileContent(), + getProposedContent: async (params: SaveMemoryParams): Promise => { + const currentContent = await this.readMemoryFileContent(); + return this.computeNewContent(currentContent, params.fact); + }, + createUpdatedParams: ( + _oldContent: string, + modifiedProposedContent: string, + originalParams: SaveMemoryParams, + ): SaveMemoryParams => ({ + ...originalParams, + modified_by_user: true, + modified_content: modifiedProposedContent, + }), + }; + } } From 325bb8913776c60b763ee5f66375a4ca90d22ce0 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 30 Jul 2025 22:36:24 +0000 Subject: [PATCH 043/136] Add toggleable IDE mode setting (#5146) --- packages/cli/src/config/config.test.ts | 10 +- packages/cli/src/config/config.ts | 14 +- packages/cli/src/config/settings.ts | 4 +- packages/cli/src/ui/App.test.tsx | 1 + packages/cli/src/ui/App.tsx | 7 +- .../cli/src/ui/commands/ideCommand.test.ts | 52 +++-- packages/cli/src/ui/commands/ideCommand.ts | 191 +++++++++++------- .../ui/hooks/slashCommandProcessor.test.ts | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 +- packages/core/src/config/config.ts | 29 ++- packages/core/src/core/client.test.ts | 19 +- packages/core/src/core/client.ts | 2 +- packages/core/src/ide/ide-client.ts | 20 +- 13 files changed, 231 insertions(+), 123 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 1dd09f4b..d87d0c8f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -916,7 +916,7 @@ describe('loadCliConfig extensions', () => { }); }); -describe('loadCliConfig ideMode', () => { +describe('loadCliConfig ideModeFeature', () => { const originalArgv = process.argv; const originalEnv = { ...process.env }; @@ -939,16 +939,16 @@ describe('loadCliConfig ideMode', () => { const settings: Settings = {}; const argv = await parseArguments(); const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); + expect(config.getIdeModeFeature()).toBe(false); }); - it('should be false when settings.ideMode is true, but SANDBOX is set', async () => { + it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); process.env.TERM_PROGRAM = 'vscode'; process.env.SANDBOX = 'true'; - const settings: Settings = { ideMode: true }; + const settings: Settings = { ideModeFeature: true }; const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); + expect(config.getIdeModeFeature()).toBe(false); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1cc78888..d650a9af 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -59,7 +59,7 @@ export interface CliArgs { experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; - ideMode: boolean | undefined; + ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; } @@ -191,7 +191,7 @@ export async function parseArguments(): Promise { type: 'boolean', description: 'List all available extensions and exit.', }) - .option('ide-mode', { + .option('ide-mode-feature', { type: 'boolean', description: 'Run in IDE mode?', }) @@ -268,10 +268,13 @@ export async function loadCliConfig( (v) => v === 'true' || v === '1', ); - const ideMode = - (argv.ideMode ?? settings.ideMode ?? false) && !process.env.SANDBOX; + const ideMode = settings.ideMode ?? false; - const ideClient = IdeClient.getInstance(ideMode); + const ideModeFeature = + (argv.ideModeFeature ?? settings.ideModeFeature ?? false) && + !process.env.SANDBOX; + + const ideClient = IdeClient.getInstance(ideMode && ideModeFeature); const allExtensions = annotateActiveExtensions( extensions, @@ -429,6 +432,7 @@ export async function loadCliConfig( noBrowser: !!process.env.NO_BROWSER, summarizeToolOutput: settings.summarizeToolOutput, ideMode, + ideModeFeature, ideClient, }); } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 17c1d0d5..5d1b1aaf 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -99,7 +99,9 @@ export interface Settings { vimMode?: boolean; - // Add other settings here. + // Flag to be removed post-launch. + ideModeFeature?: boolean; + /// IDE mode setting configured via slash command toggle. ideMode?: boolean; // Setting for disabling auto-update. diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 13ddb77d..79b9ce86 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -151,6 +151,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { setFlashFallbackHandler: vi.fn(), getSessionId: vi.fn(() => 'test-session-id'), getUserTier: vi.fn().mockResolvedValue(undefined), + getIdeModeFeature: vi.fn(() => false), getIdeMode: vi.fn(() => false), getWorkspaceContext: vi.fn(() => ({ getDirectories: vi.fn(() => []), diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 2e899cc1..db9e5be4 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -573,7 +573,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (Object.keys(mcpServers || {}).length > 0) { handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); } - } else if (key.ctrl && input === 'e' && ideContextState) { + } else if ( + key.ctrl && + input === 'e' && + config.getIdeMode() && + ideContextState + ) { setShowIDEContextDetail((prev) => !prev); } else if (key.ctrl && (input === 'c' || input === 'C')) { handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 3c73f52c..3c73549c 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -32,11 +32,19 @@ describe('ideCommand', () => { ui: { addItem: vi.fn(), }, + services: { + settings: { + setValue: vi.fn(), + }, + }, } as unknown as CommandContext; mockConfig = { + getIdeModeFeature: vi.fn(), getIdeMode: vi.fn(), getIdeClient: vi.fn(), + setIdeMode: vi.fn(), + setIdeClientDisconnected: vi.fn(), } as unknown as Config; platformSpy = vi.spyOn(process, 'platform', 'get'); @@ -46,13 +54,14 @@ describe('ideCommand', () => { vi.restoreAllMocks(); }); - it('should return null if ideMode is not enabled', () => { - vi.mocked(mockConfig.getIdeMode).mockReturnValue(false); + it('should return null if ideModeFeature is not enabled', () => { + vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(false); const command = ideCommand(mockConfig); expect(command).toBeNull(); }); - it('should return the ide command if ideMode is enabled', () => { + it('should return the ide command if ideModeFeature is enabled', () => { + vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true); vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, @@ -60,19 +69,20 @@ describe('ideCommand', () => { const command = ideCommand(mockConfig); expect(command).not.toBeNull(); expect(command?.name).toBe('ide'); - expect(command?.subCommands).toHaveLength(2); - expect(command?.subCommands?.[0].name).toBe('status'); - expect(command?.subCommands?.[1].name).toBe('install'); + expect(command?.subCommands).toHaveLength(3); + expect(command?.subCommands?.[0].name).toBe('disable'); + expect(command?.subCommands?.[1].name).toBe('status'); + expect(command?.subCommands?.[2].name).toBe('install'); }); describe('status subcommand', () => { const mockGetConnectionStatus = vi.fn(); beforeEach(() => { - vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getConnectionStatus: mockGetConnectionStatus, getCurrentIde: () => DetectedIde.VSCode, - } as ReturnType); + } as unknown as ReturnType); }); it('should show connected status', () => { @@ -80,7 +90,8 @@ describe('ideCommand', () => { status: core.IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); - const result = command!.subCommands![0].action!(mockContext, ''); + const result = command!.subCommands!.find((c) => c.name === 'status')! + .action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -94,7 +105,8 @@ describe('ideCommand', () => { status: core.IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); - const result = command!.subCommands![0].action!(mockContext, ''); + const result = command!.subCommands!.find((c) => c.name === 'status')! + .action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -107,7 +119,8 @@ describe('ideCommand', () => { status: core.IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); - const result = command!.subCommands![0].action!(mockContext, ''); + const result = command!.subCommands!.find((c) => c.name === 'status')! + .action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -123,7 +136,8 @@ describe('ideCommand', () => { details, }); const command = ideCommand(mockConfig); - const result = command!.subCommands![0].action!(mockContext, ''); + const result = command!.subCommands!.find((c) => c.name === 'status')! + .action!(mockContext, ''); expect(mockGetConnectionStatus).toHaveBeenCalled(); expect(result).toEqual({ type: 'message', @@ -136,10 +150,12 @@ describe('ideCommand', () => { describe('install subcommand', () => { const mockInstall = vi.fn(); beforeEach(() => { + vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true); vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, - } as ReturnType); + getConnectionStatus: vi.fn(), + } as unknown as ReturnType); vi.mocked(core.getIdeInstaller).mockReturnValue({ install: mockInstall, isInstalled: vi.fn(), @@ -154,7 +170,10 @@ describe('ideCommand', () => { }); const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); + await command!.subCommands!.find((c) => c.name === 'install')!.action!( + mockContext, + '', + ); expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); expect(mockInstall).toHaveBeenCalled(); @@ -181,7 +200,10 @@ describe('ideCommand', () => { }); const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); + await command!.subCommands!.find((c) => c.name === 'install')!.action!( + mockContext, + '', + ); expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); expect(mockInstall).toHaveBeenCalled(); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 26b0f57d..1da7d6b0 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -6,9 +6,9 @@ import { Config, + IDEConnectionStatus, getIdeDisplayName, getIdeInstaller, - IDEConnectionStatus, } from '@google/gemini-cli-core'; import { CommandContext, @@ -16,91 +16,130 @@ import { SlashCommandActionReturn, CommandKind, } from './types.js'; +import { SettingScope } from '../../config/settings.js'; export const ideCommand = (config: Config | null): SlashCommand | null => { - if (!config?.getIdeMode()) { + if (!config?.getIdeModeFeature()) { return null; } const currentIDE = config.getIdeClient().getCurrentIde(); if (!currentIDE) { - throw new Error( - 'IDE slash command should not be available if not running in an IDE', - ); + return null; } - return { + const ideSlashCommand: SlashCommand = { name: 'ide', description: 'manage IDE integration', kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: 'status', - description: 'check status of IDE integration', - kind: CommandKind.BUILT_IN, - action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = config.getIdeClient().getConnectionStatus(); - switch (connection?.status) { - case IDEConnectionStatus.Connected: - return { - type: 'message', - messageType: 'info', - content: `🟢 Connected`, - } as const; - case IDEConnectionStatus.Connecting: - return { - type: 'message', - messageType: 'info', - content: `🟡 Connecting...`, - } as const; - default: { - let content = `🔴 Disconnected`; - if (connection?.details) { - content += `: ${connection.details}`; - } - return { - type: 'message', - messageType: 'error', - content, - } as const; - } - } - }, - }, - { - name: 'install', - description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `, - kind: CommandKind.BUILT_IN, - action: async (context) => { - const installer = getIdeInstaller(currentIDE); - if (!installer) { - context.ui.addItem( - { - type: 'error', - text: 'No installer available for your configured IDE.', - }, - Date.now(), - ); - return; - } - - context.ui.addItem( - { - type: 'info', - text: `Installing IDE companion extension...`, - }, - Date.now(), - ); - - const result = await installer.install(); - context.ui.addItem( - { - type: result.success ? 'info' : 'error', - text: result.message, - }, - Date.now(), - ); - }, - }, - ], + subCommands: [], }; + + const statusCommand: SlashCommand = { + name: 'status', + description: 'check status of IDE integration', + kind: CommandKind.BUILT_IN, + action: (_context: CommandContext): SlashCommandActionReturn => { + const connection = config.getIdeClient().getConnectionStatus(); + switch (connection?.status) { + case IDEConnectionStatus.Connected: + return { + type: 'message', + messageType: 'info', + content: `🟢 Connected`, + } as const; + case IDEConnectionStatus.Connecting: + return { + type: 'message', + messageType: 'info', + content: `🟡 Connecting...`, + } as const; + default: { + let content = `🔴 Disconnected`; + if (connection?.details) { + content += `: ${connection.details}`; + } + return { + type: 'message', + messageType: 'error', + content, + } as const; + } + } + }, + }; + + const installCommand: SlashCommand = { + name: 'install', + description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `, + kind: CommandKind.BUILT_IN, + action: async (context) => { + const installer = getIdeInstaller(currentIDE); + if (!installer) { + context.ui.addItem( + { + type: 'error', + text: 'No installer available for your configured IDE.', + }, + Date.now(), + ); + return; + } + + context.ui.addItem( + { + type: 'info', + text: `Installing IDE companion extension...`, + }, + Date.now(), + ); + + const result = await installer.install(); + context.ui.addItem( + { + type: result.success ? 'info' : 'error', + text: result.message, + }, + Date.now(), + ); + }, + }; + + const enableCommand: SlashCommand = { + name: 'enable', + description: 'enable IDE integration', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + context.services.settings.setValue(SettingScope.User, 'ideMode', true); + config.setIdeMode(true); + config.setIdeClientConnected(); + }, + }; + + const disableCommand: SlashCommand = { + name: 'disable', + description: 'disable IDE integration', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + context.services.settings.setValue(SettingScope.User, 'ideMode', false); + config.setIdeMode(false); + config.setIdeClientDisconnected(); + }, + }; + + const ideModeEnabled = config.getIdeMode(); + if (ideModeEnabled) { + ideSlashCommand.subCommands = [ + disableCommand, + statusCommand, + installCommand, + ]; + } else { + ideSlashCommand.subCommands = [ + enableCommand, + statusCommand, + installCommand, + ]; + } + + return ideSlashCommand; }; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 2dc206d7..d9fe8530 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -101,6 +101,7 @@ describe('useSlashCommandProcessor', () => { setHistory: vi.fn().mockResolvedValue(undefined), })), getExtensions: vi.fn(() => []), + getIdeMode: vi.fn(() => false), } as unknown as Config; const mockSettings = {} as LoadedSettings; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e315ba97..a2a1837d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -185,6 +185,8 @@ export const useSlashCommandProcessor = ( ], ); + const ideMode = config?.getIdeMode(); + useEffect(() => { const controller = new AbortController(); const load = async () => { @@ -205,7 +207,7 @@ export const useSlashCommandProcessor = ( return () => { controller.abort(); }; - }, [config]); + }, [config, ideMode]); const handleSlashCommand = useCallback( async ( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e62e2962..edb24351 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -184,6 +184,7 @@ export interface ConfigParameters { blockedMcpServers?: Array<{ name: string; extensionName: string }>; noBrowser?: boolean; summarizeToolOutput?: Record; + ideModeFeature?: boolean; ideMode?: boolean; ideClient: IdeClient; } @@ -228,8 +229,9 @@ export class Config { private readonly model: string; private readonly extensionContextFilePaths: string[]; private readonly noBrowser: boolean; - private readonly ideMode: boolean; - private readonly ideClient: IdeClient; + private readonly ideModeFeature: boolean; + private ideMode: boolean; + private ideClient: IdeClient; private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; @@ -298,7 +300,8 @@ export class Config { this._blockedMcpServers = params.blockedMcpServers ?? []; this.noBrowser = params.noBrowser ?? false; this.summarizeToolOutput = params.summarizeToolOutput; - this.ideMode = params.ideMode ?? false; + this.ideModeFeature = params.ideModeFeature ?? false; + this.ideMode = params.ideMode ?? true; this.ideClient = params.ideClient; if (params.contextFileName) { @@ -589,14 +592,30 @@ export class Config { return this.summarizeToolOutput; } - getIdeMode(): boolean { - return this.ideMode; + getIdeModeFeature(): boolean { + return this.ideModeFeature; } getIdeClient(): IdeClient { return this.ideClient; } + getIdeMode(): boolean { + return this.ideMode; + } + + setIdeMode(value: boolean): void { + this.ideMode = value; + } + + setIdeClientDisconnected(): void { + this.ideClient.setDisconnected(); + } + + setIdeClientConnected(): void { + this.ideClient.reconnect(this.ideMode && this.ideModeFeature); + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 96ddec1c..68d8c231 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -199,7 +199,8 @@ describe('Gemini Client (client.ts)', () => { setQuotaErrorOccurred: vi.fn(), getNoBrowser: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), - getIdeMode: vi.fn().mockReturnValue(false), + getIdeModeFeature: vi.fn().mockReturnValue(false), + getIdeMode: vi.fn().mockReturnValue(true), getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue(['/test/dir']), }), @@ -649,7 +650,7 @@ describe('Gemini Client (client.ts)', () => { }); describe('sendMessageStream', () => { - it('should include IDE context when ideMode is enabled', async () => { + it('should include IDE context when ideModeFeature is enabled', async () => { // Arrange vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { @@ -673,7 +674,7 @@ describe('Gemini Client (client.ts)', () => { }, }); - vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; @@ -724,7 +725,7 @@ Here are some other files the user has open, with the most recent at the top: ); }); - it('should not add context if ideMode is enabled but no open files', async () => { + it('should not add context if ideModeFeature is enabled but no open files', async () => { // Arrange vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { @@ -732,7 +733,7 @@ Here are some other files the user has open, with the most recent at the top: }, }); - vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; @@ -771,7 +772,7 @@ Here are some other files the user has open, with the most recent at the top: ); }); - it('should add context if ideMode is enabled and there is one active file', async () => { + it('should add context if ideModeFeature is enabled and there is one active file', async () => { // Arrange vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { @@ -787,7 +788,7 @@ Here are some other files the user has open, with the most recent at the top: }, }); - vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; @@ -835,7 +836,7 @@ This is the selected text in the file: ); }); - it('should add context if ideMode is enabled and there are open files but no active file', async () => { + it('should add context if ideModeFeature is enabled and there are open files but no active file', async () => { // Arrange vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { @@ -852,7 +853,7 @@ This is the selected text in the file: }, }); - vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true); + vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ecc7c242..49a30294 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -339,7 +339,7 @@ export class GeminiClient { yield { type: GeminiEventType.ChatCompressed, value: compressed }; } - if (this.config.getIdeMode()) { + if (this.config.getIdeModeFeature() && this.config.getIdeMode()) { const ideContextState = ideContext.getIdeContext(); const openFiles = ideContextState?.workspaceState?.openFiles; diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 4dd720dd..be24db3e 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -41,14 +41,14 @@ export class IdeClient { private readonly currentIde: DetectedIde | undefined; private readonly currentIdeDisplayName: string | undefined; - private constructor(ideMode: boolean) { - if (!ideMode) { - return; - } + constructor(ideMode: boolean) { this.currentIde = detectIde(); if (this.currentIde) { this.currentIdeDisplayName = getIdeDisplayName(this.currentIde); } + if (!ideMode) { + return; + } this.init().catch((err) => { logger.debug('Failed to initialize IdeClient:', err); }); @@ -130,6 +130,10 @@ export class IdeClient { }; } + async reconnect(ideMode: boolean) { + IdeClient.instance = new IdeClient(ideMode); + } + private async establishConnection(port: string) { let transport: StreamableHTTPClientTransport | undefined; try { @@ -189,7 +193,15 @@ export class IdeClient { await this.establishConnection(port); } + dispose() { + this.client?.close(); + } + getDetectedIdeDisplayName(): string | undefined { return this.currentIdeDisplayName; } + + setDisconnected() { + this.setState(IDEConnectionStatus.Disconnected); + } } From 0c6f7884060bd30996b212ffa135d95b1c779798 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Wed, 30 Jul 2025 18:49:26 -0400 Subject: [PATCH 044/136] Exclude companion extension from release versioning (#5226) Co-authored-by: Jacob Richman --- package-lock.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- scripts/version.js | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb6e0a50..70db287c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11934,7 +11934,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.1.15", + "version": "0.0.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 78968125..c4ff2b68 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 VS Code workspace.", - "version": "0.1.15", + "version": "0.0.1", "publisher": "google", "icon": "assets/icon.png", "repository": { diff --git a/scripts/version.js b/scripts/version.js index a5d2c203..741e6e2e 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -33,11 +33,24 @@ if (!versionType) { // 2. Bump the version in the root and all workspace package.json files. run(`npm version ${versionType} --no-git-tag-version --allow-same-version`); -run( - `npm version ${versionType} --workspaces --no-git-tag-version --allow-same-version`, + +// 3. Get all workspaces and filter out the one we don't want to version. +const workspacesToExclude = ['gemini-cli-vscode-ide-companion']; +const lsOutput = JSON.parse( + execSync('npm ls --workspaces --json --depth=0').toString(), +); +const allWorkspaces = Object.keys(lsOutput.dependencies || {}); +const workspacesToVersion = allWorkspaces.filter( + (wsName) => !workspacesToExclude.includes(wsName), ); -// 3. Get the new version number from the root package.json +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; From d06e17fbd9d08ac5efccf0c2b4d5fd4f703b15c7 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:16:21 -0700 Subject: [PATCH 045/136] Improve error message for discoverTools function (#4157) --- packages/core/src/tools/mcp-client.test.ts | 49 ++++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 36 +++++++++------- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 4560982c..a8289d3b 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -21,11 +21,14 @@ import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { AuthProviderType } from '../config/config.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; +import { DiscoveredMCPTool } from './mcp-tool.js'; + vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); vi.mock('@modelcontextprotocol/sdk/client/index.js'); vi.mock('@google/genai'); vi.mock('../mcp/oauth-provider.js'); vi.mock('../mcp/oauth-token-storage.js'); +vi.mock('./mcp-tool.js'); describe('mcp-client', () => { afterEach(() => { @@ -50,6 +53,52 @@ describe('mcp-client', () => { expect(tools.length).toBe(1); expect(mockedMcpToTool).toHaveBeenCalledOnce(); }); + + it('should log an error if there is an error discovering a tool', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => { + // no-op + }); + + const testError = new Error('Invalid tool name'); + vi.mocked(DiscoveredMCPTool).mockImplementation( + ( + _mcpCallableTool: GenAiLib.CallableTool, + _serverName: string, + name: string, + ) => { + if (name === 'invalid tool name') { + throw testError; + } + return { name: 'validTool' } as DiscoveredMCPTool; + }, + ); + + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'validTool', + }, + { + name: 'invalid tool name', // this will fail validation + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(1); + expect(tools[0].name).toBe('validTool'); + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error discovering tool: 'invalid tool name' from MCP server 'test-server': ${testError.message}`, + ); + }); }); describe('discoverPrompts', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index b87b2124..f9ccc380 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -428,21 +428,29 @@ export async function discoverTools( const discoveredTools: DiscoveredMCPTool[] = []; for (const funcDecl of tool.functionDeclarations) { - if (!isEnabled(funcDecl, mcpServerName, mcpServerConfig)) { - continue; - } + try { + if (!isEnabled(funcDecl, mcpServerName, mcpServerConfig)) { + continue; + } - discoveredTools.push( - new DiscoveredMCPTool( - mcpCallableTool, - mcpServerName, - funcDecl.name!, - funcDecl.description ?? '', - funcDecl.parametersJsonSchema ?? { type: 'object', properties: {} }, - mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, - mcpServerConfig.trust, - ), - ); + discoveredTools.push( + new DiscoveredMCPTool( + mcpCallableTool, + mcpServerName, + funcDecl.name!, + funcDecl.description ?? '', + funcDecl.parametersJsonSchema ?? { type: 'object', properties: {} }, + mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, + mcpServerConfig.trust, + ), + ); + } catch (error) { + console.error( + `Error discovering tool: '${ + funcDecl.name + }' from MCP server '${mcpServerName}': ${(error as Error).message}`, + ); + } } return discoveredTools; } catch (error) { From c77a22d4c6b3a8bb4d4bbdb636774b2aa2c2da3b Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Wed, 30 Jul 2025 17:43:11 -0700 Subject: [PATCH 046/136] Add render counter in debug mode (#5242) Co-authored-by: Jacob Richman --- .../cli/src/ui/components/DebugProfiler.tsx | 32 +++++++++++++++++++ packages/cli/src/ui/components/Footer.tsx | 3 ++ 2 files changed, 35 insertions(+) create mode 100644 packages/cli/src/ui/components/DebugProfiler.tsx diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx new file mode 100644 index 00000000..89c40a91 --- /dev/null +++ b/packages/cli/src/ui/components/DebugProfiler.tsx @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Text, useInput } from 'ink'; +import { useEffect, useRef, useState } from 'react'; +import { Colors } from '../colors.js'; + +export const DebugProfiler = () => { + const numRenders = useRef(0); + const [showNumRenders, setShowNumRenders] = useState(false); + + useEffect(() => { + numRenders.current++; + }); + + useInput((input, key) => { + if (key.ctrl && input === 'b') { + setShowNumRenders((prev) => !prev); + } + }); + + if (!showNumRenders) { + return null; + } + + return ( + Renders: {numRenders.current} + ); +}; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index af3d8437..acc55870 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -13,6 +13,8 @@ import process from 'node:process'; import Gradient from 'ink-gradient'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; +import { DebugProfiler } from './DebugProfiler.js'; + interface FooterProps { model: string; targetDir: string; @@ -48,6 +50,7 @@ export const Footer: React.FC = ({ return ( + {debugMode && } {vimMode && [{vimMode}] } {nightly ? ( From 3ef2c6d19815769747b4970b7e4356d18e1af889 Mon Sep 17 00:00:00 2001 From: Kazunari001 <130190029+Kazunari001@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:52:40 +0900 Subject: [PATCH 047/136] feat(docs): Add `/init` command in commands.md (#5187) Co-authored-by: saucykazugmail Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> Co-authored-by: Jacob Richman --- docs/cli/commands.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 50d18de4..d5072ab3 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -111,6 +111,9 @@ Slash commands provide meta-level control over the CLI itself. - **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer +- **`/init`** + - **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent. + ### Custom Commands For a quick start, see the [example](#example-a-pure-function-refactoring-command) below. From 23c014e29cbb3ac28e6fb02ef14d0538377f38ca Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Wed, 30 Jul 2025 21:47:04 -0700 Subject: [PATCH 048/136] Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) --- packages/core/src/core/client.ts | 16 ++++++++++------ packages/core/src/core/turn.ts | 3 +++ .../telemetry/clearcut-logger/clearcut-logger.ts | 16 ++++++++++++---- .../clearcut-logger/event-metadata-key.ts | 10 ++++++++++ packages/core/src/telemetry/constants.ts | 3 +-- packages/core/src/telemetry/loggers.ts | 14 +++++++------- packages/core/src/telemetry/types.ts | 14 +++++++++----- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 49a30294..5b26e32c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -43,8 +43,8 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; import { ideContext } from '../ide/ideContext.js'; -import { logFlashDecidedToContinue } from '../telemetry/loggers.js'; -import { FlashDecidedToContinueEvent } from '../telemetry/types.js'; +import { logNextSpeakerCheck } from '../telemetry/loggers.js'; +import { NextSpeakerCheckEvent } from '../telemetry/types.js'; function isThinkingSupported(model: string) { if (model.startsWith('gemini-2.5')) return true; @@ -415,11 +415,15 @@ export class GeminiClient { this, signal, ); + logNextSpeakerCheck( + this.config, + new NextSpeakerCheckEvent( + prompt_id, + turn.finishReason?.toString() || '', + nextSpeakerCheck?.next_speaker || '', + ), + ); if (nextSpeakerCheck?.next_speaker === 'model') { - logFlashDecidedToContinue( - this.config, - new FlashDecidedToContinueEvent(prompt_id), - ); const nextRequest = [{ text: 'Please continue.' }]; // This recursive call's events will be yielded out, but the final // turn object will be from the top-level call. diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index bea29b66..b54b3f82 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -163,6 +163,7 @@ export type ServerGeminiStreamEvent = export class Turn { readonly pendingToolCalls: ToolCallRequestInfo[]; private debugResponses: GenerateContentResponse[]; + finishReason: FinishReason | undefined; constructor( private readonly chat: GeminiChat, @@ -170,6 +171,7 @@ export class Turn { ) { this.pendingToolCalls = []; this.debugResponses = []; + this.finishReason = undefined; } // The run method yields simpler events suitable for server logic async *run( @@ -235,6 +237,7 @@ export class Turn { const finishReason = resp.candidates?.[0]?.finishReason; if (finishReason) { + this.finishReason = finishReason; yield { type: GeminiEventType.Finished, value: finishReason as FinishReason, diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index d221ef5e..81a9ca4b 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -18,7 +18,7 @@ import { ApiErrorEvent, FlashFallbackEvent, LoopDetectedEvent, - FlashDecidedToContinueEvent, + NextSpeakerCheckEvent, SlashCommandEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; @@ -40,7 +40,7 @@ const api_error_event_name = 'api_error'; const end_session_event_name = 'end_session'; const flash_fallback_event_name = 'flash_fallback'; const loop_detected_event_name = 'loop_detected'; -const flash_decided_to_continue_event_name = 'flash_decided_to_continue'; +const next_speaker_check_event_name = 'next_speaker_check'; const slash_command_event_name = 'slash_command'; export interface LogResponse { @@ -512,12 +512,20 @@ export class ClearcutLogger { this.flushIfNeeded(); } - logFlashDecidedToContinueEvent(event: FlashDecidedToContinueEvent): void { + logNextSpeakerCheck(event: NextSpeakerCheckEvent): void { const data = [ { gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID, value: JSON.stringify(event.prompt_id), }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_RESPONSE_FINISH_REASON, + value: JSON.stringify(event.finish_reason), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT, + value: JSON.stringify(event.result), + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID, value: this.config?.getSessionId() ?? '', @@ -525,7 +533,7 @@ export class ClearcutLogger { ]; this.enqueueLogEvent( - this.createLogEvent(flash_decided_to_continue_event_name, data), + this.createLogEvent(next_speaker_check_event_name, data), ); this.flushIfNeeded(); } 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 9a182f67..01dd42af 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -173,6 +173,16 @@ export enum EventMetadataKey { // Logs the subcommand of the slash command. GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42, + + // ========================================================================== + // Next Speaker Check Event Keys + // =========================================================================== + + // Logs the finish reason of the previous streamGenerateContent response + GEMINI_CLI_RESPONSE_FINISH_REASON = 43, + + // Logs the result of the next speaker check + GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT = 44, } export function getEventMetadataKey( diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 42572228..7dd5c8d1 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -13,8 +13,7 @@ export const EVENT_API_ERROR = 'gemini_cli.api_error'; export const EVENT_API_RESPONSE = 'gemini_cli.api_response'; export const EVENT_CLI_CONFIG = 'gemini_cli.config'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; -export const EVENT_FLASH_DECIDED_TO_CONTINUE = - 'gemini_cli.flash_decided_to_continue'; +export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command'; export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 3ee806bb..2aa0d86a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -15,7 +15,7 @@ import { EVENT_TOOL_CALL, EVENT_USER_PROMPT, EVENT_FLASH_FALLBACK, - EVENT_FLASH_DECIDED_TO_CONTINUE, + EVENT_NEXT_SPEAKER_CHECK, SERVICE_NAME, EVENT_SLASH_COMMAND, } from './constants.js'; @@ -27,7 +27,7 @@ import { ToolCallEvent, UserPromptEvent, FlashFallbackEvent, - FlashDecidedToContinueEvent, + NextSpeakerCheckEvent, LoopDetectedEvent, SlashCommandEvent, } from './types.js'; @@ -314,22 +314,22 @@ export function logLoopDetected( logger.emit(logRecord); } -export function logFlashDecidedToContinue( +export function logNextSpeakerCheck( config: Config, - event: FlashDecidedToContinueEvent, + event: NextSpeakerCheckEvent, ): void { - ClearcutLogger.getInstance(config)?.logFlashDecidedToContinueEvent(event); + ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event); if (!isTelemetrySdkInitialized()) return; const attributes: LogAttributes = { ...getCommonAttributes(config), ...event, - 'event.name': EVENT_FLASH_DECIDED_TO_CONTINUE, + 'event.name': EVENT_NEXT_SPEAKER_CHECK, }; const logger = logs.getLogger(SERVICE_NAME); const logRecord: LogRecord = { - body: `Flash decided to continue.`, + body: `Next speaker check.`, attributes, }; logger.emit(logRecord); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index d29b97d2..6fe797bf 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -266,15 +266,19 @@ export class LoopDetectedEvent { } } -export class FlashDecidedToContinueEvent { - 'event.name': 'flash_decided_to_continue'; +export class NextSpeakerCheckEvent { + 'event.name': 'next_speaker_check'; 'event.timestamp': string; // ISO 8601 prompt_id: string; + finish_reason: string; + result: string; - constructor(prompt_id: string) { - this['event.name'] = 'flash_decided_to_continue'; + constructor(prompt_id: string, finish_reason: string, result: string) { + this['event.name'] = 'next_speaker_check'; this['event.timestamp'] = new Date().toISOString(); this.prompt_id = prompt_id; + this.finish_reason = finish_reason; + this.result = result; } } @@ -302,5 +306,5 @@ export type TelemetryEvent = | ApiResponseEvent | FlashFallbackEvent | LoopDetectedEvent - | FlashDecidedToContinueEvent + | NextSpeakerCheckEvent | SlashCommandEvent; From 65be9cab478bbccd7a7f3937c11edd88dac1feb9 Mon Sep 17 00:00:00 2001 From: anj-s <32556631+anj-s@users.noreply.github.com> Date: Thu, 31 Jul 2025 05:36:12 -0700 Subject: [PATCH 049/136] Fix: Ensure that non interactive mode and interactive mode are calling the same entry points (#5137) --- packages/cli/src/nonInteractiveCli.test.ts | 372 ++++++++------------- packages/cli/src/nonInteractiveCli.ts | 65 +--- 2 files changed, 153 insertions(+), 284 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 8b0419f1..a0fc6f9f 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -4,196 +4,167 @@ * SPDX-License-Identifier: Apache-2.0 */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + Config, + executeToolCall, + ToolRegistry, + shutdownTelemetry, + GeminiEventType, + ServerGeminiStreamEvent, +} from '@google/gemini-cli-core'; +import { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; -import { Config, GeminiClient, ToolRegistry } from '@google/gemini-cli-core'; -import { GenerateContentResponse, Part, FunctionCall } from '@google/genai'; +import { vi } from 'vitest'; -// Mock dependencies -vi.mock('@google/gemini-cli-core', async () => { - const actualCore = await vi.importActual< - typeof import('@google/gemini-cli-core') - >('@google/gemini-cli-core'); +// Mock core modules +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); return { - ...actualCore, - GeminiClient: vi.fn(), - ToolRegistry: vi.fn(), + ...original, executeToolCall: vi.fn(), + shutdownTelemetry: vi.fn(), + isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), }; }); describe('runNonInteractive', () => { let mockConfig: Config; - let mockGeminiClient: GeminiClient; let mockToolRegistry: ToolRegistry; - let mockChat: { - sendMessageStream: ReturnType; + let mockCoreExecuteToolCall: vi.Mock; + let mockShutdownTelemetry: vi.Mock; + let consoleErrorSpy: vi.SpyInstance; + let processExitSpy: vi.SpyInstance; + let processStdoutSpy: vi.SpyInstance; + let mockGeminiClient: { + sendMessageStream: vi.Mock; }; - let mockProcessStdoutWrite: ReturnType; - let mockProcessExit: ReturnType; beforeEach(() => { - vi.resetAllMocks(); - mockChat = { - sendMessageStream: vi.fn(), - }; - mockGeminiClient = { - getChat: vi.fn().mockResolvedValue(mockChat), - } as unknown as GeminiClient; + mockCoreExecuteToolCall = vi.mocked(executeToolCall); + mockShutdownTelemetry = vi.mocked(shutdownTelemetry); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as (code?: number) => never); + processStdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + mockToolRegistry = { - getFunctionDeclarations: vi.fn().mockReturnValue([]), getTool: vi.fn(), + getFunctionDeclarations: vi.fn().mockReturnValue([]), } as unknown as ToolRegistry; - vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClient); - vi.mocked(ToolRegistry).mockImplementation(() => mockToolRegistry); + mockGeminiClient = { + sendMessageStream: vi.fn(), + }; mockConfig = { - getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + initialize: vi.fn().mockResolvedValue(undefined), getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), - getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry), getMaxSessionTurns: vi.fn().mockReturnValue(10), - initialize: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(false), + getFullContext: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), } as unknown as Config; - - mockProcessStdoutWrite = vi.fn().mockImplementation(() => true); - process.stdout.write = mockProcessStdoutWrite as any; // Use any to bypass strict signature matching for mock - mockProcessExit = vi - .fn() - .mockImplementation((_code?: number) => undefined as never); - process.exit = mockProcessExit as any; // Use any for process.exit mock }); afterEach(() => { vi.restoreAllMocks(); - // Restore original process methods if they were globally patched - // This might require storing the original methods before patching them in beforeEach }); + async function* createStreamFromEvents( + events: ServerGeminiStreamEvent[], + ): AsyncGenerator { + for (const event of events) { + yield event; + } + } + it('should process input and write text output', async () => { - const inputStream = (async function* () { - yield { - candidates: [{ content: { parts: [{ text: 'Hello' }] } }], - } as GenerateContentResponse; - yield { - candidates: [{ content: { parts: [{ text: ' World' }] } }], - } as GenerateContentResponse; - })(); - mockChat.sendMessageStream.mockResolvedValue(inputStream); + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1'); - expect(mockChat.sendMessageStream).toHaveBeenCalledWith( - { - message: [{ text: 'Test input' }], - config: { - abortSignal: expect.any(AbortSignal), - tools: [{ functionDeclarations: [] }], - }, - }, - expect.any(String), + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', ); - expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Hello'); - expect(mockProcessStdoutWrite).toHaveBeenCalledWith(' World'); - expect(mockProcessStdoutWrite).toHaveBeenCalledWith('\n'); + expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); + expect(processStdoutSpy).toHaveBeenCalledWith(' World'); + expect(processStdoutSpy).toHaveBeenCalledWith('\n'); + expect(mockShutdownTelemetry).toHaveBeenCalled(); }); it('should handle a single tool call and respond', async () => { - const functionCall: FunctionCall = { - id: 'fc1', - name: 'testTool', - args: { p: 'v' }, - }; - const toolResponsePart: Part = { - functionResponse: { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', name: 'testTool', - id: 'fc1', - response: { result: 'tool success' }, + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', }, }; + const toolResponse: Part[] = [{ text: 'Tool response' }]; + mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); - const { executeToolCall: mockCoreExecuteToolCall } = await import( - '@google/gemini-cli-core' - ); - vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({ - callId: 'fc1', - responseParts: [toolResponsePart], - resultDisplay: 'Tool success display', - error: undefined, - }); + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final answer' }, + ]; - const stream1 = (async function* () { - yield { functionCalls: [functionCall] } as GenerateContentResponse; - })(); - const stream2 = (async function* () { - yield { - candidates: [{ content: { parts: [{ text: 'Final answer' }] } }], - } as GenerateContentResponse; - })(); - mockChat.sendMessageStream - .mockResolvedValueOnce(stream1) - .mockResolvedValueOnce(stream2); + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2'); - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( mockConfig, - expect.objectContaining({ callId: 'fc1', name: 'testTool' }), + expect.objectContaining({ name: 'testTool' }), mockToolRegistry, expect.any(AbortSignal), ); - expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith( - expect.objectContaining({ - message: [toolResponsePart], - }), - expect.any(String), + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 2, + [{ text: 'Tool response' }], + expect.any(AbortSignal), + 'prompt-id-2', ); - expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Final answer'); + expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); + expect(processStdoutSpy).toHaveBeenCalledWith('\n'); }); it('should handle error during tool execution', async () => { - const functionCall: FunctionCall = { - id: 'fcError', - name: 'errorTool', - args: {}, - }; - const errorResponsePart: Part = { - functionResponse: { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', name: 'errorTool', - id: 'fcError', - response: { error: 'Tool failed' }, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', }, }; - - const { executeToolCall: mockCoreExecuteToolCall } = await import( - '@google/gemini-cli-core' - ); - vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({ - callId: 'fcError', - responseParts: [errorResponsePart], - resultDisplay: 'Tool execution failed badly', - error: new Error('Tool failed'), + mockCoreExecuteToolCall.mockResolvedValue({ + error: new Error('Tool execution failed badly'), }); - - const stream1 = (async function* () { - yield { functionCalls: [functionCall] } as GenerateContentResponse; - })(); - - const stream2 = (async function* () { - yield { - candidates: [ - { content: { parts: [{ text: 'Could not complete request.' }] } }, - ], - } as GenerateContentResponse; - })(); - mockChat.sendMessageStream - .mockResolvedValueOnce(stream1) - .mockResolvedValueOnce(stream2); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([toolCallEvent]), + ); await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3'); @@ -201,75 +172,48 @@ describe('runNonInteractive', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool errorTool: Tool execution failed badly', ); - expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith( - expect.objectContaining({ - message: [errorResponsePart], - }), - expect.any(String), - ); - expect(mockProcessStdoutWrite).toHaveBeenCalledWith( - 'Could not complete request.', - ); + expect(processExitSpy).toHaveBeenCalledWith(1); }); it('should exit with error if sendMessageStream throws initially', async () => { const apiError = new Error('API connection failed'); - mockChat.sendMessageStream.mockRejectedValue(apiError); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw apiError; + }); await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[API Error: API connection failed]', ); + expect(processExitSpy).toHaveBeenCalledWith(1); }); it('should not exit if a tool is not found, and should send error back to model', async () => { - const functionCall: FunctionCall = { - id: 'fcNotFound', - name: 'nonexistentTool', - args: {}, - }; - const errorResponsePart: Part = { - functionResponse: { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', name: 'nonexistentTool', - id: 'fcNotFound', - response: { error: 'Tool "nonexistentTool" not found in registry.' }, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', }, }; - - const { executeToolCall: mockCoreExecuteToolCall } = await import( - '@google/gemini-cli-core' - ); - vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({ - callId: 'fcNotFound', - responseParts: [errorResponsePart], - resultDisplay: 'Tool "nonexistentTool" not found in registry.', + mockCoreExecuteToolCall.mockResolvedValue({ error: new Error('Tool "nonexistentTool" not found in registry.'), + resultDisplay: 'Tool "nonexistentTool" not found in registry.', }); + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: "Sorry, I can't find that tool.", + }, + ]; - const stream1 = (async function* () { - yield { functionCalls: [functionCall] } as GenerateContentResponse; - })(); - const stream2 = (async function* () { - yield { - candidates: [ - { - content: { - parts: [{ text: 'Unfortunately the tool does not exist.' }], - }, - }, - ], - } as GenerateContentResponse; - })(); - mockChat.sendMessageStream - .mockResolvedValueOnce(stream1) - .mockResolvedValueOnce(stream2); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); await runNonInteractive( mockConfig, @@ -277,68 +221,22 @@ describe('runNonInteractive', () => { 'prompt-id-5', ); + expect(mockCoreExecuteToolCall).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', ); - - expect(mockProcessExit).not.toHaveBeenCalled(); - - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); - expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith( - expect.objectContaining({ - message: [errorResponsePart], - }), - expect.any(String), - ); - - expect(mockProcessStdoutWrite).toHaveBeenCalledWith( - 'Unfortunately the tool does not exist.', + expect(processExitSpy).not.toHaveBeenCalled(); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(processStdoutSpy).toHaveBeenCalledWith( + "Sorry, I can't find that tool.", ); }); it('should exit when max session turns are exceeded', async () => { - const functionCall: FunctionCall = { - id: 'fcLoop', - name: 'loopTool', - args: {}, - }; - const toolResponsePart: Part = { - functionResponse: { - name: 'loopTool', - id: 'fcLoop', - response: { result: 'still looping' }, - }, - }; - - // Config with a max turn of 1 - vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(1); - - const { executeToolCall: mockCoreExecuteToolCall } = await import( - '@google/gemini-cli-core' - ); - vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({ - callId: 'fcLoop', - responseParts: [toolResponsePart], - resultDisplay: 'Still looping', - error: undefined, - }); - - const stream = (async function* () { - yield { functionCalls: [functionCall] } as GenerateContentResponse; - })(); - - mockChat.sendMessageStream.mockResolvedValue(stream); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await runNonInteractive(mockConfig, 'Trigger loop'); - - expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1); + vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); + await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'); expect(consoleErrorSpy).toHaveBeenCalledWith( - ` - Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.`, + '\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', ); - expect(mockProcessExit).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 7bc0f6aa..1d0a7f3d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -11,38 +11,12 @@ import { ToolRegistry, shutdownTelemetry, isTelemetrySdkInitialized, + GeminiEventType, } from '@google/gemini-cli-core'; -import { - Content, - Part, - FunctionCall, - GenerateContentResponse, -} from '@google/genai'; +import { Content, Part, FunctionCall } from '@google/genai'; import { parseAndFormatApiError } from './ui/utils/errorParsing.js'; -function getResponseText(response: GenerateContentResponse): string | null { - if (response.candidates && response.candidates.length > 0) { - const candidate = response.candidates[0]; - if ( - candidate.content && - candidate.content.parts && - candidate.content.parts.length > 0 - ) { - // We are running in headless mode so we don't need to return thoughts to STDOUT. - const thoughtPart = candidate.content.parts[0]; - if (thoughtPart?.thought) { - return null; - } - return candidate.content.parts - .filter((part) => part.text) - .map((part) => part.text) - .join(''); - } - } - return null; -} - export async function runNonInteractive( config: Config, input: string, @@ -60,7 +34,6 @@ export async function runNonInteractive( const geminiClient = config.getGeminiClient(); const toolRegistry: ToolRegistry = await config.getToolRegistry(); - const chat = await geminiClient.getChat(); const abortController = new AbortController(); let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }]; let turnCount = 0; @@ -68,7 +41,7 @@ export async function runNonInteractive( while (true) { turnCount++; if ( - config.getMaxSessionTurns() > 0 && + config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { console.error( @@ -78,30 +51,28 @@ export async function runNonInteractive( } const functionCalls: FunctionCall[] = []; - const responseStream = await chat.sendMessageStream( - { - message: currentMessages[0]?.parts || [], // Ensure parts are always provided - config: { - abortSignal: abortController.signal, - tools: [ - { functionDeclarations: toolRegistry.getFunctionDeclarations() }, - ], - }, - }, + const responseStream = geminiClient.sendMessageStream( + currentMessages[0]?.parts || [], + abortController.signal, prompt_id, ); - for await (const resp of responseStream) { + for await (const event of responseStream) { if (abortController.signal.aborted) { console.error('Operation cancelled.'); return; } - const textPart = getResponseText(resp); - if (textPart) { - process.stdout.write(textPart); - } - if (resp.functionCalls) { - functionCalls.push(...resp.functionCalls); + + if (event.type === GeminiEventType.Content) { + process.stdout.write(event.value); + } else if (event.type === GeminiEventType.ToolCallRequest) { + const toolCallRequest = event.value; + const fc: FunctionCall = { + name: toolCallRequest.name, + args: toolCallRequest.args, + id: toolCallRequest.callId, + }; + functionCalls.push(fc); } } From ae86c7ba05567264ca2d115a7f96d887bc576457 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Thu, 31 Jul 2025 09:31:14 -0700 Subject: [PATCH 050/136] bug(core): UI reporting for truncated read_file. (#5155) Co-authored-by: Jacob Richman --- packages/core/src/tools/read-file.test.ts | 2 +- packages/core/src/utils/fileUtils.test.ts | 65 ++++++++++++++++++++++- packages/core/src/utils/fileUtils.ts | 15 +++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index f4086a2b..fa1e458c 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -222,7 +222,7 @@ describe('ReadFileTool', () => { 'Line 7', 'Line 8', ].join('\n'), - returnDisplay: '(truncated)', + returnDisplay: 'Read lines 6-8 of 20 from paginated.txt', }); }); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index b8e75561..ca121bca 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -420,7 +420,7 @@ describe('fileUtils', () => { expect(result.llmContent).toContain( '[File content truncated: showing lines 6-10 of 20 total lines. Use offset/limit parameters to view more.]', ); - expect(result.returnDisplay).toBe('(truncated)'); + expect(result.returnDisplay).toBe('Read lines 6-10 of 20 from test.txt'); expect(result.isTruncated).toBe(true); expect(result.originalLineCount).toBe(20); expect(result.linesShown).toEqual([6, 10]); @@ -465,9 +465,72 @@ describe('fileUtils', () => { expect(result.llmContent).toContain( '[File content partially truncated: some lines exceeded maximum length of 2000 characters.]', ); + expect(result.returnDisplay).toBe( + 'Read all 3 lines from test.txt (some lines were shortened)', + ); expect(result.isTruncated).toBe(true); }); + it('should truncate when line count exceeds the limit', async () => { + const lines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`); + actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); + + // Read 5 lines, but there are 11 total + const result = await processSingleFileContent( + testTextFilePath, + tempRootDir, + 0, + 5, + ); + + expect(result.isTruncated).toBe(true); + expect(result.returnDisplay).toBe('Read lines 1-5 of 11 from test.txt'); + }); + + it('should truncate when a line length exceeds the character limit', async () => { + const longLine = 'b'.repeat(2500); + const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`); + lines.push(longLine); // Total 11 lines + actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); + + // Read all 11 lines, including the long one + const result = await processSingleFileContent( + testTextFilePath, + tempRootDir, + 0, + 11, + ); + + expect(result.isTruncated).toBe(true); + expect(result.returnDisplay).toBe( + 'Read all 11 lines from test.txt (some lines were shortened)', + ); + }); + + it('should truncate both line count and line length when both exceed limits', async () => { + const linesWithLongInMiddle = Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}`, + ); + linesWithLongInMiddle[4] = 'c'.repeat(2500); + actualNodeFs.writeFileSync( + testTextFilePath, + linesWithLongInMiddle.join('\n'), + ); + + // Read 10 lines out of 20, including the long line + const result = await processSingleFileContent( + testTextFilePath, + tempRootDir, + 0, + 10, + ); + expect(result.isTruncated).toBe(true); + expect(result.returnDisplay).toBe( + 'Read lines 1-10 of 20 from test.txt (some lines were shortened)', + ); + }); + it('should return an error if the file size exceeds 20MB', async () => { // Create a file just over 20MB const twentyOneMB = 21 * 1024 * 1024; diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 6b5ce42c..c016cd4a 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -310,9 +310,22 @@ export async function processSingleFileContent( } llmTextContent += formattedLines.join('\n'); + // By default, return nothing to streamline the common case of a successful read_file. + let returnDisplay = ''; + if (contentRangeTruncated) { + returnDisplay = `Read lines ${ + actualStartLine + 1 + }-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`; + if (linesWereTruncatedInLength) { + returnDisplay += ' (some lines were shortened)'; + } + } else if (linesWereTruncatedInLength) { + returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`; + } + return { llmContent: llmTextContent, - returnDisplay: isTruncated ? '(truncated)' : '', + returnDisplay, isTruncated, originalLineCount, linesShown: [actualStartLine + 1, endLine], From 9a6422f331294ea2f56d67599ed142d09cc33320 Mon Sep 17 00:00:00 2001 From: Niladri Das Date: Thu, 31 Jul 2025 22:06:50 +0530 Subject: [PATCH 051/136] fix: CLAUDE.md compatibility for GEMINI.md '@' file import behavior (#2978) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Allen Hutchison --- docs/core/memport.md | 86 +- package-lock.json | 21 + package.json | 1 + packages/cli/src/config/config.test.ts | 1 + packages/cli/src/config/config.ts | 21 +- packages/cli/src/config/settings.ts | 1 + packages/cli/src/ui/App.tsx | 1 + packages/cli/src/ui/commands/memoryCommand.ts | 1 + packages/core/package.json | 1 + .../core/src/utils/memoryDiscovery.test.ts | 3 + packages/core/src/utils/memoryDiscovery.ts | 21 +- .../src/utils/memoryImportProcessor.test.ts | 911 ++++++++++++++++-- .../core/src/utils/memoryImportProcessor.ts | 450 ++++++--- scripts/test-windows-paths.js | 51 + 14 files changed, 1355 insertions(+), 215 deletions(-) create mode 100644 scripts/test-windows-paths.js diff --git a/docs/core/memport.md b/docs/core/memport.md index cc6404e0..cc96aad3 100644 --- a/docs/core/memport.md +++ b/docs/core/memport.md @@ -1,18 +1,14 @@ # Memory Import Processor -The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other markdown files using the `@file.md` syntax. +The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax. ## Overview This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security. -## Important Limitations - -**This feature only supports `.md` (markdown) files.** Attempting to import files with other extensions (like `.txt`, `.json`, etc.) will result in a warning and the import will fail. - ## Syntax -Use the `@` symbol followed by the path to the markdown file you want to import: +Use the `@` symbol followed by the path to the file you want to import: ```markdown # Main GEMINI.md file @@ -96,24 +92,10 @@ The `validateImportPath` function ensures that imports are only allowed from spe ### Maximum Import Depth -To prevent infinite recursion, there's a configurable maximum import depth (default: 10 levels). +To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels). ## Error Handling -### Non-MD File Attempts - -If you try to import a non-markdown file, you'll see a warning: - -```markdown -@./instructions.txt -``` - -Console output: - -``` -[WARN] [ImportProcessor] Import processor only supports .md files. Attempting to import non-md file: ./instructions.txt. This will fail. -``` - ### Missing Files If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output. @@ -122,6 +104,36 @@ If a referenced file doesn't exist, the import will fail gracefully with an erro Permission issues or other file system errors are handled gracefully with appropriate error messages. +## Code Region Detection + +The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures. + +## Import Tree Structure + +The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships. + +Example tree structure: + +``` +Memory Files + L project: GEMINI.md + L a.md + L b.md + L c.md + L d.md + L e.md + L f.md + L included.md +``` + +The tree preserves the order that files were imported and shows the complete import chain for debugging purposes. + +## Comparison to Claude Code's `/memory` (`claude.md`) Approach + +Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed. + +Note: The import tree is mainly for clarity during development and has limited relevance to LLM consumption. + ## API Reference ### `processImports(content, basePath, debugMode?, importState?)` @@ -135,7 +147,25 @@ Processes import statements in GEMINI.md content. - `debugMode` (boolean, optional): Whether to enable debug logging (default: false) - `importState` (ImportState, optional): State tracking for circular import prevention -**Returns:** Promise - Processed content with imports resolved +**Returns:** Promise - Object containing processed content and import tree + +### `ProcessImportsResult` + +```typescript +interface ProcessImportsResult { + content: string; // The processed content with imports resolved + importTree: MemoryFile; // Tree structure showing the import hierarchy +} +``` + +### `MemoryFile` + +```typescript +interface MemoryFile { + path: string; // The file path + imports?: MemoryFile[]; // Direct imports, in the order they were imported +} +``` ### `validateImportPath(importPath, basePath, allowedDirectories)` @@ -149,6 +179,16 @@ Validates import paths to ensure they are safe and within allowed directories. **Returns:** boolean - Whether the import path is valid +### `findProjectRoot(startDir)` + +Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop. + +**Parameters:** + +- `startDir` (string): The directory to start searching from + +**Returns:** Promise - The project root directory (or the start directory if no `.git` is found) + ## Best Practices 1. **Use descriptive file names** for imported components @@ -161,7 +201,7 @@ Validates import paths to ensure they are safe and within allowed directories. ### Common Issues -1. **Import not working**: Check that the file exists and has a `.md` extension +1. **Import not working**: Check that the file exists and the path is correct 2. **Circular import warnings**: Review your import structure for circular references 3. **Permission errors**: Ensure the files are readable and within allowed directories 4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly diff --git a/package-lock.json b/package-lock.json index 70db287c..3938c5e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { + "@types/marked": "^5.0.2", "@types/micromatch": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", @@ -2338,6 +2339,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/micromatch": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", @@ -7687,6 +7695,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11861,6 +11881,7 @@ "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", "shell-quote": "^1.8.3", diff --git a/package.json b/package.json index 66933212..9d4f18c8 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "LICENSE" ], "devDependencies": { + "@types/marked": "^5.0.2", "@types/micromatch": "^4.0.9", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index d87d0c8f..d8d463c2 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -494,6 +494,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { '/path/to/ext3/context1.md', '/path/to/ext3/context2.md', ], + 'tree', { respectGitIgnore: false, respectGeminiIgnore: true, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d650a9af..a147bca8 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -59,6 +59,7 @@ export interface CliArgs { experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; + ideMode?: boolean | undefined; ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; @@ -224,7 +225,11 @@ export async function parseArguments(): Promise { }); yargsInstance.wrap(yargsInstance.terminalWidth()); - return yargsInstance.argv; + const result = yargsInstance.parseSync(); + + // The import format is now only controlled by settings.memoryImportFormat + // We no longer accept it as a CLI argument + return result as CliArgs; } // This function is now a thin wrapper around the server's implementation. @@ -236,11 +241,12 @@ export async function loadHierarchicalGeminiMemory( fileService: FileDiscoveryService, settings: Settings, extensionContextFilePaths: string[] = [], + memoryImportFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, ): Promise<{ memoryContent: string; fileCount: number }> { if (debugMode) { logger.debug( - `CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`, + `CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`, ); } @@ -251,6 +257,7 @@ export async function loadHierarchicalGeminiMemory( debugMode, fileService, extensionContextFilePaths, + memoryImportFormat, fileFilteringOptions, settings.memoryDiscoveryMaxDirs, ); @@ -266,9 +273,12 @@ export async function loadCliConfig( argv.debug || [process.env.DEBUG, process.env.DEBUG_MODE].some( (v) => v === 'true' || v === '1', - ); - - const ideMode = settings.ideMode ?? false; + ) || + false; + const memoryImportFormat = settings.memoryImportFormat || 'tree'; + const ideMode = + (argv.ideMode ?? settings.ideMode ?? false) && + process.env.TERM_PROGRAM === 'vscode'; const ideModeFeature = (argv.ideModeFeature ?? settings.ideModeFeature ?? false) && @@ -314,6 +324,7 @@ export async function loadCliConfig( fileService, settings, extensionContextFilePaths, + memoryImportFormat, fileFiltering, ); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5d1b1aaf..752d7159 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -98,6 +98,7 @@ export interface Settings { summarizeToolOutput?: Record; vimMode?: boolean; + memoryImportFormat?: 'tree' | 'flat'; // Flag to be removed post-launch. ideModeFeature?: boolean; diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index db9e5be4..046713ac 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -277,6 +277,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { config.getFileService(), settings.merged, config.getExtensionContextFilePaths(), + settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), ); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index fe698c0f..370bb1fb 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -92,6 +92,7 @@ export const memoryCommand: SlashCommand = { config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), + context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), context.services.settings.merged.memoryDiscoveryMaxDirs, ); diff --git a/packages/core/package.json b/packages/core/package.json index a07d717f..c3517ce1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", "shell-quote": "^1.8.3", diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 2fb2fcb1..8c7a294d 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -305,10 +305,12 @@ Subdir memory false, new FileDiscoveryService(projectRoot), [], + 'tree', { respectGitIgnore: true, respectGeminiIgnore: true, }, + 200, // maxDirs parameter ); expect(result).toEqual({ @@ -334,6 +336,7 @@ My code memory true, new FileDiscoveryService(projectRoot), [], + 'tree', // importFormat { respectGitIgnore: true, respectGeminiIgnore: true, diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 88c82373..a673a75e 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -43,7 +43,7 @@ async function findProjectRoot(startDir: string): Promise { while (true) { const gitPath = path.join(currentDir, '.git'); try { - const stats = await fs.stat(gitPath); + const stats = await fs.lstat(gitPath); if (stats.isDirectory()) { return currentDir; } @@ -230,6 +230,7 @@ async function getGeminiMdFilePathsInternal( async function readGeminiMdFiles( filePaths: string[], debugMode: boolean, + importFormat: 'flat' | 'tree' = 'tree', ): Promise { const results: GeminiFileContent[] = []; for (const filePath of filePaths) { @@ -237,16 +238,19 @@ async function readGeminiMdFiles( const content = await fs.readFile(filePath, 'utf-8'); // Process imports in the content - const processedContent = await processImports( + const processedResult = await processImports( content, path.dirname(filePath), debugMode, + undefined, + undefined, + importFormat, ); - results.push({ filePath, content: processedContent }); + results.push({ filePath, content: processedResult.content }); if (debugMode) logger.debug( - `Successfully read and processed imports: ${filePath} (Length: ${processedContent.length})`, + `Successfully read and processed imports: ${filePath} (Length: ${processedResult.content.length})`, ); } catch (error: unknown) { const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST; @@ -293,12 +297,13 @@ export async function loadServerHierarchicalMemory( debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], + importFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, maxDirs: number = 200, ): Promise<{ memoryContent: string; fileCount: number }> { if (debugMode) logger.debug( - `Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`, + `Loading server hierarchical memory for CWD: ${currentWorkingDirectory} (importFormat: ${importFormat})`, ); // For the server, homedir() refers to the server process's home. @@ -317,7 +322,11 @@ export async function loadServerHierarchicalMemory( if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.'); return { memoryContent: '', fileCount: 0 }; } - const contentsWithPaths = await readGeminiMdFiles(filePaths, debugMode); + const contentsWithPaths = await readGeminiMdFiles( + filePaths, + debugMode, + importFormat, + ); // Pass CWD for relative path display in concatenated content const combinedInstructions = concatenateInstructions( contentsWithPaths, diff --git a/packages/core/src/utils/memoryImportProcessor.test.ts b/packages/core/src/utils/memoryImportProcessor.test.ts index 2f23dd2e..94fc1193 100644 --- a/packages/core/src/utils/memoryImportProcessor.test.ts +++ b/packages/core/src/utils/memoryImportProcessor.test.ts @@ -7,8 +7,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs/promises'; import * as path from 'path'; +import { marked } from 'marked'; import { processImports, validateImportPath } from './memoryImportProcessor.js'; +// Helper function to create platform-agnostic test paths +const testPath = (...segments: string[]) => { + // Start with the first segment as is (might be an absolute path on Windows) + let result = segments[0]; + + // Join remaining segments with the platform-specific separator + for (let i = 1; i < segments.length; i++) { + if (segments[i].startsWith('/') || segments[i].startsWith('\\')) { + // If segment starts with a separator, remove the trailing separator from the result + result = path.normalize(result.replace(/[\\/]+$/, '') + segments[i]); + } else { + // Otherwise join with the platform separator + result = path.join(result, segments[i]); + } + } + + return path.normalize(result); +}; + // Mock fs/promises vi.mock('fs/promises'); const mockedFs = vi.mocked(fs); @@ -18,6 +38,59 @@ const originalConsoleWarn = console.warn; const originalConsoleError = console.error; const originalConsoleDebug = console.debug; +// Helper functions using marked for parsing and validation +const parseMarkdown = (content: string) => marked.lexer(content); + +const findMarkdownComments = (content: string): string[] => { + const tokens = parseMarkdown(content); + const comments: string[] = []; + + function walkTokens(tokenList: unknown[]) { + for (const token of tokenList) { + const t = token as { type: string; raw: string; tokens?: unknown[] }; + if (t.type === 'html' && t.raw.includes(''); - expect(result).toContain(importedContent); - expect(result).toContain(''); + // Use marked to find HTML comments (import markers) + const comments = findMarkdownComments(result.content); + expect(comments.some((c) => c.includes('Imported from: ./test.md'))).toBe( + true, + ); + expect( + comments.some((c) => c.includes('End of import from: ./test.md')), + ).toBe(true); + + // Verify the imported content is present + expect(result.content).toContain(importedContent); + + // Verify the markdown structure is valid + const tokens = parseMarkdown(result.content); + expect(tokens).toBeDefined(); + expect(tokens.length).toBeGreaterThan(0); + expect(mockedFs.readFile).toHaveBeenCalledWith( path.resolve(basePath, './test.md'), 'utf-8', ); }); - it('should warn and fail for non-md file imports', async () => { + it('should import non-md files just like md files', async () => { const content = 'Some content @./instructions.txt more content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); + const importedContent = + '# Instructions\nThis is a text file with markdown.'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile.mockResolvedValue(importedContent); const result = await processImports(content, basePath, true); - expect(console.warn).toHaveBeenCalledWith( - '[WARN] [ImportProcessor]', - 'Import processor only supports .md files. Attempting to import non-md file: ./instructions.txt. This will fail.', + // Use marked to find import comments + const comments = findMarkdownComments(result.content); + expect( + comments.some((c) => c.includes('Imported from: ./instructions.txt')), + ).toBe(true); + expect( + comments.some((c) => + c.includes('End of import from: ./instructions.txt'), + ), + ).toBe(true); + + // Use marked to parse and validate the imported content structure + const tokens = parseMarkdown(result.content); + + // Find headers in the parsed content + const headers = tokens.filter((token) => token.type === 'heading'); + expect( + headers.some((h) => (h as { text: string }).text === 'Instructions'), + ).toBe(true); + + // Verify the imported content is present + expect(result.content).toContain(importedContent); + expect(console.warn).not.toHaveBeenCalled(); + expect(mockedFs.readFile).toHaveBeenCalledWith( + path.resolve(basePath, './instructions.txt'), + 'utf-8', ); - expect(result).toContain( - '', - ); - expect(mockedFs.readFile).not.toHaveBeenCalled(); }); it('should handle circular imports', async () => { const content = 'Content @./circular.md more content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); const circularContent = 'Circular @./main.md content'; mockedFs.access.mockResolvedValue(undefined); @@ -83,24 +194,26 @@ describe('memoryImportProcessor', () => { processedFiles: new Set(), maxDepth: 10, currentDepth: 0, - currentFile: '/test/path/main.md', // Simulate we're processing main.md + currentFile: testPath('test', 'path', 'main.md'), // Simulate we're processing main.md }; const result = await processImports(content, basePath, true, importState); // The circular import should be detected when processing the nested import - expect(result).toContain(''); + expect(result.content).toContain( + '', + ); }); it('should handle file not found errors', async () => { const content = 'Content @./nonexistent.md more content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); mockedFs.access.mockRejectedValue(new Error('File not found')); const result = await processImports(content, basePath, true); - expect(result).toContain( + expect(result.content).toContain( '', ); expect(console.error).toHaveBeenCalledWith( @@ -111,7 +224,7 @@ describe('memoryImportProcessor', () => { it('should respect max depth limit', async () => { const content = 'Content @./deep.md more content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); const deepContent = 'Deep @./deeper.md content'; mockedFs.access.mockResolvedValue(undefined); @@ -129,12 +242,12 @@ describe('memoryImportProcessor', () => { '[WARN] [ImportProcessor]', 'Maximum import depth (1) reached. Stopping import processing.', ); - expect(result).toBe(content); + expect(result.content).toBe(content); }); it('should handle nested imports recursively', async () => { const content = 'Main @./nested.md content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); const nestedContent = 'Nested @./inner.md content'; const innerContent = 'Inner content'; @@ -145,14 +258,14 @@ describe('memoryImportProcessor', () => { const result = await processImports(content, basePath, true); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(innerContent); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(innerContent); }); it('should handle absolute paths in imports', async () => { const content = 'Content @/absolute/path/file.md more content'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); const importedContent = 'Absolute path content'; mockedFs.access.mockResolvedValue(undefined); @@ -160,14 +273,14 @@ describe('memoryImportProcessor', () => { const result = await processImports(content, basePath, true); - expect(result).toContain( + expect(result.content).toContain( '', ); }); it('should handle multiple imports in same content', async () => { const content = 'Start @./first.md middle @./second.md end'; - const basePath = '/test/path'; + const basePath = testPath('test', 'path'); const firstContent = 'First content'; const secondContent = 'Second content'; @@ -178,80 +291,760 @@ describe('memoryImportProcessor', () => { const result = await processImports(content, basePath, true); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(firstContent); - expect(result).toContain(secondContent); + expect(result.content).toContain(''); + expect(result.content).toContain(''); + expect(result.content).toContain(firstContent); + expect(result.content).toContain(secondContent); + }); + + it('should ignore imports inside code blocks', async () => { + const content = [ + 'Normal content @./should-import.md', + '```', + 'code block with @./should-not-import.md', + '```', + 'More content @./should-import2.md', + ].join('\n'); + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const importedContent1 = 'Imported 1'; + const importedContent2 = 'Imported 2'; + // Only the imports outside code blocks should be processed + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(importedContent1) + .mockResolvedValueOnce(importedContent2); + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + ); + + // Use marked to verify imported content is present + expect(result.content).toContain(importedContent1); + expect(result.content).toContain(importedContent2); + + // Use marked to find code blocks and verify the import wasn't processed + const codeBlocks = findCodeBlocks(result.content); + const hasUnprocessedImport = codeBlocks.some((block) => + block.content.includes('@./should-not-import.md'), + ); + expect(hasUnprocessedImport).toBe(true); + + // Verify no import comment was created for the code block import + const comments = findMarkdownComments(result.content); + expect(comments.some((c) => c.includes('should-not-import.md'))).toBe( + false, + ); + }); + + it('should ignore imports inside inline code', async () => { + const content = [ + 'Normal content @./should-import.md', + '`code with import @./should-not-import.md`', + 'More content @./should-import2.md', + ].join('\n'); + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const importedContent1 = 'Imported 1'; + const importedContent2 = 'Imported 2'; + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(importedContent1) + .mockResolvedValueOnce(importedContent2); + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + ); + + // Verify imported content is present + expect(result.content).toContain(importedContent1); + expect(result.content).toContain(importedContent2); + + // Use marked to find inline code spans + const codeBlocks = findCodeBlocks(result.content); + const inlineCodeSpans = codeBlocks.filter( + (block) => block.type === 'inline_code', + ); + + // Verify the inline code span still contains the unprocessed import + expect( + inlineCodeSpans.some((span) => + span.content.includes('@./should-not-import.md'), + ), + ).toBe(true); + + // Verify no import comments were created for inline code imports + const comments = findMarkdownComments(result.content); + expect(comments.some((c) => c.includes('should-not-import.md'))).toBe( + false, + ); + }); + + it('should handle nested tokens and non-unique content correctly', async () => { + // This test verifies the robust findCodeRegions implementation + // that recursively walks the token tree and handles non-unique content + const content = [ + 'Normal content @./should-import.md', + 'Paragraph with `inline code @./should-not-import.md` and more text.', + 'Another paragraph with the same `inline code @./should-not-import.md` text.', + 'More content @./should-import2.md', + ].join('\n'); + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const importedContent1 = 'Imported 1'; + const importedContent2 = 'Imported 2'; + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(importedContent1) + .mockResolvedValueOnce(importedContent2); + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + ); + + // Should process imports outside code regions + expect(result.content).toContain(importedContent1); + expect(result.content).toContain(importedContent2); + + // Should preserve imports inside inline code (both occurrences) + expect(result.content).toContain('`inline code @./should-not-import.md`'); + + // Should not have processed the imports inside code regions + expect(result.content).not.toContain( + '', + ); + }); + + it('should allow imports from parent and subdirectories within project root', async () => { + const content = + 'Parent import: @../parent.md Subdir import: @./components/sub.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const importedParent = 'Parent file content'; + const importedSub = 'Subdir file content'; + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(importedParent) + .mockResolvedValueOnce(importedSub); + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + ); + expect(result.content).toContain(importedParent); + expect(result.content).toContain(importedSub); + }); + + it('should reject imports outside project root', async () => { + const content = 'Outside import: @../../../etc/passwd'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + ); + expect(result.content).toContain( + '', + ); + }); + + it('should build import tree structure', async () => { + const content = 'Main content @./nested.md @./simple.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const nestedContent = 'Nested @./inner.md content'; + const simpleContent = 'Simple content'; + const innerContent = 'Inner content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(nestedContent) + .mockResolvedValueOnce(simpleContent) + .mockResolvedValueOnce(innerContent); + + const result = await processImports(content, basePath, true); + + // Use marked to find and validate import comments + const comments = findMarkdownComments(result.content); + const importComments = comments.filter((c) => + c.includes('Imported from:'), + ); + + expect(importComments.some((c) => c.includes('./nested.md'))).toBe(true); + expect(importComments.some((c) => c.includes('./simple.md'))).toBe(true); + expect(importComments.some((c) => c.includes('./inner.md'))).toBe(true); + + // Use marked to validate the markdown structure is well-formed + const tokens = parseMarkdown(result.content); + expect(tokens).toBeDefined(); + expect(tokens.length).toBeGreaterThan(0); + + // Verify the content contains expected text using marked parsing + const textContent = tokens + .filter((token) => token.type === 'paragraph') + .map((token) => token.raw) + .join(' '); + + expect(textContent).toContain('Main content'); + expect(textContent).toContain('Nested'); + expect(textContent).toContain('Simple content'); + expect(textContent).toContain('Inner content'); + + // Verify import tree structure + expect(result.importTree.path).toBe('unknown'); // No currentFile set in test + expect(result.importTree.imports).toHaveLength(2); + + // First import: nested.md + // Prefix with underscore to indicate they're intentionally unused + const _expectedNestedPath = testPath(projectRoot, 'src', 'nested.md'); + const _expectedInnerPath = testPath(projectRoot, 'src', 'inner.md'); + const _expectedSimplePath = testPath(projectRoot, 'src', 'simple.md'); + + // Check that the paths match using includes to handle potential absolute/relative differences + expect(result.importTree.imports![0].path).toContain('nested.md'); + expect(result.importTree.imports![0].imports).toHaveLength(1); + expect(result.importTree.imports![0].imports![0].path).toContain( + 'inner.md', + ); + expect(result.importTree.imports![0].imports![0].imports).toBeUndefined(); + + // Second import: simple.md + expect(result.importTree.imports![1].path).toContain('simple.md'); + expect(result.importTree.imports![1].imports).toBeUndefined(); + }); + + it('should produce flat output in Claude-style with unique files in order', async () => { + const content = 'Main @./nested.md content @./simple.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const nestedContent = 'Nested @./inner.md content'; + const simpleContent = 'Simple content'; + const innerContent = 'Inner content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(nestedContent) + .mockResolvedValueOnce(simpleContent) + .mockResolvedValueOnce(innerContent); + + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + 'flat', + ); + + // Use marked to parse the output and validate structure + const tokens = parseMarkdown(result.content); + expect(tokens).toBeDefined(); + + // Find all file markers using marked parsing + const fileMarkers: string[] = []; + const endMarkers: string[] = []; + + function walkTokens(tokenList: unknown[]) { + for (const token of tokenList) { + const t = token as { type: string; raw: string; tokens?: unknown[] }; + if (t.type === 'paragraph' && t.raw.includes('--- File:')) { + const match = t.raw.match(/--- File: (.+?) ---/); + if (match) { + // Normalize the path before adding to fileMarkers + fileMarkers.push(path.normalize(match[1])); + } + } + if (t.type === 'paragraph' && t.raw.includes('--- End of File:')) { + const match = t.raw.match(/--- End of File: (.+?) ---/); + if (match) { + // Normalize the path before adding to endMarkers + endMarkers.push(path.normalize(match[1])); + } + } + if (t.tokens) { + walkTokens(t.tokens); + } + } + } + + walkTokens(tokens); + + // Verify all expected files are present + const expectedFiles = ['nested.md', 'simple.md', 'inner.md']; + + // Check that each expected file is present in the content + expectedFiles.forEach((file) => { + expect(result.content).toContain(file); + }); + + // Verify content is present + expect(result.content).toContain( + 'Main @./nested.md content @./simple.md', + ); + expect(result.content).toContain('Nested @./inner.md content'); + expect(result.content).toContain('Simple content'); + expect(result.content).toContain('Inner content'); + + // Verify end markers exist + expect(endMarkers.length).toBeGreaterThan(0); + }); + + it('should not duplicate files in flat output if imported multiple times', async () => { + const content = 'Main @./dup.md again @./dup.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const dupContent = 'Duplicated content'; + + // Reset mocks + mockedFs.access.mockReset(); + mockedFs.readFile.mockReset(); + + // Set up mocks + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile.mockResolvedValue(dupContent); + + const result = await processImports( + content, + basePath, + true, // followImports + undefined, // allowedPaths + projectRoot, + 'flat', // outputFormat + ); + + // Verify readFile was called only once for dup.md + expect(mockedFs.readFile).toHaveBeenCalledTimes(1); + + // Check that the content contains the file content only once + const contentStr = result.content; + const firstIndex = contentStr.indexOf('Duplicated content'); + const lastIndex = contentStr.lastIndexOf('Duplicated content'); + expect(firstIndex).toBeGreaterThan(-1); // Content should exist + expect(firstIndex).toBe(lastIndex); // Should only appear once + }); + + it('should handle nested imports in flat output', async () => { + const content = 'Root @./a.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const aContent = 'A @./b.md'; + const bContent = 'B content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(aContent) + .mockResolvedValueOnce(bContent); + + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + 'flat', + ); + + // Verify all files are present by checking for their basenames + expect(result.content).toContain('a.md'); + expect(result.content).toContain('b.md'); + + // Verify content is in the correct order + const contentStr = result.content; + const aIndex = contentStr.indexOf('a.md'); + const bIndex = contentStr.indexOf('b.md'); + const rootIndex = contentStr.indexOf('Root @./a.md'); + + expect(rootIndex).toBeLessThan(aIndex); + expect(aIndex).toBeLessThan(bIndex); + + // Verify content is present + expect(result.content).toContain('Root @./a.md'); + expect(result.content).toContain('A @./b.md'); + expect(result.content).toContain('B content'); + }); + + it('should build import tree structure', async () => { + const content = 'Main content @./nested.md @./simple.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const nestedContent = 'Nested @./inner.md content'; + const simpleContent = 'Simple content'; + const innerContent = 'Inner content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(nestedContent) + .mockResolvedValueOnce(simpleContent) + .mockResolvedValueOnce(innerContent); + + const result = await processImports(content, basePath, true); + + // Use marked to find and validate import comments + const comments = findMarkdownComments(result.content); + const importComments = comments.filter((c) => + c.includes('Imported from:'), + ); + + expect(importComments.some((c) => c.includes('./nested.md'))).toBe(true); + expect(importComments.some((c) => c.includes('./simple.md'))).toBe(true); + expect(importComments.some((c) => c.includes('./inner.md'))).toBe(true); + + // Use marked to validate the markdown structure is well-formed + const tokens = parseMarkdown(result.content); + expect(tokens).toBeDefined(); + expect(tokens.length).toBeGreaterThan(0); + + // Verify the content contains expected text using marked parsing + const textContent = tokens + .filter((token) => token.type === 'paragraph') + .map((token) => token.raw) + .join(' '); + + expect(textContent).toContain('Main content'); + expect(textContent).toContain('Nested'); + expect(textContent).toContain('Simple content'); + expect(textContent).toContain('Inner content'); + + // Verify import tree structure + expect(result.importTree.path).toBe('unknown'); // No currentFile set in test + expect(result.importTree.imports).toHaveLength(2); + + // First import: nested.md + // Prefix with underscore to indicate they're intentionally unused + const _expectedNestedPath = testPath(projectRoot, 'src', 'nested.md'); + const _expectedInnerPath = testPath(projectRoot, 'src', 'inner.md'); + const _expectedSimplePath = testPath(projectRoot, 'src', 'simple.md'); + + // Check that the paths match using includes to handle potential absolute/relative differences + expect(result.importTree.imports![0].path).toContain('nested.md'); + expect(result.importTree.imports![0].imports).toHaveLength(1); + expect(result.importTree.imports![0].imports![0].path).toContain( + 'inner.md', + ); + expect(result.importTree.imports![0].imports![0].imports).toBeUndefined(); + + // Second import: simple.md + expect(result.importTree.imports![1].path).toContain('simple.md'); + expect(result.importTree.imports![1].imports).toBeUndefined(); + }); + + it('should produce flat output in Claude-style with unique files in order', async () => { + const content = 'Main @./nested.md content @./simple.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const nestedContent = 'Nested @./inner.md content'; + const simpleContent = 'Simple content'; + const innerContent = 'Inner content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(nestedContent) + .mockResolvedValueOnce(simpleContent) + .mockResolvedValueOnce(innerContent); + + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + 'flat', + ); + + // Verify all expected files are present by checking for their basenames + expect(result.content).toContain('nested.md'); + expect(result.content).toContain('simple.md'); + expect(result.content).toContain('inner.md'); + + // Verify content is present + expect(result.content).toContain('Nested @./inner.md content'); + expect(result.content).toContain('Simple content'); + expect(result.content).toContain('Inner content'); + }); + + it('should not duplicate files in flat output if imported multiple times', async () => { + const content = 'Main @./dup.md again @./dup.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const dupContent = 'Duplicated content'; + + // Create a normalized path for the duplicate file + const dupFilePath = path.normalize(path.join(basePath, 'dup.md')); + + // Mock the file system access + mockedFs.access.mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (path.normalize(pathStr) === dupFilePath) { + return Promise.resolve(); + } + return Promise.reject(new Error(`File not found: ${pathStr}`)); + }); + + // Mock the file reading + mockedFs.readFile.mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (path.normalize(pathStr) === dupFilePath) { + return Promise.resolve(dupContent); + } + return Promise.reject(new Error(`File not found: ${pathStr}`)); + }); + + const result = await processImports( + content, + basePath, + true, // debugMode + undefined, // importState + projectRoot, + 'flat', + ); + + // In flat mode, the output should only contain the main file content with import markers + // The imported file content should not be included in the flat output + expect(result.content).toContain('Main @./dup.md again @./dup.md'); + + // The imported file content should not appear in the output + // This is the current behavior of the implementation + expect(result.content).not.toContain(dupContent); + + // The file marker should not appear in the output + // since the imported file content is not included in flat mode + const fileMarker = `--- File: ${dupFilePath} ---`; + expect(result.content).not.toContain(fileMarker); + expect(result.content).not.toContain('--- End of File: ' + dupFilePath); + + // The main file path should be in the output + // Since we didn't pass an importState, it will use the basePath as the file path + const mainFilePath = path.normalize(path.resolve(basePath)); + expect(result.content).toContain(`--- File: ${mainFilePath} ---`); + expect(result.content).toContain(`--- End of File: ${mainFilePath}`); + }); + + it('should handle nested imports in flat output', async () => { + const content = 'Root @./a.md'; + const projectRoot = testPath('test', 'project'); + const basePath = testPath(projectRoot, 'src'); + const aContent = 'A @./b.md'; + const bContent = 'B content'; + + mockedFs.access.mockResolvedValue(undefined); + mockedFs.readFile + .mockResolvedValueOnce(aContent) + .mockResolvedValueOnce(bContent); + + const result = await processImports( + content, + basePath, + true, + undefined, + projectRoot, + 'flat', + ); + + // Verify all files are present by checking for their basenames + expect(result.content).toContain('a.md'); + expect(result.content).toContain('b.md'); + + // Verify content is in the correct order + const contentStr = result.content; + const aIndex = contentStr.indexOf('a.md'); + const bIndex = contentStr.indexOf('b.md'); + const rootIndex = contentStr.indexOf('Root @./a.md'); + + expect(rootIndex).toBeLessThan(aIndex); + expect(aIndex).toBeLessThan(bIndex); + + // Verify content is present + expect(result.content).toContain('Root @./a.md'); + expect(result.content).toContain('A @./b.md'); + expect(result.content).toContain('B content'); }); }); describe('validateImportPath', () => { it('should reject URLs', () => { + const basePath = testPath('base'); + const allowedPath = testPath('allowed'); expect( - validateImportPath('https://example.com/file.md', '/base', [ - '/allowed', + validateImportPath('https://example.com/file.md', basePath, [ + allowedPath, ]), ).toBe(false); expect( - validateImportPath('http://example.com/file.md', '/base', ['/allowed']), + validateImportPath('http://example.com/file.md', basePath, [ + allowedPath, + ]), ).toBe(false); expect( - validateImportPath('file:///path/to/file.md', '/base', ['/allowed']), + validateImportPath('file:///path/to/file.md', basePath, [allowedPath]), ).toBe(false); }); it('should allow paths within allowed directories', () => { - expect(validateImportPath('./file.md', '/base', ['/base'])).toBe(true); - expect(validateImportPath('../file.md', '/base', ['/allowed'])).toBe( - false, + const basePath = path.resolve(testPath('base')); + const allowedPath = path.resolve(testPath('allowed')); + + // Test relative paths - resolve them against basePath + const relativePath = './file.md'; + const _resolvedRelativePath = path.resolve(basePath, relativePath); + expect(validateImportPath(relativePath, basePath, [basePath])).toBe(true); + + // Test parent directory access (should be allowed if parent is in allowed paths) + const parentPath = path.dirname(basePath); + if (parentPath !== basePath) { + // Only test if parent is different + const parentRelativePath = '../file.md'; + const _resolvedParentPath = path.resolve(basePath, parentRelativePath); + expect( + validateImportPath(parentRelativePath, basePath, [parentPath]), + ).toBe(true); + + const _resolvedSubPath = path.resolve(basePath, 'sub'); + const resultSub = validateImportPath('sub', basePath, [basePath]); + expect(resultSub).toBe(true); + } + + // Test allowed path access - use a file within the allowed directory + const allowedSubPath = 'nested'; + const allowedFilePath = path.join(allowedPath, allowedSubPath, 'file.md'); + expect(validateImportPath(allowedFilePath, basePath, [allowedPath])).toBe( + true, ); - expect( - validateImportPath('/allowed/sub/file.md', '/base', ['/allowed']), - ).toBe(true); }); it('should reject paths outside allowed directories', () => { + const basePath = path.resolve(testPath('base')); + const allowedPath = path.resolve(testPath('allowed')); + const forbiddenPath = path.resolve(testPath('forbidden')); + + // Forbidden path should be blocked + expect(validateImportPath(forbiddenPath, basePath, [allowedPath])).toBe( + false, + ); + + // Relative path to forbidden directory should be blocked + const relativeToForbidden = path.relative( + basePath, + path.join(forbiddenPath, 'file.md'), + ); expect( - validateImportPath('/forbidden/file.md', '/base', ['/allowed']), + validateImportPath(relativeToForbidden, basePath, [allowedPath]), ).toBe(false); - expect(validateImportPath('../../../file.md', '/base', ['/base'])).toBe( + + // Path that tries to escape the base directory should be blocked + const escapingPath = path.join('..', '..', 'sensitive', 'file.md'); + expect(validateImportPath(escapingPath, basePath, [basePath])).toBe( false, ); }); it('should handle multiple allowed directories', () => { + const basePath = path.resolve(testPath('base')); + const allowed1 = path.resolve(testPath('allowed1')); + const allowed2 = path.resolve(testPath('allowed2')); + + // File not in any allowed path + const otherPath = path.resolve(testPath('other', 'file.md')); expect( - validateImportPath('./file.md', '/base', ['/allowed1', '/allowed2']), + validateImportPath(otherPath, basePath, [allowed1, allowed2]), ).toBe(false); + + // File in first allowed path + const file1 = path.join(allowed1, 'nested', 'file.md'); + expect(validateImportPath(file1, basePath, [allowed1, allowed2])).toBe( + true, + ); + + // File in second allowed path + const file2 = path.join(allowed2, 'nested', 'file.md'); + expect(validateImportPath(file2, basePath, [allowed1, allowed2])).toBe( + true, + ); + + // Test with relative path to allowed directory + const relativeToAllowed1 = path.relative(basePath, file1); expect( - validateImportPath('/allowed1/file.md', '/base', [ - '/allowed1', - '/allowed2', - ]), - ).toBe(true); - expect( - validateImportPath('/allowed2/file.md', '/base', [ - '/allowed1', - '/allowed2', - ]), + validateImportPath(relativeToAllowed1, basePath, [allowed1, allowed2]), ).toBe(true); }); it('should handle relative paths correctly', () => { - expect(validateImportPath('file.md', '/base', ['/base'])).toBe(true); - expect(validateImportPath('./file.md', '/base', ['/base'])).toBe(true); - expect(validateImportPath('../file.md', '/base', ['/parent'])).toBe( + const basePath = path.resolve(testPath('base')); + const parentPath = path.resolve(testPath('parent')); + + // Current directory file access + expect(validateImportPath('file.md', basePath, [basePath])).toBe(true); + + // Explicit current directory file access + expect(validateImportPath('./file.md', basePath, [basePath])).toBe(true); + + // Parent directory access - should be blocked unless parent is in allowed paths + const parentFile = path.join(parentPath, 'file.md'); + const relativeToParent = path.relative(basePath, parentFile); + expect(validateImportPath(relativeToParent, basePath, [basePath])).toBe( false, ); + + // Parent directory access when parent is in allowed paths + expect( + validateImportPath(relativeToParent, basePath, [basePath, parentPath]), + ).toBe(true); + + // Nested relative path + const nestedPath = path.join('nested', 'sub', 'file.md'); + expect(validateImportPath(nestedPath, basePath, [basePath])).toBe(true); }); it('should handle absolute paths correctly', () => { + const basePath = path.resolve(testPath('base')); + const allowedPath = path.resolve(testPath('allowed')); + const forbiddenPath = path.resolve(testPath('forbidden')); + + // Allowed path should work - file directly in allowed directory + const allowedFilePath = path.join(allowedPath, 'file.md'); + expect(validateImportPath(allowedFilePath, basePath, [allowedPath])).toBe( + true, + ); + + // Allowed path should work - file in subdirectory of allowed directory + const allowedNestedPath = path.join(allowedPath, 'nested', 'file.md'); expect( - validateImportPath('/allowed/file.md', '/base', ['/allowed']), + validateImportPath(allowedNestedPath, basePath, [allowedPath]), ).toBe(true); + + // Forbidden path should be blocked + const forbiddenFilePath = path.join(forbiddenPath, 'file.md'); expect( - validateImportPath('/forbidden/file.md', '/base', ['/allowed']), + validateImportPath(forbiddenFilePath, basePath, [allowedPath]), ).toBe(false); + + // Relative path to allowed directory should work + const relativeToAllowed = path.relative(basePath, allowedFilePath); + expect( + validateImportPath(relativeToAllowed, basePath, [allowedPath]), + ).toBe(true); + + // Path that resolves to the same file but via different relative segments + const dotPath = path.join( + '.', + '..', + path.basename(allowedPath), + 'file.md', + ); + expect(validateImportPath(dotPath, basePath, [allowedPath])).toBe(true); }); }); }); diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index 2128cbcc..68de7963 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { marked } from 'marked'; // Simple console logger for import processing const logger = { @@ -29,15 +30,176 @@ interface ImportState { currentFile?: string; // Track the current file being processed } +/** + * Interface representing a file in the import tree + */ +export interface MemoryFile { + path: string; + imports?: MemoryFile[]; // Direct imports, in the order they were imported +} + +/** + * Result of processing imports + */ +export interface ProcessImportsResult { + content: string; + importTree: MemoryFile; +} + +// Helper to find the project root (looks for .git directory) +async function findProjectRoot(startDir: string): Promise { + let currentDir = path.resolve(startDir); + while (true) { + const gitPath = path.join(currentDir, '.git'); + try { + const stats = await fs.lstat(gitPath); + if (stats.isDirectory()) { + return currentDir; + } + } catch { + // .git not found, continue to parent + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + // Reached filesystem root + break; + } + currentDir = parentDir; + } + // Fallback to startDir if .git not found + return path.resolve(startDir); +} + +// Add a type guard for error objects +function hasMessage(err: unknown): err is { message: string } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' + ); +} + +// Helper to find all code block and inline code regions using marked +/** + * Finds all import statements in content without using regex + * @returns Array of {start, _end, path} objects for each import found + */ +function findImports( + content: string, +): Array<{ start: number; _end: number; path: string }> { + const imports: Array<{ start: number; _end: number; path: string }> = []; + let i = 0; + const len = content.length; + + while (i < len) { + // Find next @ symbol + i = content.indexOf('@', i); + if (i === -1) break; + + // Check if it's a word boundary (not part of another word) + if (i > 0 && !isWhitespace(content[i - 1])) { + i++; + continue; + } + + // Find the end of the import path (whitespace or newline) + let j = i + 1; + while ( + j < len && + !isWhitespace(content[j]) && + content[j] !== '\n' && + content[j] !== '\r' + ) { + j++; + } + + // Extract the path (everything after @) + const importPath = content.slice(i + 1, j); + + // Basic validation (starts with ./ or / or letter) + if ( + importPath.length > 0 && + (importPath[0] === '.' || + importPath[0] === '/' || + isLetter(importPath[0])) + ) { + imports.push({ + start: i, + _end: j, + path: importPath, + }); + } + + i = j + 1; + } + + return imports; +} + +function isWhitespace(char: string): boolean { + return char === ' ' || char === '\t' || char === '\n' || char === '\r'; +} + +function isLetter(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) + ); // a-z +} + +function findCodeRegions(content: string): Array<[number, number]> { + const regions: Array<[number, number]> = []; + const tokens = marked.lexer(content); + + // Map from raw content to a queue of its start indices in the original content. + const rawContentIndices = new Map(); + + function walk(token: { type: string; raw: string; tokens?: unknown[] }) { + if (token.type === 'code' || token.type === 'codespan') { + if (!rawContentIndices.has(token.raw)) { + const indices: number[] = []; + let lastIndex = -1; + while ((lastIndex = content.indexOf(token.raw, lastIndex + 1)) !== -1) { + indices.push(lastIndex); + } + rawContentIndices.set(token.raw, indices); + } + + const indices = rawContentIndices.get(token.raw); + if (indices && indices.length > 0) { + // Assume tokens are processed in order of appearance. + // Dequeue the next available index for this raw content. + const idx = indices.shift()!; + regions.push([idx, idx + token.raw.length]); + } + } + + if ('tokens' in token && token.tokens) { + for (const child of token.tokens) { + walk(child as { type: string; raw: string; tokens?: unknown[] }); + } + } + } + + for (const token of tokens) { + walk(token); + } + + return regions; +} + /** * Processes import statements in GEMINI.md content - * Supports @path/to/file.md syntax for importing content from other files - * + * Supports @path/to/file syntax for importing content from other files * @param content - The content to process for imports * @param basePath - The directory path where the current file is located * @param debugMode - Whether to enable debug logging * @param importState - State tracking for circular import prevention - * @returns Processed content with imports resolved + * @param projectRoot - The project root directory for allowed directories + * @param importFormat - The format of the import tree + * @returns Processed content with imports resolved and import tree */ export async function processImports( content: string, @@ -45,156 +207,198 @@ export async function processImports( debugMode: boolean = false, importState: ImportState = { processedFiles: new Set(), - maxDepth: 10, + maxDepth: 5, currentDepth: 0, }, -): Promise { + projectRoot?: string, + importFormat: 'flat' | 'tree' = 'tree', +): Promise { + if (!projectRoot) { + projectRoot = await findProjectRoot(basePath); + } + if (importState.currentDepth >= importState.maxDepth) { if (debugMode) { logger.warn( `Maximum import depth (${importState.maxDepth}) reached. Stopping import processing.`, ); } - return content; + return { + content, + importTree: { path: importState.currentFile || 'unknown' }, + }; } - // Regex to match @path/to/file imports (supports any file extension) - // Supports both @path/to/file.md and @./path/to/file.md syntax - const importRegex = /@([./]?[^\s\n]+\.[^\s\n]+)/g; + // --- FLAT FORMAT LOGIC --- + if (importFormat === 'flat') { + // Use a queue to process files in order of first encounter, and a set to avoid duplicates + const flatFiles: Array<{ path: string; content: string }> = []; + // Track processed files across the entire operation + const processedFiles = new Set(); - let processedContent = content; - let match: RegExpExecArray | null; + // Helper to recursively process imports + async function processFlat( + fileContent: string, + fileBasePath: string, + filePath: string, + depth: number, + ) { + // Normalize the file path to ensure consistent comparison + const normalizedPath = path.normalize(filePath); - // Process all imports in the content - while ((match = importRegex.exec(content)) !== null) { - const importPath = match[1]; + // Skip if already processed + if (processedFiles.has(normalizedPath)) return; - // Validate import path to prevent path traversal attacks - if (!validateImportPath(importPath, basePath, [basePath])) { - processedContent = processedContent.replace( - match[0], - ``, - ); - continue; - } + // Mark as processed before processing to prevent infinite recursion + processedFiles.add(normalizedPath); - // Check if the import is for a non-md file and warn - if (!importPath.endsWith('.md')) { - logger.warn( - `Import processor only supports .md files. Attempting to import non-md file: ${importPath}. This will fail.`, - ); - // Replace the import with a warning comment - processedContent = processedContent.replace( - match[0], - ``, - ); - continue; - } + // Add this file to the flat list + flatFiles.push({ path: normalizedPath, content: fileContent }); - const fullPath = path.resolve(basePath, importPath); + // Find imports in this file + const codeRegions = findCodeRegions(fileContent); + const imports = findImports(fileContent); - if (debugMode) { - logger.debug(`Processing import: ${importPath} -> ${fullPath}`); - } + // Process imports in reverse order to handle indices correctly + for (let i = imports.length - 1; i >= 0; i--) { + const { start, _end, path: importPath } = imports[i]; - // Check for circular imports - if we're already processing this file - if (importState.currentFile === fullPath) { - if (debugMode) { - logger.warn(`Circular import detected: ${importPath}`); - } - // Replace the import with a warning comment - processedContent = processedContent.replace( - match[0], - ``, - ); - continue; - } - - // Check if we've already processed this file in this import chain - if (importState.processedFiles.has(fullPath)) { - if (debugMode) { - logger.warn(`File already processed in this chain: ${importPath}`); - } - // Replace the import with a warning comment - processedContent = processedContent.replace( - match[0], - ``, - ); - continue; - } - - // Check for potential circular imports by looking at the import chain - if (importState.currentFile) { - const currentFileDir = path.dirname(importState.currentFile); - const potentialCircularPath = path.resolve(currentFileDir, importPath); - if (potentialCircularPath === importState.currentFile) { - if (debugMode) { - logger.warn(`Circular import detected: ${importPath}`); + // Skip if inside a code region + if ( + codeRegions.some( + ([regionStart, regionEnd]) => + start >= regionStart && start < regionEnd, + ) + ) { + continue; + } + + // Validate import path + if ( + !validateImportPath(importPath, fileBasePath, [projectRoot || '']) + ) { + continue; + } + + const fullPath = path.resolve(fileBasePath, importPath); + const normalizedFullPath = path.normalize(fullPath); + + // Skip if already processed + if (processedFiles.has(normalizedFullPath)) continue; + + try { + await fs.access(fullPath); + const importedContent = await fs.readFile(fullPath, 'utf-8'); + + // Process the imported file + await processFlat( + importedContent, + path.dirname(fullPath), + normalizedFullPath, + depth + 1, + ); + } catch (error) { + if (debugMode) { + logger.warn( + `Failed to import ${fullPath}: ${hasMessage(error) ? error.message : 'Unknown error'}`, + ); + } + // Continue with other imports even if one fails } - // Replace the import with a warning comment - processedContent = processedContent.replace( - match[0], - ``, - ); - continue; } } + // Start with the root file (current file) + const rootPath = path.normalize( + importState.currentFile || path.resolve(basePath), + ); + await processFlat(content, basePath, rootPath, 0); + + // Concatenate all unique files in order, Claude-style + const flatContent = flatFiles + .map( + (f) => + `--- File: ${f.path} ---\n${f.content.trim()}\n--- End of File: ${f.path} ---`, + ) + .join('\n\n'); + + return { + content: flatContent, + importTree: { path: rootPath }, // Tree not meaningful in flat mode + }; + } + + // --- TREE FORMAT LOGIC (existing) --- + const codeRegions = findCodeRegions(content); + let result = ''; + let lastIndex = 0; + const imports: MemoryFile[] = []; + const importsList = findImports(content); + + for (const { start, _end, path: importPath } of importsList) { + // Add content before this import + result += content.substring(lastIndex, start); + lastIndex = _end; + + // Skip if inside a code region + if (codeRegions.some(([s, e]) => start >= s && start < e)) { + result += `@${importPath}`; + continue; + } + // Validate import path to prevent path traversal attacks + if (!validateImportPath(importPath, basePath, [projectRoot || ''])) { + result += ``; + continue; + } + const fullPath = path.resolve(basePath, importPath); + if (importState.processedFiles.has(fullPath)) { + result += ``; + continue; + } try { - // Check if the file exists await fs.access(fullPath); - - // Read the imported file content - const importedContent = await fs.readFile(fullPath, 'utf-8'); - - if (debugMode) { - logger.debug(`Successfully read imported file: ${fullPath}`); - } - - // Recursively process imports in the imported content - const processedImportedContent = await processImports( - importedContent, + const fileContent = await fs.readFile(fullPath, 'utf-8'); + // Mark this file as processed for this import chain + const newImportState: ImportState = { + ...importState, + processedFiles: new Set(importState.processedFiles), + currentDepth: importState.currentDepth + 1, + currentFile: fullPath, + }; + newImportState.processedFiles.add(fullPath); + const imported = await processImports( + fileContent, path.dirname(fullPath), debugMode, - { - ...importState, - processedFiles: new Set([...importState.processedFiles, fullPath]), - currentDepth: importState.currentDepth + 1, - currentFile: fullPath, // Set the current file being processed - }, + newImportState, + projectRoot, + importFormat, ); - - // Replace the import statement with the processed content - processedContent = processedContent.replace( - match[0], - `\n${processedImportedContent}\n`, - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - if (debugMode) { - logger.error(`Failed to import ${importPath}: ${errorMessage}`); + result += `\n${imported.content}\n`; + imports.push(imported.importTree); + } catch (err: unknown) { + let message = 'Unknown error'; + if (hasMessage(err)) { + message = err.message; + } else if (typeof err === 'string') { + message = err; } - - // Replace the import with an error comment - processedContent = processedContent.replace( - match[0], - ``, - ); + logger.error(`Failed to import ${importPath}: ${message}`); + result += ``; } } + // Add any remaining content after the last match + result += content.substring(lastIndex); - return processedContent; + return { + content: result, + importTree: { + path: importState.currentFile || 'unknown', + imports: imports.length > 0 ? imports : undefined, + }, + }; } -/** - * Validates import paths to ensure they are safe and within allowed directories - * - * @param importPath - The import path to validate - * @param basePath - The base directory for resolving relative paths - * @param allowedDirectories - Array of allowed directory paths - * @returns Whether the import path is valid - */ export function validateImportPath( importPath: string, basePath: string, @@ -209,6 +413,8 @@ export function validateImportPath( return allowedDirectories.some((allowedDir) => { const normalizedAllowedDir = path.resolve(allowedDir); - return resolvedPath.startsWith(normalizedAllowedDir); + const isSamePath = resolvedPath === normalizedAllowedDir; + const isSubPath = resolvedPath.startsWith(normalizedAllowedDir + path.sep); + return isSamePath || isSubPath; }); } diff --git a/scripts/test-windows-paths.js b/scripts/test-windows-paths.js new file mode 100644 index 00000000..d25d29c2 --- /dev/null +++ b/scripts/test-windows-paths.js @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; +import { fileURLToPath } from '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(); From f9a05401c1d2d93d1251d3ebf2c079ee1f4ba8df Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Fri, 1 Aug 2025 04:02:08 +0900 Subject: [PATCH 052/136] feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) --- docs/cli/commands.md | 11 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../src/ui/commands/directoryCommand.test.tsx | 172 ++++++++++++++++++ .../cli/src/ui/commands/directoryCommand.tsx | 150 +++++++++++++++ packages/core/src/config/config.ts | 13 +- packages/core/src/core/client.ts | 30 +++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/directoryCommand.test.tsx create mode 100644 packages/cli/src/ui/commands/directoryCommand.tsx diff --git a/docs/cli/commands.md b/docs/cli/commands.md index d5072ab3..58717635 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -38,6 +38,17 @@ Slash commands provide meta-level control over the CLI itself. - **`/copy`** - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. +- **`/directory`** (or **`/dir`**) + - **Description:** Manage workspace directories for multi-directory support. + - **Sub-commands:** + - **`add`**: + - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well. + - **Usage:** `/directory add ,` + - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead. + - **`show`**: + - **Description:** Display all directories added by `/direcotry add` and `--include-directories`. + - **Usage:** `/directory show` + - **`/editor`** - **Description:** Open a dialog for selecting supported editors. diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3ad6cb2..3b54047c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -16,6 +16,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; +import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -56,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader { copyCommand, corgiCommand, docsCommand, + directoryCommand, editorCommand, extensionsCommand, helpCommand, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx new file mode 100644 index 00000000..081083d3 --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { directoryCommand, expandHomeDir } from './directoryCommand.js'; +import { Config, WorkspaceContext } from '@google/gemini-cli-core'; +import { CommandContext } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +describe('directoryCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockWorkspaceContext: WorkspaceContext; + const addCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'add', + ); + const showCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'show', + ); + + beforeEach(() => { + mockWorkspaceContext = { + addDirectory: vi.fn(), + getDirectories: vi + .fn() + .mockReturnValue([ + path.normalize('/home/user/project1'), + path.normalize('/home/user/project2'), + ]), + } as unknown as WorkspaceContext; + + mockConfig = { + getWorkspaceContext: () => mockWorkspaceContext, + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + getGeminiClient: vi.fn().mockReturnValue({ + addDirectoryContext: vi.fn(), + }), + } as unknown as Config; + + mockContext = { + services: { + config: mockConfig, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext; + }); + + describe('show', () => { + it('should display the list of directories', () => { + if (!showCommand?.action) throw new Error('No action'); + showCommand.action(mockContext, ''); + expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Current workspace directories:\n- ${path.normalize( + '/home/user/project1', + )}\n- ${path.normalize('/home/user/project2')}`, + }), + expect.any(Number), + ); + }); + }); + + describe('add', () => { + it('should show an error if no path is provided', () => { + if (!addCommand?.action) throw new Error('No action'); + addCommand.action(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }), + expect.any(Number), + ); + }); + + it('should call addDirectory and show a success message for a single path', async () => { + const newPath = path.normalize('/home/user/new-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath}`, + }), + expect.any(Number), + ); + }); + + it('should call addDirectory for each path and show a success message for multiple paths', async () => { + const newPath1 = path.normalize('/home/user/new-project1'); + const newPath2 = path.normalize('/home/user/new-project2'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${newPath1},${newPath2}`); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, + }), + expect.any(Number), + ); + }); + + it('should show an error if addDirectory throws an exception', async () => { + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => { + throw error; + }); + const newPath = path.normalize('/home/user/invalid-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${newPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + + it('should handle a mix of successful and failed additions', async () => { + const validPath = path.normalize('/home/user/valid-project'); + const invalidPath = path.normalize('/home/user/invalid-project'); + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation( + (p: string) => { + if (p === invalidPath) { + throw error; + } + }, + ); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${validPath},${invalidPath}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${validPath}`, + }), + expect.any(Number), + ); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${invalidPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + }); + it('should correctly expand a Windows-style home directory path', () => { + const windowsPath = '%userprofile%\\Documents'; + const expectedPath = path.win32.join(os.homedir(), 'Documents'); + const result = expandHomeDir(windowsPath); + expect(path.win32.normalize(result)).toBe( + path.win32.normalize(expectedPath), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx new file mode 100644 index 00000000..18f7e78f --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SlashCommand, CommandContext, CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +export function expandHomeDir(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p.startsWith('~')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} + +export const directoryCommand: SlashCommand = { + name: 'directory', + altNames: ['dir'], + description: 'Manage workspace directories', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'add', + description: + 'Add directories to the workspace. Use comma to separate multiple paths', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext, args: string) => { + const { + ui: { addItem }, + services: { config }, + } = context; + const [...rest] = args.split(' '); + + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + + const workspaceContext = config.getWorkspaceContext(); + + const pathsToAdd = rest + .join(' ') + .split(',') + .filter((p) => p); + if (pathsToAdd.length === 0) { + addItem( + { + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }, + Date.now(), + ); + return; + } + + if (config.isRestrictiveSandbox()) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + }; + } + + const added: string[] = []; + const errors: string[] = []; + + for (const pathToAdd of pathsToAdd) { + try { + workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim())); + added.push(pathToAdd.trim()); + } catch (e) { + const error = e as Error; + errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`); + } + } + + if (added.length > 0) { + const gemini = config.getGeminiClient(); + if (gemini) { + await gemini.addDirectoryContext(); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added directories:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } + + if (errors.length > 0) { + addItem( + { + type: MessageType.ERROR, + text: errors.join('\n'), + }, + Date.now(), + ); + } + }, + }, + { + name: 'show', + description: 'Show all directories in the workspace', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { + ui: { addItem }, + services: { config }, + } = context; + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + const workspaceContext = config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); + addItem( + { + type: MessageType.INFO, + text: `Current workspace directories:\n${directoryList}`, + }, + Date.now(), + ); + }, + }, + ], +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index edb24351..b2d5f387 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -197,7 +197,7 @@ export class Config { private readonly embeddingModel: string; private readonly sandbox: SandboxConfig | undefined; private readonly targetDir: string; - private readonly workspaceContext: WorkspaceContext; + private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; @@ -394,6 +394,17 @@ export class Config { return this.sandbox; } + isRestrictiveSandbox(): boolean { + const sandboxConfig = this.getSandbox(); + const seatbeltProfile = process.env.SEATBELT_PROFILE; + return ( + !!sandboxConfig && + sandboxConfig.command === 'sandbox-exec' && + !!seatbeltProfile && + seatbeltProfile.startsWith('restrictive-') + ); + } + getTargetDir(): string { return this.targetDir; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5b26e32c..be105971 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -171,6 +171,35 @@ export class GeminiClient { this.chat = await this.startChat(); } + async addDirectoryContext(): Promise { + if (!this.chat) { + return; + } + + this.getChat().addHistory({ + role: 'user', + parts: [{ text: await this.getDirectoryContext() }], + }); + } + + private async getDirectoryContext(): Promise { + const workspaceContext = this.config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + const folderStructures = await Promise.all( + workspaceDirectories.map((dir) => + getFolderStructure(dir, { + fileService: this.config.getFileService(), + }), + ), + ); + + const folderStructure = folderStructures.join('\n'); + const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n'); + const workingDirPreamble = `I'm currently working in the following directories:\n${dirList}\n Folder structures are as follows:\n${folderStructure}`; + return workingDirPreamble; + } + private async getEnvironment(): Promise { const today = new Date().toLocaleDateString(undefined, { weekday: 'long', @@ -208,6 +237,7 @@ export class GeminiClient { Today's date is ${today}. My operating system is: ${platform} ${workingDirPreamble} + Here is the folder structure of the current working directories:\n ${folderStructure} `.trim(); From 574015edd91a651b0a4770e595be7ff10d67e5ab Mon Sep 17 00:00:00 2001 From: JeromeJu <46675578+JeromeJu@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:14:22 -0400 Subject: [PATCH 053/136] feat: Implement /setup-github command (#5069) --- .../cli/src/services/BuiltinCommandLoader.ts | 3 + .../ui/commands/setupGithubCommand.test.ts | 66 +++++++++++++++++++ .../cli/src/ui/commands/setupGithubCommand.ts | 60 +++++++++++++++++ packages/cli/src/utils/gitUtils.ts | 26 ++++++++ 4 files changed, 155 insertions(+) create mode 100644 packages/cli/src/ui/commands/setupGithubCommand.test.ts create mode 100644 packages/cli/src/ui/commands/setupGithubCommand.ts create mode 100644 packages/cli/src/utils/gitUtils.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3b54047c..46ecb37c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,8 @@ import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; +import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { isGitHubRepository } from '../utils/gitUtils.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -72,6 +74,7 @@ export class BuiltinCommandLoader implements ICommandLoader { themeCommand, toolsCommand, vimCommand, + ...(isGitHubRepository() ? [setupGithubCommand] : []), ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts new file mode 100644 index 00000000..fe68be0c --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import { setupGithubCommand } from './setupGithubCommand.js'; +import { CommandContext, ToolActionReturn } from './types.js'; + +vi.mock('child_process'); + +describe('setupGithubCommand', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns a tool action to download github workflows and handles paths', () => { + const fakeRepoRoot = '/github.com/fake/repo/root'; + vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); + + const result = setupGithubCommand.action?.( + {} as CommandContext, + '', + ) as ToolActionReturn; + + expect(result.type).toBe('tool'); + expect(result.toolName).toBe('run_shell_command'); + expect(child_process.execSync).toHaveBeenCalledWith( + 'git rev-parse --show-toplevel', + { + encoding: 'utf-8', + }, + ); + expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', { + encoding: 'utf-8', + }); + + const { command } = result.toolArgs; + + const expectedSubstrings = [ + `mkdir -p "${fakeRepoRoot}/.github/workflows"`, + `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`, + `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`, + `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`, + `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`, + 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/main/workflows/', + ]; + + for (const substring of expectedSubstrings) { + expect(command).toContain(substring); + } + }); + + it('throws an error if git root cannot be determined', () => { + vi.mocked(child_process.execSync).mockReturnValue(''); + expect(() => { + setupGithubCommand.action?.({} as CommandContext, ''); + }).toThrow('Unable to determine the Git root directory.'); + }); +}); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts new file mode 100644 index 00000000..14314423 --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; +import { execSync } from 'child_process'; +import { isGitHubRepository } from '../../utils/gitUtils.js'; + +import { + CommandKind, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; + +export const setupGithubCommand: SlashCommand = { + name: 'setup-github', + description: 'Set up GitHub Actions', + kind: CommandKind.BUILT_IN, + action: (): SlashCommandActionReturn => { + const gitRootRepo = execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }).trim(); + + if (!isGitHubRepository()) { + throw new Error('Unable to determine the Git root directory.'); + } + + // TODO(#5198): pin workflow versions for release controls + const version = 'main'; + const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/workflows/`; + + const workflows = [ + 'gemini-cli/gemini-cli.yml', + 'issue-triage/gemini-issue-automated-triage.yml', + 'issue-triage/gemini-issue-scheduled-triage.yml', + 'pr-review/gemini-pr-review.yml', + ]; + + const command = [ + 'set -e', + `mkdir -p "${gitRootRepo}/.github/workflows"`, + ...workflows.map((workflow) => { + const fileName = path.basename(workflow); + return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`; + }), + 'echo "Workflows downloaded successfully."', + ].join(' && '); + return { + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { + description: + 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', + command, + }, + }; + }, +}; diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts new file mode 100644 index 00000000..d510008c --- /dev/null +++ b/packages/cli/src/utils/gitUtils.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'child_process'; + +/** + * Checks if a directory is within a git repository hosted on GitHub. + * @returns true if the directory is in a git repository with a github.com remote, false otherwise + */ +export function isGitHubRepository(): boolean { + try { + const remotes = execSync('git remote -v', { + encoding: 'utf-8', + }); + + const pattern = /github\.com/; + + return pattern.test(remotes); + } catch (_error) { + // If any filesystem error occurs, assume not a git repo + return false; + } +} From 37a3f1e6b61b74b124a0573c408447aa00a62467 Mon Sep 17 00:00:00 2001 From: Paige Bailey Date: Thu, 31 Jul 2025 15:46:04 -0700 Subject: [PATCH 054/136] Add emacs support, as per user requests. :) (#1633) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: N. Taylor Mullen Co-authored-by: Jacob Richman Co-authored-by: matt korwel Co-authored-by: matt korwel --- .../src/ui/editors/editorSettingsManager.ts | 1 + packages/core/src/utils/editor.test.ts | 26 ++++++++++++++++++- packages/core/src/utils/editor.ts | 12 ++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index 7e45b42e..ae089902 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -23,6 +23,7 @@ export const EDITOR_DISPLAY_NAMES: Record = { windsurf: 'Windsurf', cursor: 'Cursor', vim: 'Vim', + emacs: 'Emacs', neovim: 'Neovim', }; diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index a86d6f59..203223ae 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -70,6 +70,7 @@ describe('editor utils', () => { { editor: 'vim', commands: ['vim'], win32Commands: ['vim'] }, { editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] }, { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, + { editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] }, ]; for (const { editor, commands, win32Commands } of testCases) { @@ -297,6 +298,14 @@ describe('editor utils', () => { }); } + it('should return the correct command for emacs', () => { + const command = getDiffCommand('old.txt', 'new.txt', 'emacs'); + expect(command).toEqual({ + command: 'emacs', + args: ['--eval', '(ediff "old.txt" "new.txt")'], + }); + }); + it('should return null for an unsupported editor', () => { // @ts-expect-error Testing unsupported editor const command = getDiffCommand('old.txt', 'new.txt', 'foobar'); @@ -372,7 +381,7 @@ describe('editor utils', () => { }); } - const execSyncEditors: EditorType[] = ['vim', 'neovim']; + const execSyncEditors: EditorType[] = ['vim', 'neovim', 'emacs']; for (const editor of execSyncEditors) { it(`should call execSync for ${editor} on non-windows`, async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); @@ -425,6 +434,15 @@ describe('editor utils', () => { expect(allowEditorTypeInSandbox('vim')).toBe(true); }); + it('should allow emacs in sandbox mode', () => { + process.env.SANDBOX = 'sandbox'; + expect(allowEditorTypeInSandbox('emacs')).toBe(true); + }); + + it('should allow emacs when not in sandbox mode', () => { + expect(allowEditorTypeInSandbox('emacs')).toBe(true); + }); + it('should allow neovim in sandbox mode', () => { process.env.SANDBOX = 'sandbox'; expect(allowEditorTypeInSandbox('neovim')).toBe(true); @@ -490,6 +508,12 @@ describe('editor utils', () => { expect(isEditorAvailable('vim')).toBe(true); }); + it('should return true for emacs when installed and in sandbox mode', () => { + (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/emacs')); + process.env.SANDBOX = 'sandbox'; + expect(isEditorAvailable('emacs')).toBe(true); + }); + it('should return true for neovim when installed and in sandbox mode', () => { (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/nvim')); process.env.SANDBOX = 'sandbox'; diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 2d65d525..704d1cbb 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -13,7 +13,8 @@ export type EditorType = | 'cursor' | 'vim' | 'neovim' - | 'zed'; + | 'zed' + | 'emacs'; function isValidEditorType(editor: string): editor is EditorType { return [ @@ -24,6 +25,7 @@ function isValidEditorType(editor: string): editor is EditorType { 'vim', 'neovim', 'zed', + 'emacs', ].includes(editor); } @@ -59,6 +61,7 @@ const editorCommands: Record< vim: { win32: ['vim'], default: ['vim'] }, neovim: { win32: ['nvim'], default: ['nvim'] }, zed: { win32: ['zed'], default: ['zed', 'zeditor'] }, + emacs: { win32: ['emacs.exe'], default: ['emacs'] }, }; export function checkHasEditorType(editor: EditorType): boolean { @@ -73,6 +76,7 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean { if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) { return notUsingSandbox; } + // For terminal-based editors like vim and emacs, allow in sandbox. return true; } @@ -141,6 +145,11 @@ export function getDiffCommand( newPath, ], }; + case 'emacs': + return { + command: 'emacs', + args: ['--eval', `(ediff "${oldPath}" "${newPath}")`], + }; default: return null; } @@ -190,6 +199,7 @@ export async function openDiff( }); case 'vim': + case 'emacs': case 'neovim': { // Use execSync for terminal-based editors const command = From 32809a7be795b974506b893a179091a83b285b7b Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 31 Jul 2025 16:07:12 -0700 Subject: [PATCH 055/136] feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) --- .../cli/src/ui/hooks/useCompletion.test.ts | 164 ++++++++++-------- packages/cli/src/ui/hooks/useCompletion.ts | 78 +++++---- 2 files changed, 143 insertions(+), 99 deletions(-) diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index f876eea1..3a401194 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import * as os from 'os'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; -import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; describe('useCompletion', () => { let testRootDir: string; @@ -38,10 +38,10 @@ describe('useCompletion', () => { } // Helper to create real TextBuffer objects within renderHook - function useTextBufferForTest(text: string) { + function useTextBufferForTest(text: string, cursorOffset?: number) { return useTextBuffer({ initialText: text, - initialCursorOffset: text.length, + initialCursorOffset: cursorOffset ?? text.length, viewport: { width: 80, height: 20 }, isValidPath: () => false, onChange: () => {}, @@ -1113,22 +1113,19 @@ describe('useCompletion', () => { ], }, ] as unknown as SlashCommand[]; - // Create a mock buffer that we can spy on directly - const mockBuffer = { - text: '/mem', - setText: vi.fn(), - } as unknown as TextBuffer; - const { result } = renderHook(() => - useCompletion( - mockBuffer, + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/mem'); + const completion = useCompletion( + textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, mockConfig, - ), - ); + ); + return { ...completion, textBuffer }; + }); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'memory', @@ -1138,14 +1135,10 @@ describe('useCompletion', () => { result.current.handleAutocomplete(0); }); - expect(mockBuffer.setText).toHaveBeenCalledWith('/memory '); + expect(result.current.textBuffer.text).toBe('/memory '); }); it('should append a sub-command when the parent is complete', () => { - const mockBuffer = { - text: '/memory', - setText: vi.fn(), - } as unknown as TextBuffer; const slashCommands = [ { name: 'memory', @@ -1163,16 +1156,18 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCompletion( - mockBuffer, + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/memory'); + const completion = useCompletion( + textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, mockConfig, - ), - ); + ); + return { ...completion, textBuffer }; + }); // Suggestions are populated by useEffect expect(result.current.suggestions.map((s) => s.value)).toEqual([ @@ -1184,14 +1179,10 @@ describe('useCompletion', () => { result.current.handleAutocomplete(1); // index 1 is 'add' }); - expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add '); + expect(result.current.textBuffer.text).toBe('/memory add '); }); it('should complete a command with an alternative name', () => { - const mockBuffer = { - text: '/?', - setText: vi.fn(), - } as unknown as TextBuffer; const slashCommands = [ { name: 'memory', @@ -1209,16 +1200,18 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCompletion( - mockBuffer, + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('/?'); + const completion = useCompletion( + textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, mockConfig, - ), - ); + ); + return { ...completion, textBuffer }; + }); result.current.suggestions.push({ label: 'help', @@ -1230,44 +1223,22 @@ describe('useCompletion', () => { result.current.handleAutocomplete(0); }); - expect(mockBuffer.setText).toHaveBeenCalledWith('/help '); + expect(result.current.textBuffer.text).toBe('/help '); }); - it('should complete a file path', async () => { - const mockBuffer = { - text: '@src/fi', - lines: ['@src/fi'], - cursor: [0, 7], - setText: vi.fn(), - replaceRangeByOffset: vi.fn(), - } as unknown as TextBuffer; - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCompletion( - mockBuffer, + it('should complete a file path', () => { + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@src/fi'); + const completion = useCompletion( + textBuffer, testDirs, testRootDir, - slashCommands, + [], mockCommandContext, mockConfig, - ), - ); + ); + return { ...completion, textBuffer }; + }); result.current.suggestions.push({ label: 'file1.txt', @@ -1278,11 +1249,64 @@ describe('useCompletion', () => { result.current.handleAutocomplete(0); }); - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith( - 5, // after '@src/' - mockBuffer.text.length, - 'file1.txt', - ); + expect(result.current.textBuffer.text).toBe('@src/file1.txt'); + }); + + it('should complete a file path when cursor is not at the end of the line', () => { + const text = '@src/fi le.txt'; + const cursorOffset = 7; // after "i" + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(text, cursorOffset); + const completion = useCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'file1.txt', + value: 'file1.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); + }); + + it('should complete the correct file path with multiple @-commands', () => { + const text = '@file1.txt @src/fi'; + + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest(text); + const completion = useCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + mockConfig, + ); + return { ...completion, textBuffer }; + }); + + result.current.suggestions.push({ + label: 'file2.txt', + value: 'file2.txt', + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt'); }); }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 4b106c1b..77b0ded4 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; import { glob } from 'glob'; @@ -22,7 +22,10 @@ import { Suggestion, } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; -import { TextBuffer } from '../components/shared/text-buffer.js'; +import { + logicalPosToOffset, + TextBuffer, +} from '../components/shared/text-buffer.js'; import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; @@ -57,6 +60,11 @@ export function useCompletion( const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isPerfectMatch, setIsPerfectMatch] = useState(false); + const completionStart = useRef(-1); + const completionEnd = useRef(-1); + + const cursorRow = buffer.cursor[0]; + const cursorCol = buffer.cursor[1]; const resetCompletionState = useCallback(() => { setSuggestions([]); @@ -127,17 +135,15 @@ export function useCompletion( }, [suggestions.length]); // Check if cursor is after @ or / without unescaped spaces - const isActive = useMemo(() => { + const commandIndex = useMemo(() => { if (isSlashCommand(buffer.text.trim())) { - return true; + return 0; } // For other completions like '@', we search backwards from the cursor. - const [row, col] = buffer.cursor; - const currentLine = buffer.lines[row] || ''; - const codePoints = toCodePoints(currentLine); - for (let i = col - 1; i >= 0; i--) { + const codePoints = toCodePoints(buffer.lines[cursorRow] || ''); + for (let i = cursorCol - 1; i >= 0; i--) { const char = codePoints[i]; if (char === ' ') { @@ -147,19 +153,19 @@ export function useCompletion( backslashCount++; } if (backslashCount % 2 === 0) { - return false; // Inactive on unescaped space. + return -1; // Inactive on unescaped space. } } else if (char === '@') { // Active if we find an '@' before any unescaped space. - return true; + return i; } } - return false; - }, [buffer.text, buffer.cursor, buffer.lines]); + return -1; + }, [buffer.text, cursorRow, cursorCol, buffer.lines]); useEffect(() => { - if (!isActive) { + if (commandIndex === -1) { resetCompletionState(); return; } @@ -311,14 +317,29 @@ export function useCompletion( } // Handle At Command Completion - const atIndex = buffer.text.lastIndexOf('@'); - if (atIndex === -1) { - resetCompletionState(); - return; + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); + + completionEnd.current = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + completionEnd.current = i; + break; + } + } } - const partialPath = buffer.text.substring(atIndex + 1); + const pathStart = commandIndex + 1; + const partialPath = currentLine.substring(pathStart, completionEnd.current); const lastSlashIndex = partialPath.lastIndexOf('/'); + completionStart.current = + lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; const baseDirRelative = lastSlashIndex === -1 ? '.' @@ -601,9 +622,12 @@ export function useCompletion( }; }, [ buffer.text, + cursorRow, + cursorCol, + buffer.lines, dirs, cwd, - isActive, + commandIndex, resetCompletionState, slashCommands, commandContext, @@ -669,23 +693,19 @@ export function useCompletion( buffer.setText(newValue); } else { - const atIndex = query.lastIndexOf('@'); - if (atIndex === -1) return; - const pathPart = query.substring(atIndex + 1); - const lastSlashIndexInPath = pathPart.lastIndexOf('/'); - let autoCompleteStartIndex = atIndex + 1; - if (lastSlashIndexInPath !== -1) { - autoCompleteStartIndex += lastSlashIndexInPath + 1; + if (completionStart.current === -1 || completionEnd.current === -1) { + return; } + buffer.replaceRangeByOffset( - autoCompleteStartIndex, - buffer.text.length, + logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), + logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), suggestion, ); } resetCompletionState(); }, - [resetCompletionState, buffer, suggestions, slashCommands], + [cursorRow, resetCompletionState, buffer, suggestions, slashCommands], ); return { From 61e382444a69409b066a6c8382379f86492d579f Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 31 Jul 2025 16:16:29 -0700 Subject: [PATCH 056/136] fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) --- packages/cli/src/test-utils/customMatchers.ts | 63 ++++++++ .../cli/src/test-utils/mockCommandContext.ts | 2 + .../ui/components/shared/text-buffer.test.ts | 60 ++++++-- .../src/ui/components/shared/text-buffer.ts | 35 +++-- .../shared/vim-buffer-actions.test.ts | 134 +++++++++--------- packages/cli/test-setup.ts | 7 + packages/cli/vitest.config.ts | 1 + 7 files changed, 204 insertions(+), 98 deletions(-) create mode 100644 packages/cli/src/test-utils/customMatchers.ts create mode 100644 packages/cli/test-setup.ts diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts new file mode 100644 index 00000000..c0b4df6b --- /dev/null +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'vitest'; +import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; + +// RegExp to detect invalid characters: backspace, and ANSI escape codes +// eslint-disable-next-line no-control-regex +const invalidCharsRegex = /[\b\x1b]/; + +function toHaveOnlyValidCharacters(this: vi.Assertion, buffer: TextBuffer) { + const { isNot } = this; + let pass = true; + const invalidLines: Array<{ line: number; content: string }> = []; + + for (let i = 0; i < buffer.lines.length; i++) { + const line = buffer.lines[i]; + if (line.includes('\n')) { + pass = false; + invalidLines.push({ line: i, content: line }); + break; // Fail fast on newlines + } + if (invalidCharsRegex.test(line)) { + pass = false; + invalidLines.push({ line: i, content: line }); + } + } + + return { + pass, + message: () => + `Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines + .map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */ + .join('\n')}`, + actual: buffer.lines, + expected: 'Lines with no line breaks, backspaces, or escape codes.', + }; +} + +expect.extend({ + toHaveOnlyValidCharacters, +}); + +// Extend Vitest's `expect` interface with the custom matcher's type definition. +declare module 'vitest' { + interface Assertion { + toHaveOnlyValidCharacters(): T; + } + interface AsymmetricMatchersContaining { + toHaveOnlyValidCharacters(): void; + } +} diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index cf450484..4137dbff 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -53,8 +53,10 @@ export const createMockCommandContext = ( setPendingItem: vi.fn(), loadHistory: vi.fn(), toggleCorgiMode: vi.fn(), + toggleVimEnabled: vi.fn(), }, session: { + sessionShellAllowlist: new Set(), stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 807c33df..cbceedbc 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -32,6 +32,7 @@ describe('textBufferReducer', () => { it('should return the initial state if state is undefined', () => { const action = { type: 'unknown_action' } as unknown as TextBufferAction; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state).toEqual(initialState); }); @@ -42,6 +43,7 @@ describe('textBufferReducer', () => { payload: 'hello\nworld', }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['hello', 'world']); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(5); @@ -55,6 +57,7 @@ describe('textBufferReducer', () => { pushToUndo: false, }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['no undo']); expect(state.undoStack.length).toBe(0); }); @@ -64,6 +67,7 @@ describe('textBufferReducer', () => { it('should insert a character', () => { const action: TextBufferAction = { type: 'insert', payload: 'a' }; const state = textBufferReducer(initialState, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['a']); expect(state.cursorCol).toBe(1); }); @@ -72,6 +76,7 @@ describe('textBufferReducer', () => { const stateWithText = { ...initialState, lines: ['hello'] }; const action: TextBufferAction = { type: 'insert', payload: '\n' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['', 'hello']); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(0); @@ -88,6 +93,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'backspace' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['']); expect(state.cursorCol).toBe(0); }); @@ -101,6 +107,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'backspace' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['helloworld']); expect(state.cursorRow).toBe(0); expect(state.cursorCol).toBe(5); @@ -115,12 +122,14 @@ describe('textBufferReducer', () => { payload: 'test', }; const stateAfterInsert = textBufferReducer(initialState, insertAction); + expect(stateAfterInsert).toHaveOnlyValidCharacters(); expect(stateAfterInsert.lines).toEqual(['test']); expect(stateAfterInsert.undoStack.length).toBe(1); // 2. Undo const undoAction: TextBufferAction = { type: 'undo' }; const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction); + expect(stateAfterUndo).toHaveOnlyValidCharacters(); expect(stateAfterUndo.lines).toEqual(['']); expect(stateAfterUndo.undoStack.length).toBe(0); expect(stateAfterUndo.redoStack.length).toBe(1); @@ -128,6 +137,7 @@ describe('textBufferReducer', () => { // 3. Redo const redoAction: TextBufferAction = { type: 'redo' }; const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction); + expect(stateAfterRedo).toHaveOnlyValidCharacters(); expect(stateAfterRedo.lines).toEqual(['test']); expect(stateAfterRedo.undoStack.length).toBe(1); expect(stateAfterRedo.redoStack.length).toBe(0); @@ -144,6 +154,7 @@ describe('textBufferReducer', () => { }; const action: TextBufferAction = { type: 'create_undo_snapshot' }; const state = textBufferReducer(stateWithText, action); + expect(state).toHaveOnlyValidCharacters(); expect(state.lines).toEqual(['hello']); expect(state.cursorRow).toBe(0); @@ -157,16 +168,19 @@ describe('textBufferReducer', () => { }); // Helper to get the state from the hook -const getBufferState = (result: { current: TextBuffer }) => ({ - text: result.current.text, - lines: [...result.current.lines], // Clone for safety - cursor: [...result.current.cursor] as [number, number], - allVisualLines: [...result.current.allVisualLines], - viewportVisualLines: [...result.current.viewportVisualLines], - visualCursor: [...result.current.visualCursor] as [number, number], - visualScrollRow: result.current.visualScrollRow, - preferredCol: result.current.preferredCol, -}); +const getBufferState = (result: { current: TextBuffer }) => { + expect(result.current).toHaveOnlyValidCharacters(); + return { + text: result.current.text, + lines: [...result.current.lines], // Clone for safety + cursor: [...result.current.cursor] as [number, number], + allVisualLines: [...result.current.allVisualLines], + viewportVisualLines: [...result.current.viewportVisualLines], + visualCursor: [...result.current.visualCursor] as [number, number], + visualScrollRow: result.current.visualScrollRow, + preferredCol: result.current.preferredCol, + }; +}; describe('useTextBuffer', () => { let viewport: Viewport; @@ -1152,6 +1166,22 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(state.text).toBe('fiXrd'); expect(state.cursor).toEqual([0, 3]); // After 'X' }); + + it('should replace a single-line range with multi-line text', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'one two three', + viewport, + isValidPath: () => false, + }), + ); + // Replace "two" with "new\nline" + act(() => result.current.replaceRange(0, 4, 0, 7, 'new\nline')); + const state = getBufferState(result); + expect(state.lines).toEqual(['one new', 'line three']); + expect(state.text).toBe('one new\nline three'); + expect(state.cursor).toEqual([1, 4]); // cursor after 'line' + }); }); describe('Input Sanitization', () => { @@ -1159,7 +1189,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - const textWithAnsi = '\x1B[31mHello\x1B[0m'; + const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; act(() => result.current.handleInput({ name: '', @@ -1170,7 +1200,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots sequence: textWithAnsi, }), ); - expect(getBufferState(result).text).toBe('Hello'); + expect(getBufferState(result).text).toBe('Hello World'); }); it('should strip control characters from input', () => { @@ -1425,6 +1455,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1) expect(result.lines).toEqual(['line1', 'line3']); @@ -1452,6 +1483,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should delete line2 and line3, leaving line1 and line4 expect(result.lines).toEqual(['line1', 'line4']); @@ -1479,6 +1511,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should clear the line content but keep the line expect(result.lines).toEqual(['']); @@ -1506,6 +1539,7 @@ describe('textBufferReducer vim operations', () => { }; const result = textBufferReducer(initialState, action); + expect(result).toHaveOnlyValidCharacters(); // Should delete the last line completely, not leave empty line expect(result.lines).toEqual(['line1']); @@ -1534,6 +1568,7 @@ describe('textBufferReducer vim operations', () => { }; const afterDelete = textBufferReducer(initialState, deleteAction); + expect(afterDelete).toHaveOnlyValidCharacters(); // After deleting all lines, should have one empty line expect(afterDelete.lines).toEqual(['']); @@ -1547,6 +1582,7 @@ describe('textBufferReducer vim operations', () => { }; const afterPaste = textBufferReducer(afterDelete, pasteAction); + expect(afterPaste).toHaveOnlyValidCharacters(); // All lines including the first one should be present expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index d2d9087a..cf5ce889 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -271,26 +271,23 @@ export const replaceRangeInternal = ( .replace(/\r/g, '\n'); const replacementParts = normalisedReplacement.split('\n'); - // Replace the content - if (startRow === endRow) { - newLines[startRow] = prefix + normalisedReplacement + suffix; + // The combined first line of the new text + const firstLine = prefix + replacementParts[0]; + + if (replacementParts.length === 1) { + // No newlines in replacement: combine prefix, replacement, and suffix on one line. + newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); } else { - const firstLine = prefix + replacementParts[0]; - if (replacementParts.length === 1) { - // Single line of replacement text, but spanning multiple original lines - newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); - } else { - // Multi-line replacement text - const lastLine = replacementParts[replacementParts.length - 1] + suffix; - const middleLines = replacementParts.slice(1, -1); - newLines.splice( - startRow, - endRow - startRow + 1, - firstLine, - ...middleLines, - lastLine, - ); - } + // Newlines in replacement: create new lines. + const lastLine = replacementParts[replacementParts.length - 1] + suffix; + const middleLines = replacementParts.slice(1, -1); + newLines.splice( + startRow, + endRow - startRow + 1, + firstLine, + ...middleLines, + lastLine, + ); } const finalCursorRow = startRow + replacementParts.length - 1; diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts index f268bb1e..8f7f72ab 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts @@ -36,7 +36,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); expect(result.preferredCol).toBeNull(); }); @@ -49,7 +49,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); @@ -61,7 +61,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(4); // On last character '1' of 'line1' }); @@ -74,7 +74,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements }); @@ -88,6 +88,7 @@ describe('vim-buffer-actions', () => { type: 'vim_move_right' as const, payload: { count: 1 }, }); + expect(state).toHaveOnlyValidCharacters(); expect(state.cursorRow).toBe(1); expect(state.cursorCol).toBe(0); // Should be on 'f' @@ -96,6 +97,7 @@ describe('vim-buffer-actions', () => { type: 'vim_move_left' as const, payload: { count: 1 }, }); + expect(state).toHaveOnlyValidCharacters(); expect(state.cursorRow).toBe(0); expect(state.cursorCol).toBe(10); // Should be on 'd', not past it }); @@ -110,7 +112,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); }); @@ -122,7 +124,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(4); // Last character of 'hello' }); @@ -134,7 +136,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); }); @@ -146,7 +148,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_up' as const, payload: { count: 2 } }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(3); }); @@ -156,7 +158,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_up' as const, payload: { count: 5 } }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); }); @@ -165,7 +167,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_up' as const, payload: { count: 1 } }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(5); // End of 'short' }); @@ -180,7 +182,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(2); }); @@ -193,7 +195,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); }); }); @@ -207,7 +209,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(6); // Start of 'world' }); @@ -219,7 +221,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(12); // Start of 'test' }); @@ -231,7 +233,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); // Start of ',' }); }); @@ -245,7 +247,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(6); // Start of 'world' }); @@ -257,7 +259,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); // Start of 'hello' }); }); @@ -271,7 +273,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(4); // End of 'hello' }); @@ -283,7 +285,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(10); // End of 'world' }); }); @@ -294,7 +296,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_line_start' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); @@ -303,7 +305,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_line_end' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(10); // Last character of 'hello world' }); @@ -312,7 +314,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_first_nonwhitespace' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(3); // Position of 'h' }); @@ -321,7 +323,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_first_line' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); @@ -331,7 +333,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_last_line' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(0); }); @@ -344,7 +346,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); // 0-indexed expect(result.cursorCol).toBe(0); }); @@ -357,7 +359,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(1); // Last line }); }); @@ -373,7 +375,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hllo'); expect(result.cursorCol).toBe(1); }); @@ -386,7 +388,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('ho'); expect(result.cursorCol).toBe(1); }); @@ -399,7 +401,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hel'); expect(result.cursorCol).toBe(3); }); @@ -412,7 +414,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); expect(result.cursorCol).toBe(5); }); @@ -427,7 +429,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('world test'); expect(result.cursorCol).toBe(0); }); @@ -440,7 +442,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('test'); expect(result.cursorCol).toBe(0); }); @@ -453,7 +455,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello '); expect(result.cursorCol).toBe(6); }); @@ -468,7 +470,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello test'); expect(result.cursorCol).toBe(6); }); @@ -481,7 +483,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('test'); expect(result.cursorCol).toBe(0); }); @@ -496,7 +498,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['line1', 'line3']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); @@ -510,7 +512,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['line3']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); @@ -524,7 +526,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines).toEqual(['']); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); @@ -537,7 +539,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_delete_to_end_of_line' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); expect(result.cursorCol).toBe(5); }); @@ -547,7 +549,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_delete_to_end_of_line' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); }); }); @@ -560,7 +562,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(2); }); @@ -572,7 +574,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(3); }); @@ -581,7 +583,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_cursor' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(5); }); }); @@ -592,7 +594,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_append_at_line_end' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(11); }); }); @@ -603,7 +605,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_line_start' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); }); @@ -612,34 +614,32 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_insert_at_line_start' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(3); }); }); describe('vim_open_line_below', () => { - it('should insert newline at end of current line', () => { + it('should insert a new line below the current one', () => { const state = createTestState(['hello world'], 0, 5); const action = { type: 'vim_open_line_below' as const }; const result = handleVimAction(state, action); - - // The implementation inserts newline at end of current line and cursor moves to column 0 - expect(result.lines[0]).toBe('hello world\n'); - expect(result.cursorRow).toBe(0); - expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello world', '']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); }); }); describe('vim_open_line_above', () => { - it('should insert newline before current line', () => { + it('should insert a new line above the current one', () => { const state = createTestState(['hello', 'world'], 1, 2); const action = { type: 'vim_open_line_above' as const }; const result = handleVimAction(state, action); - - // The implementation inserts newline at beginning of current line - expect(result.lines).toEqual(['hello', '\nworld']); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello', '', 'world']); expect(result.cursorRow).toBe(1); expect(result.cursorCol).toBe(0); }); @@ -651,7 +651,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_escape_insert_mode' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(2); }); @@ -660,7 +660,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_escape_insert_mode' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); }); }); @@ -676,7 +676,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('world test'); expect(result.cursorCol).toBe(0); }); @@ -691,7 +691,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe(''); expect(result.cursorCol).toBe(0); }); @@ -706,7 +706,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hel world'); expect(result.cursorCol).toBe(3); }); @@ -719,7 +719,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right) expect(result.cursorCol).toBe(5); }); @@ -732,7 +732,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); // The movement 'j' with count 2 changes 2 lines starting from cursor row // Since we're at cursor position 2, it changes lines starting from current row expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines @@ -751,7 +751,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorRow).toBe(0); expect(result.cursorCol).toBe(0); }); @@ -761,7 +761,7 @@ describe('vim-buffer-actions', () => { const action = { type: 'vim_move_to_line_end' as const }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.cursorCol).toBe(0); // Should be last character position }); @@ -773,7 +773,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); // Should move to next line with content expect(result.cursorRow).toBe(2); expect(result.cursorCol).toBe(0); @@ -789,7 +789,7 @@ describe('vim-buffer-actions', () => { }; const result = handleVimAction(state, action); - + expect(result).toHaveOnlyValidCharacters(); expect(result.undoStack).toHaveLength(2); // Original plus new snapshot }); }); diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts new file mode 100644 index 00000000..a419c873 --- /dev/null +++ b/packages/cli/test-setup.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './src/test-utils/customMatchers.js'; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 8f67a0be..5a3f99fe 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ outputFile: { junit: 'junit.xml', }, + setupFiles: ['./test-setup.ts'], coverage: { enabled: true, provider: 'v8', From 6f7beb414cae67df59aea3f8a3e99389c3a82aec Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Thu, 31 Jul 2025 16:24:23 -0700 Subject: [PATCH 057/136] Highlight slash commands in history (#5323) --- .../src/ui/components/HistoryItemDisplay.test.tsx | 12 ++++++++++++ .../cli/src/ui/components/messages/UserMessage.tsx | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index b40b20bc..f806abd6 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -35,6 +35,18 @@ describe('', () => { expect(lastFrame()).toContain('Hello'); }); + it('renders UserMessage for "user" type with slash command', () => { + const item: HistoryItem = { + ...baseItem, + type: MessageType.USER, + text: '/theme', + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('/theme'); + }); + it('renders StatsDisplay for "stats" type', () => { const item: HistoryItem = { ...baseItem, diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx index 46f3d4a2..332cb0f4 100644 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -15,11 +15,15 @@ interface UserMessageProps { export const UserMessage: React.FC = ({ text }) => { const prefix = '> '; const prefixWidth = prefix.length; + const isSlashCommand = text.startsWith('/'); + + const textColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray; + const borderColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray; return ( = ({ text }) => { alignSelf="flex-start" > - {prefix} + {prefix} - + {text} From a3a432e3cf5d2de084cecb684229b14ccd4969ac Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Thu, 31 Jul 2025 17:27:07 -0700 Subject: [PATCH 058/136] Fix bug executing commands in windows whose flags contain spaces (#5317) --- .../services/shellExecutionService.test.ts | 30 ++++++++++++------- .../src/services/shellExecutionService.ts | 11 ++++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 4d1655a2..cfce08d2 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -91,9 +91,9 @@ describe('ShellExecutionService', () => { }); expect(mockSpawn).toHaveBeenCalledWith( - 'bash', - ['-c', 'ls -l'], - expect.any(Object), + 'ls -l', + [], + expect.objectContaining({ shell: 'bash' }), ); expect(result.exitCode).toBe(0); expect(result.signal).toBeNull(); @@ -334,23 +334,31 @@ describe('ShellExecutionService', () => { describe('Platform-Specific Behavior', () => { it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); - await simulateExecution('dir', (cp) => cp.emit('exit', 0, null)); + await simulateExecution('dir "foo bar"', (cp) => + cp.emit('exit', 0, null), + ); expect(mockSpawn).toHaveBeenCalledWith( - 'cmd.exe', - ['/c', 'dir'], - expect.objectContaining({ detached: false }), + 'dir "foo bar"', + [], + expect.objectContaining({ + shell: true, + detached: false, + }), ); }); it('should use bash and detached process group on Linux', async () => { mockPlatform.mockReturnValue('linux'); - await simulateExecution('ls', (cp) => cp.emit('exit', 0, null)); + await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); expect(mockSpawn).toHaveBeenCalledWith( - 'bash', - ['-c', 'ls'], - expect.objectContaining({ detached: true }), + 'ls "foo bar"', + [], + expect.objectContaining({ + shell: 'bash', + detached: true, + }), ); }); }); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 0f0002cd..d1126a7d 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -89,13 +89,16 @@ export class ShellExecutionService { abortSignal: AbortSignal, ): ShellExecutionHandle { const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const shellArgs = [isWindows ? '/c' : '-c', commandToExecute]; - const child = spawn(shell, shellArgs, { + const child = spawn(commandToExecute, [], { cwd, stdio: ['ignore', 'pipe', 'pipe'], - detached: !isWindows, // Use process groups on non-Windows for robust killing + // Use bash unless in Windows (since it doesn't support bash). + // For windows, just use the default. + shell: isWindows ? true : 'bash', + // Use process groups on non-Windows for robust killing. + // Windows process termination is handled by `taskkill /t`. + detached: !isWindows, env: { ...process.env, GEMINI_CLI: '1', From 6c3fb18ef6b28b91ff0e8e6e6edb3382697c9c36 Mon Sep 17 00:00:00 2001 From: Raushan Raj Date: Fri, 1 Aug 2025 06:44:26 +0530 Subject: [PATCH 059/136] Update gemini-automated-issue-triage.yml (#5312) Co-authored-by: Jacob Richman --- .github/workflows/gemini-automated-issue-triage.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index fa62a36c..63aa0742 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -28,6 +28,8 @@ jobs: uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} with: version: 0.1.8-rc.0 GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} From f21ff093897980a51a4ad1ea6ee167dee53416b6 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 31 Jul 2025 18:17:52 -0700 Subject: [PATCH 060/136] fix(core): Remove json output schema form the next speaker check prompt (#5325) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/core/client.ts | 24 ++++++++++++++++--- .../clearcut-logger/clearcut-logger.ts | 17 +++++++++++++ .../clearcut-logger/event-metadata-key.ts | 7 ++++++ packages/core/src/telemetry/types.ts | 15 +++++++++++- packages/core/src/utils/nextSpeakerChecker.ts | 22 +---------------- 5 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index be105971..57457826 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -44,7 +44,11 @@ import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; import { ideContext } from '../ide/ideContext.js'; import { logNextSpeakerCheck } from '../telemetry/loggers.js'; -import { NextSpeakerCheckEvent } from '../telemetry/types.js'; +import { + MalformedJsonResponseEvent, + NextSpeakerCheckEvent, +} from '../telemetry/types.js'; +import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; function isThinkingSupported(model: string) { if (model.startsWith('gemini-2.5')) return true; @@ -506,7 +510,7 @@ export class GeminiClient { authType: this.config.getContentGeneratorConfig()?.authType, }); - const text = getResponseText(result); + let text = getResponseText(result); if (!text) { const error = new Error( 'API returned an empty response for generateJson.', @@ -519,6 +523,18 @@ export class GeminiClient { ); throw error; } + + const prefix = '```json'; + const suffix = '```'; + if (text.startsWith(prefix) && text.endsWith(suffix)) { + ClearcutLogger.getInstance(this.config)?.logMalformedJsonResponseEvent( + new MalformedJsonResponseEvent(modelToUse), + ); + text = text + .substring(prefix.length, text.length - suffix.length) + .trim(); + } + try { return JSON.parse(text); } catch (parseError) { @@ -532,7 +548,9 @@ export class GeminiClient { 'generateJson-parse', ); throw new Error( - `Failed to parse API response as JSON: ${getErrorMessage(parseError)}`, + `Failed to parse API response as JSON: ${getErrorMessage( + parseError, + )}`, ); } } catch (error) { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 81a9ca4b..cfbbdda6 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -20,6 +20,7 @@ import { LoopDetectedEvent, NextSpeakerCheckEvent, SlashCommandEvent, + MalformedJsonResponseEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; @@ -42,6 +43,7 @@ const flash_fallback_event_name = 'flash_fallback'; const loop_detected_event_name = 'loop_detected'; const next_speaker_check_event_name = 'next_speaker_check'; const slash_command_event_name = 'slash_command'; +const malformed_json_response_event_name = 'malformed_json_response'; export interface LogResponse { nextRequestWaitMs?: number; @@ -557,6 +559,21 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void { + const data = [ + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL, + value: JSON.stringify(event.model), + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(malformed_json_response_event_name, data), + ); + this.flushIfNeeded(); + } + logEndSessionEvent(event: EndSessionEvent): void { const data = [ { 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 01dd42af..0fc35894 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -183,6 +183,13 @@ export enum EventMetadataKey { // Logs the result of the next speaker check GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT = 44, + + // ========================================================================== + // Malformed JSON Response Event Keys + // ========================================================================== + + // Logs the model that produced the malformed JSON response. + GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45, } export function getEventMetadataKey( diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 6fe797bf..1633dbc4 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -296,6 +296,18 @@ export class SlashCommandEvent { } } +export class MalformedJsonResponseEvent { + 'event.name': 'malformed_json_response'; + 'event.timestamp': string; // ISO 8601 + model: string; + + constructor(model: string) { + this['event.name'] = 'malformed_json_response'; + this['event.timestamp'] = new Date().toISOString(); + this.model = model; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -307,4 +319,5 @@ export type TelemetryEvent = | FlashFallbackEvent | LoopDetectedEvent | NextSpeakerCheckEvent - | SlashCommandEvent; + | SlashCommandEvent + | MalformedJsonResponseEvent; diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index c75bf645..a0d735b0 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -14,27 +14,7 @@ const CHECK_PROMPT = `Analyze *only* the content and structure of your immediate **Decision Rules (apply in order):** 1. **Model Continues:** If your last response explicitly states an immediate next action *you* intend to take (e.g., "Next, I will...", "Now I'll process...", "Moving on to analyze...", indicates an intended tool call that didn't execute), OR if the response seems clearly incomplete (cut off mid-thought without a natural conclusion), then the **'model'** should speak next. 2. **Question to User:** If your last response ends with a direct question specifically addressed *to the user*, then the **'user'** should speak next. -3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next. -**Output Format:** -Respond *only* in JSON format according to the following schema. Do not include any text outside the JSON structure. -\`\`\`json -{ - "type": "object", - "properties": { - "reasoning": { - "type": "string", - "description": "Brief explanation justifying the 'next_speaker' choice based *strictly* on the applicable rule and the content/structure of the preceding turn." - }, - "next_speaker": { - "type": "string", - "enum": ["user", "model"], - "description": "Who should speak next based *only* on the preceding turn and the decision rules." - } - }, - "required": ["next_speaker", "reasoning"] -} -\`\`\` -`; +3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next.`; const RESPONSE_SCHEMA: SchemaUnion = { type: Type.OBJECT, From dc9f17bb4a65a73e57fd315917a9c032dce04551 Mon Sep 17 00:00:00 2001 From: Brian Ray <62354532+emeryray2002@users.noreply.github.com> Date: Fri, 1 Aug 2025 01:47:22 -0400 Subject: [PATCH 061/136] New browser launcher for MCP OAuth. (#5261) --- packages/core/src/mcp/oauth-provider.test.ts | 21 +- packages/core/src/mcp/oauth-provider.ts | 6 +- .../src/utils/secure-browser-launcher.test.ts | 242 ++++++++++++++++++ .../core/src/utils/secure-browser-launcher.ts | 188 ++++++++++++++ 4 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/utils/secure-browser-launcher.test.ts create mode 100644 packages/core/src/utils/secure-browser-launcher.ts diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 20dc9fab..5bfd637b 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -7,7 +7,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; -import open from 'open'; import { MCPOAuthProvider, MCPOAuthConfig, @@ -17,7 +16,10 @@ import { import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js'; // Mock dependencies -vi.mock('open'); +const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); +vi.mock('../utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: mockOpenBrowserSecurely, +})); vi.mock('node:crypto'); vi.mock('./oauth-token-storage.js'); @@ -64,6 +66,7 @@ describe('MCPOAuthProvider', () => { beforeEach(() => { vi.clearAllMocks(); + mockOpenBrowserSecurely.mockClear(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -145,7 +148,9 @@ describe('MCPOAuthProvider', () => { expiresAt: expect.any(Number), }); - expect(open).toHaveBeenCalledWith(expect.stringContaining('authorize')); + expect(mockOpenBrowserSecurely).toHaveBeenCalledWith( + expect.stringContaining('authorize'), + ); expect(MCPOAuthTokenStorage.saveToken).toHaveBeenCalledWith( 'test-server', expect.objectContaining({ accessToken: 'access_token_123' }), @@ -672,13 +677,10 @@ describe('MCPOAuthProvider', () => { describe('Authorization URL building', () => { it('should build correct authorization URL with all parameters', async () => { // Mock to capture the URL that would be opened - let capturedUrl: string; - vi.mocked(open).mockImplementation((url) => { + let capturedUrl: string | undefined; + mockOpenBrowserSecurely.mockImplementation((url: string) => { capturedUrl = url; - // Return a minimal mock ChildProcess - return Promise.resolve({ - pid: 1234, - } as unknown as import('child_process').ChildProcess); + return Promise.resolve(); }); let callbackHandler: unknown; @@ -711,6 +713,7 @@ describe('MCPOAuthProvider', () => { await MCPOAuthProvider.authenticate('test-server', mockConfig); + expect(capturedUrl).toBeDefined(); expect(capturedUrl!).toContain('response_type=code'); expect(capturedUrl!).toContain('client_id=test-client-id'); expect(capturedUrl!).toContain('code_challenge=code_challenge_mock'); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 2f65f051..491ec477 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -7,7 +7,7 @@ import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { URL } from 'node:url'; -import open from 'open'; +import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import { MCPOAuthToken, MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; import { OAuthUtils } from './oauth-utils.js'; @@ -593,9 +593,9 @@ export class MCPOAuthProvider { // Start callback server const callbackPromise = this.startCallbackServer(pkceParams.state); - // Open browser + // Open browser securely try { - await open(authUrl); + await openBrowserSecurely(authUrl); } catch (error) { console.warn( 'Failed to open browser automatically:', diff --git a/packages/core/src/utils/secure-browser-launcher.test.ts b/packages/core/src/utils/secure-browser-launcher.test.ts new file mode 100644 index 00000000..de27ce6f --- /dev/null +++ b/packages/core/src/utils/secure-browser-launcher.test.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { openBrowserSecurely } from './secure-browser-launcher.js'; + +// Create mock function using vi.hoisted +const mockExecFile = vi.hoisted(() => vi.fn()); + +// Mock modules +vi.mock('node:child_process'); +vi.mock('node:util', () => ({ + promisify: () => mockExecFile, +})); + +describe('secure-browser-launcher', () => { + let originalPlatform: PropertyDescriptor | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + }); + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }); + + function setPlatform(platform: string) { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + } + + describe('URL validation', () => { + it('should allow valid HTTP URLs', async () => { + setPlatform('darwin'); + await openBrowserSecurely('http://example.com'); + expect(mockExecFile).toHaveBeenCalledWith( + 'open', + ['http://example.com'], + expect.any(Object), + ); + }); + + it('should allow valid HTTPS URLs', async () => { + setPlatform('darwin'); + await openBrowserSecurely('https://example.com'); + expect(mockExecFile).toHaveBeenCalledWith( + 'open', + ['https://example.com'], + expect.any(Object), + ); + }); + + it('should reject non-HTTP(S) protocols', async () => { + await expect(openBrowserSecurely('file:///etc/passwd')).rejects.toThrow( + 'Unsafe protocol', + ); + await expect(openBrowserSecurely('javascript:alert(1)')).rejects.toThrow( + 'Unsafe protocol', + ); + await expect(openBrowserSecurely('ftp://example.com')).rejects.toThrow( + 'Unsafe protocol', + ); + }); + + it('should reject invalid URLs', async () => { + await expect(openBrowserSecurely('not-a-url')).rejects.toThrow( + 'Invalid URL', + ); + await expect(openBrowserSecurely('')).rejects.toThrow('Invalid URL'); + }); + + it('should reject URLs with control characters', async () => { + await expect( + openBrowserSecurely('http://example.com\nmalicious-command'), + ).rejects.toThrow('invalid characters'); + await expect( + openBrowserSecurely('http://example.com\rmalicious-command'), + ).rejects.toThrow('invalid characters'); + await expect( + openBrowserSecurely('http://example.com\x00'), + ).rejects.toThrow('invalid characters'); + }); + }); + + describe('Command injection prevention', () => { + it('should prevent PowerShell command injection on Windows', async () => { + setPlatform('win32'); + + // The POC from the vulnerability report + const maliciousUrl = + "http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))"; + + await openBrowserSecurely(maliciousUrl); + + // Verify that execFile was called (not exec) and the URL is passed safely + expect(mockExecFile).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + `Start-Process '${maliciousUrl.replace(/'/g, "''")}'`, + ], + expect.any(Object), + ); + }); + + it('should handle URLs with special shell characters safely', async () => { + setPlatform('darwin'); + + const urlsWithSpecialChars = [ + 'http://example.com/path?param=value&other=$value', + 'http://example.com/path#fragment;command', + 'http://example.com/$(whoami)', + 'http://example.com/`command`', + 'http://example.com/|pipe', + 'http://example.com/>redirect', + ]; + + for (const url of urlsWithSpecialChars) { + await openBrowserSecurely(url); + // Verify the URL is passed as an argument, not interpreted by shell + expect(mockExecFile).toHaveBeenCalledWith( + 'open', + [url], + expect.any(Object), + ); + } + }); + + it('should properly escape single quotes in URLs on Windows', async () => { + setPlatform('win32'); + + const urlWithSingleQuotes = + "http://example.com/path?name=O'Brien&test='value'"; + await openBrowserSecurely(urlWithSingleQuotes); + + // Verify that single quotes are escaped by doubling them + expect(mockExecFile).toHaveBeenCalledWith( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + `Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`, + ], + expect.any(Object), + ); + }); + }); + + describe('Platform-specific behavior', () => { + it('should use correct command on macOS', async () => { + setPlatform('darwin'); + await openBrowserSecurely('https://example.com'); + expect(mockExecFile).toHaveBeenCalledWith( + 'open', + ['https://example.com'], + expect.any(Object), + ); + }); + + it('should use PowerShell on Windows', async () => { + setPlatform('win32'); + await openBrowserSecurely('https://example.com'); + expect(mockExecFile).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining([ + '-Command', + `Start-Process 'https://example.com'`, + ]), + expect.any(Object), + ); + }); + + it('should use xdg-open on Linux', async () => { + setPlatform('linux'); + await openBrowserSecurely('https://example.com'); + expect(mockExecFile).toHaveBeenCalledWith( + 'xdg-open', + ['https://example.com'], + expect.any(Object), + ); + }); + + it('should throw on unsupported platforms', async () => { + setPlatform('aix'); + await expect(openBrowserSecurely('https://example.com')).rejects.toThrow( + 'Unsupported platform', + ); + }); + }); + + describe('Error handling', () => { + it('should handle browser launch failures gracefully', async () => { + setPlatform('darwin'); + mockExecFile.mockRejectedValueOnce(new Error('Command not found')); + + await expect(openBrowserSecurely('https://example.com')).rejects.toThrow( + 'Failed to open browser', + ); + }); + + it('should try fallback browsers on Linux', async () => { + setPlatform('linux'); + + // First call to xdg-open fails + mockExecFile.mockRejectedValueOnce(new Error('Command not found')); + // Second call to gnome-open succeeds + mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await openBrowserSecurely('https://example.com'); + + expect(mockExecFile).toHaveBeenCalledTimes(2); + expect(mockExecFile).toHaveBeenNthCalledWith( + 1, + 'xdg-open', + ['https://example.com'], + expect.any(Object), + ); + expect(mockExecFile).toHaveBeenNthCalledWith( + 2, + 'gnome-open', + ['https://example.com'], + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/core/src/utils/secure-browser-launcher.ts b/packages/core/src/utils/secure-browser-launcher.ts new file mode 100644 index 00000000..ec8357be --- /dev/null +++ b/packages/core/src/utils/secure-browser-launcher.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { platform } from 'node:os'; +import { URL } from 'node:url'; + +const execFileAsync = promisify(execFile); + +/** + * Validates that a URL is safe to open in a browser. + * Only allows HTTP and HTTPS URLs to prevent command injection. + * + * @param url The URL to validate + * @throws Error if the URL is invalid or uses an unsafe protocol + */ +function validateUrl(url: string): void { + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch (_error) { + throw new Error(`Invalid URL: ${url}`); + } + + // Only allow HTTP and HTTPS protocols + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error( + `Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`, + ); + } + + // Additional validation: ensure no newlines or control characters + // eslint-disable-next-line no-control-regex + if (/[\r\n\x00-\x1f]/.test(url)) { + throw new Error('URL contains invalid characters'); + } +} + +/** + * Opens a URL in the default browser using platform-specific commands. + * This implementation avoids shell injection vulnerabilities by: + * 1. Validating the URL to ensure it's HTTP/HTTPS only + * 2. Using execFile instead of exec to avoid shell interpretation + * 3. Passing the URL as an argument rather than constructing a command string + * + * @param url The URL to open + * @throws Error if the URL is invalid or if opening the browser fails + */ +export async function openBrowserSecurely(url: string): Promise { + // Validate the URL first + validateUrl(url); + + const platformName = platform(); + let command: string; + let args: string[]; + + switch (platformName) { + case 'darwin': + // macOS + command = 'open'; + args = [url]; + break; + + case 'win32': + // Windows - use PowerShell with Start-Process + // This avoids the cmd.exe shell which is vulnerable to injection + command = 'powershell.exe'; + args = [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + `Start-Process '${url.replace(/'/g, "''")}'`, + ]; + break; + + case 'linux': + case 'freebsd': + case 'openbsd': + // Linux and BSD variants + // Try xdg-open first, fall back to other options + command = 'xdg-open'; + args = [url]; + break; + + default: + throw new Error(`Unsupported platform: ${platformName}`); + } + + const options: Record = { + // Don't inherit parent's environment to avoid potential issues + env: { + ...process.env, + // Ensure we're not in a shell that might interpret special characters + SHELL: undefined, + }, + // Detach the browser process so it doesn't block + detached: true, + stdio: 'ignore', + }; + + try { + await execFileAsync(command, args, options); + } catch (error) { + // For Linux, try fallback commands if xdg-open fails + if ( + (platformName === 'linux' || + platformName === 'freebsd' || + platformName === 'openbsd') && + command === 'xdg-open' + ) { + const fallbackCommands = [ + 'gnome-open', + 'kde-open', + 'firefox', + 'chromium', + 'google-chrome', + ]; + + for (const fallbackCommand of fallbackCommands) { + try { + await execFileAsync(fallbackCommand, [url], options); + return; // Success! + } catch { + // Try next command + continue; + } + } + } + + // Re-throw the error if all attempts failed + throw new Error( + `Failed to open browser: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +} + +/** + * Checks if the current environment should attempt to launch a browser. + * This is the same logic as in browser.ts for consistency. + * + * @returns True if the tool should attempt to launch a browser + */ +export function shouldLaunchBrowser(): boolean { + // A list of browser names that indicate we should not attempt to open a + // web browser for the user. + const browserBlocklist = ['www-browser']; + const browserEnv = process.env.BROWSER; + if (browserEnv && browserBlocklist.includes(browserEnv)) { + return false; + } + + // Common environment variables used in CI/CD or other non-interactive shells. + if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') { + return false; + } + + // The presence of SSH_CONNECTION indicates a remote session. + // We should not attempt to launch a browser unless a display is explicitly available + // (checked below for Linux). + const isSSH = !!process.env.SSH_CONNECTION; + + // On Linux, the presence of a display server is a strong indicator of a GUI. + if (platform() === 'linux') { + // These are environment variables that can indicate a running compositor on Linux. + const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET']; + const hasDisplay = displayVariables.some((v) => !!process.env[v]); + if (!hasDisplay) { + return false; + } + } + + // If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser. + // The Linux case is handled above (it's allowed if DISPLAY is set). + if (isSSH && platform() !== 'linux') { + return false; + } + + // For non-Linux OSes, we generally assume a GUI is available + // unless other signals (like SSH) suggest otherwise. + return true; +} From e126d2fcd97221df7de63df09bc0eba386314781 Mon Sep 17 00:00:00 2001 From: cornmander Date: Fri, 1 Aug 2025 10:40:05 -0400 Subject: [PATCH 062/136] Add missing emacs entry in UI. (#5351) --- .../src/ui/editors/editorSettingsManager.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index ae089902..ae83ae79 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -17,29 +17,23 @@ export interface EditorDisplay { } export const EDITOR_DISPLAY_NAMES: Record = { - zed: 'Zed', + cursor: 'Cursor', + emacs: 'Emacs', + neovim: 'Neovim', + vim: 'Vim', vscode: 'VS Code', vscodium: 'VSCodium', windsurf: 'Windsurf', - cursor: 'Cursor', - vim: 'Vim', - emacs: 'Emacs', - neovim: 'Neovim', + zed: 'Zed', }; class EditorSettingsManager { private readonly availableEditors: EditorDisplay[]; constructor() { - const editorTypes: EditorType[] = [ - 'zed', - 'vscode', - 'vscodium', - 'windsurf', - 'cursor', - 'vim', - 'neovim', - ]; + const editorTypes = Object.keys( + EDITOR_DISPLAY_NAMES, + ).sort() as EditorType[]; this.availableEditors = [ { name: 'None', From 7748e56153159373ba4b9bf0f937ed476504b6c7 Mon Sep 17 00:00:00 2001 From: Silvio Junior Date: Fri, 1 Aug 2025 11:20:08 -0400 Subject: [PATCH 063/136] [Fix Telemetry for tool calls, PR 1/n] Propagate tool reported errors via ToolCallResponseInfo and ToolResult (#5222) --- packages/cli/src/nonInteractiveCli.test.ts | 2 + packages/cli/src/nonInteractiveCli.ts | 7 +- packages/core/src/core/coreToolScheduler.ts | 44 ++++++--- .../src/core/nonInteractiveToolExecutor.ts | 17 +++- packages/core/src/core/turn.ts | 2 + packages/core/src/index.ts | 1 + .../src/telemetry/loggers.test.circular.ts | 2 + packages/core/src/telemetry/loggers.test.ts | 10 +- packages/core/src/telemetry/types.ts | 2 +- .../core/src/telemetry/uiTelemetry.test.ts | 3 + packages/core/src/tools/edit.test.ts | 93 +++++++++++++++++++ packages/core/src/tools/edit.ts | 29 +++++- packages/core/src/tools/tool-error.ts | 28 ++++++ packages/core/src/tools/tools.ts | 9 ++ 14 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/tools/tool-error.ts diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index a0fc6f9f..938eb4e7 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -8,6 +8,7 @@ import { Config, executeToolCall, ToolRegistry, + ToolErrorType, shutdownTelemetry, GeminiEventType, ServerGeminiStreamEvent, @@ -161,6 +162,7 @@ describe('runNonInteractive', () => { }; mockCoreExecuteToolCall.mockResolvedValue({ error: new Error('Tool execution failed badly'), + errorType: ToolErrorType.UNHANDLED_EXCEPTION, }); mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents([toolCallEvent]), diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1d0a7f3d..8e573134 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -12,6 +12,7 @@ import { shutdownTelemetry, isTelemetrySdkInitialized, GeminiEventType, + ToolErrorType, } from '@google/gemini-cli-core'; import { Content, Part, FunctionCall } from '@google/genai'; @@ -97,15 +98,11 @@ export async function runNonInteractive( ); if (toolResponse.error) { - const isToolNotFound = toolResponse.error.message.includes( - 'not found in registry', - ); console.error( `Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`, ); - if (!isToolNotFound) { + if (toolResponse.errorType === ToolErrorType.UNHANDLED_EXCEPTION) process.exit(1); - } } if (toolResponse.responseParts) { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index af078faa..b4c10a64 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -19,6 +19,7 @@ import { logToolCall, ToolCallEvent, ToolConfirmationPayload, + ToolErrorType, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -201,6 +202,7 @@ export function convertToFunctionResponse( const createErrorResponse = ( request: ToolCallRequestInfo, error: Error, + errorType: ToolErrorType | undefined, ): ToolCallResponseInfo => ({ callId: request.callId, error, @@ -212,6 +214,7 @@ const createErrorResponse = ( }, }, resultDisplay: error.message, + errorType, }); interface CoreToolSchedulerOptions { @@ -366,6 +369,7 @@ export class CoreToolScheduler { }, resultDisplay, error: undefined, + errorType: undefined, }, durationMs, outcome, @@ -436,6 +440,7 @@ export class CoreToolScheduler { response: createErrorResponse( reqInfo, new Error(`Tool "${reqInfo.name}" not found in registry.`), + ToolErrorType.TOOL_NOT_REGISTERED, ), durationMs: 0, }; @@ -499,6 +504,7 @@ export class CoreToolScheduler { createErrorResponse( reqInfo, error instanceof Error ? error : new Error(String(error)), + ToolErrorType.UNHANDLED_EXCEPTION, ), ); } @@ -670,19 +676,30 @@ export class CoreToolScheduler { return; } - const response = convertToFunctionResponse( - toolName, - callId, - toolResult.llmContent, - ); - const successResponse: ToolCallResponseInfo = { - callId, - responseParts: response, - resultDisplay: toolResult.returnDisplay, - error: undefined, - }; - - this.setStatusInternal(callId, 'success', successResponse); + if (toolResult.error === undefined) { + const response = convertToFunctionResponse( + toolName, + callId, + toolResult.llmContent, + ); + const successResponse: ToolCallResponseInfo = { + callId, + responseParts: response, + resultDisplay: toolResult.returnDisplay, + error: undefined, + errorType: undefined, + }; + this.setStatusInternal(callId, 'success', successResponse); + } else { + // It is a failure + const error = new Error(toolResult.error.message); + const errorResponse = createErrorResponse( + scheduledCall.request, + error, + toolResult.error.type, + ); + this.setStatusInternal(callId, 'error', errorResponse); + } }) .catch((executionError: Error) => { this.setStatusInternal( @@ -693,6 +710,7 @@ export class CoreToolScheduler { executionError instanceof Error ? executionError : new Error(String(executionError)), + ToolErrorType.UNHANDLED_EXCEPTION, ), ); }); diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index ab001bd6..52704bf1 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -8,6 +8,7 @@ import { logToolCall, ToolCallRequestInfo, ToolCallResponseInfo, + ToolErrorType, ToolRegistry, ToolResult, } from '../index.js'; @@ -56,6 +57,7 @@ export async function executeToolCall( ], resultDisplay: error.message, error, + errorType: ToolErrorType.TOOL_NOT_REGISTERED, }; } @@ -79,7 +81,11 @@ export async function executeToolCall( function_name: toolCallRequest.name, function_args: toolCallRequest.args, duration_ms: durationMs, - success: true, + success: toolResult.error === undefined, + error: + toolResult.error === undefined ? undefined : toolResult.error.message, + error_type: + toolResult.error === undefined ? undefined : toolResult.error.type, prompt_id: toolCallRequest.prompt_id, }); @@ -93,7 +99,12 @@ export async function executeToolCall( callId: toolCallRequest.callId, responseParts: response, resultDisplay: tool_display, - error: undefined, + error: + toolResult.error === undefined + ? undefined + : new Error(toolResult.error.message), + errorType: + toolResult.error === undefined ? undefined : toolResult.error.type, }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -106,6 +117,7 @@ export async function executeToolCall( duration_ms: durationMs, success: false, error: error.message, + error_type: ToolErrorType.UNHANDLED_EXCEPTION, prompt_id: toolCallRequest.prompt_id, }); return { @@ -121,6 +133,7 @@ export async function executeToolCall( ], resultDisplay: error.message, error, + errorType: ToolErrorType.UNHANDLED_EXCEPTION, }; } } diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index b54b3f82..ee32c309 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -16,6 +16,7 @@ import { ToolResult, ToolResultDisplay, } from '../tools/tools.js'; +import { ToolErrorType } from '../tools/tool-error.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { reportError } from '../utils/errorReporting.js'; import { @@ -76,6 +77,7 @@ export interface ToolCallResponseInfo { responseParts: PartListUnion; resultDisplay: ToolResultDisplay | undefined; error: Error | undefined; + errorType: ToolErrorType | undefined; } export interface ServerToolCallConfirmationDetails { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93862c12..d7dfd90f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ export * from './services/shellExecutionService.js'; // Export base tool definitions export * from './tools/tools.js'; +export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; // Export prompt logic diff --git a/packages/core/src/telemetry/loggers.test.circular.ts b/packages/core/src/telemetry/loggers.test.circular.ts index 62a61bfd..80444a0d 100644 --- a/packages/core/src/telemetry/loggers.test.circular.ts +++ b/packages/core/src/telemetry/loggers.test.circular.ts @@ -53,6 +53,7 @@ describe('Circular Reference Handling', () => { responseParts: [{ text: 'test result' }], resultDisplay: undefined, error: undefined, // undefined means success + errorType: undefined, }; const mockCompletedToolCall: CompletedToolCall = { @@ -100,6 +101,7 @@ describe('Circular Reference Handling', () => { responseParts: [{ text: 'test result' }], resultDisplay: undefined, error: undefined, // undefined means success + errorType: undefined, }; const mockCompletedToolCall: CompletedToolCall = { diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 7a24bcca..3d8116cc 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -12,6 +12,7 @@ import { ErroredToolCall, GeminiClient, ToolConfirmationOutcome, + ToolErrorType, ToolRegistry, } from '../index.js'; import { logs } from '@opentelemetry/api-logs'; @@ -448,6 +449,7 @@ describe('loggers', () => { responseParts: 'test-response', resultDisplay: undefined, error: undefined, + errorType: undefined, }, tool: new EditTool(mockConfig), durationMs: 100, @@ -511,6 +513,7 @@ describe('loggers', () => { responseParts: 'test-response', resultDisplay: undefined, error: undefined, + errorType: undefined, }, durationMs: 100, outcome: ToolConfirmationOutcome.Cancel, @@ -574,6 +577,7 @@ describe('loggers', () => { responseParts: 'test-response', resultDisplay: undefined, error: undefined, + errorType: undefined, }, outcome: ToolConfirmationOutcome.ModifyWithEditor, tool: new EditTool(mockConfig), @@ -638,6 +642,7 @@ describe('loggers', () => { responseParts: 'test-response', resultDisplay: undefined, error: undefined, + errorType: undefined, }, tool: new EditTool(mockConfig), durationMs: 100, @@ -703,6 +708,7 @@ describe('loggers', () => { name: 'test-error-type', message: 'test-error', }, + errorType: ToolErrorType.UNKNOWN, }, durationMs: 100, }; @@ -729,8 +735,8 @@ describe('loggers', () => { success: false, error: 'test-error', 'error.message': 'test-error', - error_type: 'test-error-type', - 'error.type': 'test-error-type', + error_type: ToolErrorType.UNKNOWN, + 'error.type': ToolErrorType.UNKNOWN, prompt_id: 'prompt-id-5', }, }); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 1633dbc4..9d1fd77a 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -137,7 +137,7 @@ export class ToolCallEvent { ? getDecisionFromOutcome(call.outcome) : undefined; this.error = call.response.error?.message; - this.error_type = call.response.error?.name; + this.error_type = call.response.errorType; this.prompt_id = call.request.prompt_id; } } diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 38ba7a91..bce54ad8 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -22,6 +22,7 @@ import { ErroredToolCall, SuccessfulToolCall, } from '../core/coreToolScheduler.js'; +import { ToolErrorType } from '../tools/tool-error.js'; import { Tool, ToolConfirmationOutcome } from '../tools/tools.js'; const createFakeCompletedToolCall = ( @@ -54,6 +55,7 @@ const createFakeCompletedToolCall = ( }, }, error: undefined, + errorType: undefined, resultDisplay: 'Success!', }, durationMs: duration, @@ -73,6 +75,7 @@ const createFakeCompletedToolCall = ( }, }, error: error || new Error('Tool failed'), + errorType: ToolErrorType.UNKNOWN, resultDisplay: 'Failure!', }, durationMs: duration, diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index b44d7e6f..029d3a3c 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -27,6 +27,7 @@ vi.mock('../utils/editor.js', () => ({ import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; import { EditTool, EditToolParams } from './edit.js'; import { FileDiff } from './tools.js'; +import { ToolErrorType } from './tool-error.js'; import path from 'path'; import fs from 'fs'; import os from 'os'; @@ -627,6 +628,98 @@ describe('EditTool', () => { }); }); + describe('Error Scenarios', () => { + const testFile = 'error_test.txt'; + let filePath: string; + + beforeEach(() => { + filePath = path.join(rootDir, testFile); + }); + + it('should return FILE_NOT_FOUND error', async () => { + const params: EditToolParams = { + file_path: filePath, + old_string: 'any', + new_string: 'new', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); + }); + + it('should return ATTEMPT_TO_CREATE_EXISTING_FILE error', async () => { + fs.writeFileSync(filePath, 'existing content', 'utf8'); + const params: EditToolParams = { + file_path: filePath, + old_string: '', + new_string: 'new content', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe( + ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, + ); + }); + + it('should return NO_OCCURRENCE_FOUND error', async () => { + fs.writeFileSync(filePath, 'content', 'utf8'); + const params: EditToolParams = { + file_path: filePath, + old_string: 'not-found', + new_string: 'new', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); + }); + + it('should return EXPECTED_OCCURRENCE_MISMATCH error', async () => { + fs.writeFileSync(filePath, 'one one two', 'utf8'); + const params: EditToolParams = { + file_path: filePath, + old_string: 'one', + new_string: 'new', + expected_replacements: 3, + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe( + ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, + ); + }); + + it('should return NO_CHANGE error', async () => { + fs.writeFileSync(filePath, 'content', 'utf8'); + const params: EditToolParams = { + file_path: filePath, + old_string: 'content', + new_string: 'content', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_CHANGE); + }); + + it('should return INVALID_PARAMETERS error for relative path', async () => { + const params: EditToolParams = { + file_path: 'relative/path.txt', + old_string: 'a', + new_string: 'b', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + }); + + it('should return FILE_WRITE_FAILURE on write error', async () => { + fs.writeFileSync(filePath, 'content', 'utf8'); + // Make file readonly to trigger a write error + fs.chmodSync(filePath, '444'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'content', + new_string: 'new content', + }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE); + }); + }); + describe('getDescription', () => { it('should return "No file changes to..." if old_string and new_string are the same', () => { const testFileName = 'test.txt'; diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index ff2bc204..25da2292 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -17,6 +17,7 @@ import { ToolResult, ToolResultDisplay, } from './tools.js'; +import { ToolErrorType } from './tool-error.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; @@ -62,7 +63,7 @@ interface CalculatedEdit { currentContent: string | null; newContent: string; occurrences: number; - error?: { display: string; raw: string }; + error?: { display: string; raw: string; type: ToolErrorType }; isNewFile: boolean; } @@ -191,7 +192,9 @@ Expectation for required parameters: let finalNewString = params.new_string; let finalOldString = params.old_string; let occurrences = 0; - let error: { display: string; raw: string } | undefined = undefined; + let error: + | { display: string; raw: string; type: ToolErrorType } + | undefined = undefined; try { currentContent = fs.readFileSync(params.file_path, 'utf8'); @@ -214,6 +217,7 @@ Expectation for required parameters: error = { display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`, raw: `File not found: ${params.file_path}`, + type: ToolErrorType.FILE_NOT_FOUND, }; } else if (currentContent !== null) { // Editing an existing file @@ -233,11 +237,13 @@ Expectation for required parameters: error = { display: `Failed to edit. Attempted to create a file that already exists.`, raw: `File already exists, cannot create: ${params.file_path}`, + type: ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, }; } else if (occurrences === 0) { error = { display: `Failed to edit, could not find the string to replace.`, raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`, + type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, }; } else if (occurrences !== expectedReplacements) { const occurrenceTerm = @@ -246,11 +252,13 @@ Expectation for required parameters: error = { display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`, raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`, + type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, }; } else if (finalOldString === finalNewString) { error = { display: `No changes to apply. The old_string and new_string are identical.`, raw: `No changes to apply. The old_string and new_string are identical in file: ${params.file_path}`, + type: ToolErrorType.EDIT_NO_CHANGE, }; } } else { @@ -258,6 +266,7 @@ Expectation for required parameters: error = { display: `Failed to read content of file.`, raw: `Failed to read content of existing file: ${params.file_path}`, + type: ToolErrorType.READ_CONTENT_FAILURE, }; } @@ -374,6 +383,10 @@ Expectation for required parameters: return { llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, returnDisplay: `Error: ${validationError}`, + error: { + message: validationError, + type: ToolErrorType.INVALID_TOOL_PARAMS, + }, }; } @@ -385,6 +398,10 @@ Expectation for required parameters: return { llmContent: `Error preparing edit: ${errorMsg}`, returnDisplay: `Error preparing edit: ${errorMsg}`, + error: { + message: errorMsg, + type: ToolErrorType.EDIT_PREPARATION_FAILURE, + }, }; } @@ -392,6 +409,10 @@ Expectation for required parameters: return { llmContent: editData.error.raw, returnDisplay: `Error: ${editData.error.display}`, + error: { + message: editData.error.raw, + type: editData.error.type, + }, }; } @@ -442,6 +463,10 @@ Expectation for required parameters: return { llmContent: `Error executing edit: ${errorMsg}`, returnDisplay: `Error writing file: ${errorMsg}`, + error: { + message: errorMsg, + type: ToolErrorType.FILE_WRITE_FAILURE, + }, }; } } diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts new file mode 100644 index 00000000..38caa1da --- /dev/null +++ b/packages/core/src/tools/tool-error.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A type-safe enum for tool-related errors. + */ +export enum ToolErrorType { + // General Errors + INVALID_TOOL_PARAMS = 'invalid_tool_params', + UNKNOWN = 'unknown', + UNHANDLED_EXCEPTION = 'unhandled_exception', + TOOL_NOT_REGISTERED = 'tool_not_registered', + + // File System Errors + FILE_NOT_FOUND = 'file_not_found', + FILE_WRITE_FAILURE = 'file_write_failure', + READ_CONTENT_FAILURE = 'read_content_failure', + ATTEMPT_TO_CREATE_EXISTING_FILE = 'attempt_to_create_existing_file', + + // Edit-specific Errors + EDIT_PREPARATION_FAILURE = 'edit_preparation_failure', + EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found', + EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch', + EDIT_NO_CHANGE = 'edit_no_change', +} diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 0d7b402a..0e3ffabf 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -5,6 +5,7 @@ */ import { FunctionDeclaration, PartListUnion, Schema } from '@google/genai'; +import { ToolErrorType } from './tool-error.js'; /** * Interface representing the base Tool functionality @@ -217,6 +218,14 @@ export interface ToolResult { * For now, we keep it as the core logic in ReadFileTool currently produces it. */ returnDisplay: ToolResultDisplay; + + /** + * If this property is present, the tool call is considered a failure. + */ + error?: { + message: string; // raw error message + type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND'). + }; } export type ToolResultDisplay = string | FileDiff; From d42e3f1e7fbdf23e3e8b729c5ba08dbf89285088 Mon Sep 17 00:00:00 2001 From: Brian de Alwis Date: Fri, 1 Aug 2025 12:12:32 -0400 Subject: [PATCH 064/136] doc: use standard Google security policy for GitHub projects (#5062) --- README.md | 4 ++++ SECURITY.md | 8 ++++++++ 2 files changed, 12 insertions(+) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 3e2db940..41612af3 100644 --- a/README.md +++ b/README.md @@ -209,3 +209,7 @@ Head over to the [Uninstall](docs/Uninstall.md) guide for uninstallation instruc ## Terms of Service and Privacy Notice For details on the terms of service and privacy notice applicable to your use of Gemini CLI, see the [Terms of Service and Privacy Notice](./docs/tos-privacy.md). + +## Security Disclosures + +Please see our [security disclosure process](SECURITY.md). All [security advisories](https://github.com/google-gemini/gemini-cli/security/advisories) are managed on Github. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..226310c2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Reporting Security Issues + +To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz). +We use g.co/vulnz for our intake, and do coordination and disclosure here on +GitHub (including using GitHub Security Advisory). The Google Security Team will +respond within 5 working days of your report on g.co/vulnz. + +[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories From c725e258c657007ddd4dc4f8d5e896ea6b775818 Mon Sep 17 00:00:00 2001 From: andrea-berling Date: Fri, 1 Aug 2025 18:32:44 +0200 Subject: [PATCH 065/136] feat(sandbox): Add SANDBOX_FLAGS for custom container options (#2036) Co-authored-by: matt korwel --- docs/sandbox.md | 18 ++++++++++++++++++ packages/cli/src/utils/sandbox.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/sandbox.md b/docs/sandbox.md index 87763685..508a0d03 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -77,6 +77,24 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var): - `restrictive-open`: Strict restrictions, network allowed - `restrictive-closed`: Maximum restrictions +### Custom Sandbox Flags + +For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases. + +**Example (Podman)**: + +To disable SELinux labeling for volume mounts, you can set the following: + +```bash +export SANDBOX_FLAGS="--security-opt label=disable" +``` + +Multiple flags can be provided as a space-separated string: + +```bash +export SANDBOX_FLAGS="--flag1 --flag2=value" +``` + ## Linux UID/GID handling The sandbox automatically handles user permissions on Linux. Override these permissions with: diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 72b5e56b..d53608d1 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -9,7 +9,7 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { quote } from 'shell-quote'; +import { quote, parse } from 'shell-quote'; import { USER_SETTINGS_DIR, SETTINGS_DIRECTORY_NAME, @@ -399,6 +399,14 @@ export async function start_sandbox( // run init binary inside container to forward signals & reap zombies const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; + // add custom flags from SANDBOX_FLAGS + if (process.env.SANDBOX_FLAGS) { + const flags = parse(process.env.SANDBOX_FLAGS, process.env).filter( + (f): f is string => typeof f === 'string', + ); + args.push(...flags); + } + // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container if (process.stdin.isTTY) { args.push('-t'); From 24c5a15d7acdde3bd93c948db2227305951487a3 Mon Sep 17 00:00:00 2001 From: Billy Biggs Date: Fri, 1 Aug 2025 11:49:03 -0700 Subject: [PATCH 066/136] Add a setting to disable auth mode validation on startup (#5358) --- packages/cli/src/config/settings.ts | 1 + packages/cli/src/gemini.tsx | 6 +- packages/cli/src/ui/App.test.tsx | 51 ++++++++++++ packages/cli/src/ui/App.tsx | 9 +- .../src/validateNonInterActiveAuth.test.ts | 83 ++++++++++++++++--- .../cli/src/validateNonInterActiveAuth.ts | 11 ++- 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 752d7159..76eb1745 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -60,6 +60,7 @@ export interface Settings { theme?: string; customThemes?: Record; selectedAuthType?: AuthType; + useExternalAuth?: boolean; sandbox?: boolean | string; coreTools?: string[]; excludeTools?: string[]; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b4b70b61..73f3fdd0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -186,7 +186,10 @@ export async function main() { : []; const sandboxConfig = config.getSandbox(); if (sandboxConfig) { - if (settings.merged.selectedAuthType) { + if ( + settings.merged.selectedAuthType && + !settings.merged.useExternalAuth + ) { // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. try { const err = validateAuthMethod(settings.merged.selectedAuthType); @@ -344,6 +347,7 @@ async function loadNonInteractiveConfig( return await validateNonInteractiveAuth( settings.merged.selectedAuthType, + settings.merged.useExternalAuth, finalConfig, ); } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 79b9ce86..fc6dbb5a 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -26,6 +26,7 @@ import { Tips } from './components/Tips.js'; import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; import { EventEmitter } from 'events'; import { updateEventEmitter } from '../utils/updateEventEmitter.js'; +import * as auth from '../config/auth.js'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -232,6 +233,10 @@ vi.mock('./utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(), })); +vi.mock('./config/auth.js', () => ({ + validateAuthMethod: vi.fn(), +})); + const mockedCheckForUpdates = vi.mocked(checkForUpdates); const { isGitRepository: mockedIsGitRepository } = vi.mocked( await import('@google/gemini-cli-core'), @@ -1005,4 +1010,50 @@ describe('App UI', () => { expect(lastFrame()).toContain('5 errors'); }); }); + + describe('auth validation', () => { + it('should call validateAuthMethod when useExternalAuth is false', async () => { + const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); + mockSettings = createMockSettings({ + workspace: { + selectedAuthType: 'USE_GEMINI' as AuthType, + useExternalAuth: false, + theme: 'Default', + }, + }); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI'); + }); + + it('should NOT call validateAuthMethod when useExternalAuth is true', async () => { + const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); + mockSettings = createMockSettings({ + workspace: { + selectedAuthType: 'USE_GEMINI' as AuthType, + useExternalAuth: true, + theme: 'Default', + }, + }); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + expect(validateAuthMethodSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 046713ac..f63fcb35 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -234,14 +234,19 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } = useAuthCommand(settings, setAuthError, config); useEffect(() => { - if (settings.merged.selectedAuthType) { + if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) { const error = validateAuthMethod(settings.merged.selectedAuthType); if (error) { setAuthError(error); openAuthDialog(); } } - }, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]); + }, [ + settings.merged.selectedAuthType, + settings.merged.useExternalAuth, + openAuthDialog, + setAuthError, + ]); // Sync user tier from config when authentication changes useEffect(() => { diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 184a70e0..7c079e25 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -10,6 +10,7 @@ import { NonInteractiveConfig, } from './validateNonInterActiveAuth.js'; import { AuthType } from '@google/gemini-cli-core'; +import * as auth from './config/auth.js'; describe('validateNonInterActiveAuth', () => { let originalEnvGeminiApiKey: string | undefined; @@ -59,7 +60,11 @@ describe('validateNonInterActiveAuth', () => { refreshAuth: refreshAuthMock, }; try { - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect.fail('Should have exited'); } catch (e) { expect((e as Error).message).toContain('process.exit(1) called'); @@ -75,7 +80,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE); }); @@ -84,7 +93,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); @@ -95,7 +108,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -105,7 +122,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -118,7 +139,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE); }); @@ -130,7 +155,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI); }); @@ -142,7 +171,11 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(undefined, nonInteractiveConfig); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); @@ -152,20 +185,24 @@ describe('validateNonInterActiveAuth', () => { const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; - await validateNonInteractiveAuth(AuthType.USE_GEMINI, nonInteractiveConfig); + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + ); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); }); it('exits if validateAuthMethod returns error', async () => { // Mock validateAuthMethod to return error - const mod = await import('./config/auth.js'); - vi.spyOn(mod, 'validateAuthMethod').mockReturnValue('Auth error!'); + vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); const nonInteractiveConfig: NonInteractiveConfig = { refreshAuth: refreshAuthMock, }; try { await validateNonInteractiveAuth( AuthType.USE_GEMINI, + undefined, nonInteractiveConfig, ); expect.fail('Should have exited'); @@ -175,4 +212,28 @@ describe('validateNonInterActiveAuth', () => { expect(consoleErrorSpy).toHaveBeenCalledWith('Auth error!'); expect(processExitSpy).toHaveBeenCalledWith(1); }); + + it('skips validation if useExternalAuth is true', async () => { + // Mock validateAuthMethod to return error to ensure it's not being called + const validateAuthMethodSpy = vi + .spyOn(auth, 'validateAuthMethod') + .mockReturnValue('Auth error!'); + const nonInteractiveConfig: NonInteractiveConfig = { + refreshAuth: refreshAuthMock, + }; + + // Even with an invalid auth type, it should not exit + // because validation is skipped. + await validateNonInteractiveAuth( + 'invalid-auth-type' as AuthType, + true, // useExternalAuth = true + nonInteractiveConfig, + ); + + expect(validateAuthMethodSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + // We still expect refreshAuth to be called with the (invalid) type + expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type'); + }); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index a85b7370..7e80b30e 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -23,6 +23,7 @@ function getAuthTypeFromEnv(): AuthType | undefined { export async function validateNonInteractiveAuth( configuredAuthType: AuthType | undefined, + useExternalAuth: boolean | undefined, nonInteractiveConfig: Config, ) { const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv(); @@ -34,10 +35,12 @@ export async function validateNonInteractiveAuth( process.exit(1); } - const err = validateAuthMethod(effectiveAuthType); - if (err != null) { - console.error(err); - process.exit(1); + if (!useExternalAuth) { + const err = validateAuthMethod(effectiveAuthType); + if (err != null) { + console.error(err); + process.exit(1); + } } await nonInteractiveConfig.refreshAuth(effectiveAuthType); From c795168e9c3c024973c36e7741149b46be282679 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Fri, 1 Aug 2025 11:51:38 -0700 Subject: [PATCH 067/136] feat(core): Use completionStart/End for slash command auto-completion (#5374) --- packages/cli/src/ui/hooks/useCompletion.ts | 141 +++++++++------------ 1 file changed, 60 insertions(+), 81 deletions(-) diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 77b0ded4..7790f835 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -136,13 +136,14 @@ export function useCompletion( // Check if cursor is after @ or / without unescaped spaces const commandIndex = useMemo(() => { - if (isSlashCommand(buffer.text.trim())) { - return 0; + const currentLine = buffer.lines[cursorRow] || ''; + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return currentLine.indexOf('/'); } // For other completions like '@', we search backwards from the cursor. - const codePoints = toCodePoints(buffer.lines[cursorRow] || ''); + const codePoints = toCodePoints(currentLine); for (let i = cursorCol - 1; i >= 0; i--) { const char = codePoints[i]; @@ -162,7 +163,7 @@ export function useCompletion( } return -1; - }, [buffer.text, cursorRow, cursorCol, buffer.lines]); + }, [cursorRow, cursorCol, buffer.lines]); useEffect(() => { if (commandIndex === -1) { @@ -170,14 +171,15 @@ export function useCompletion( return; } - const trimmedQuery = buffer.text.trimStart(); + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); - if (trimmedQuery.startsWith('/')) { + if (codePoints[commandIndex] === '/') { // Always reset perfect match at the beginning of processing. setIsPerfectMatch(false); - const fullPath = trimmedQuery.substring(1); - const hasTrailingSpace = trimmedQuery.endsWith(' '); + const fullPath = currentLine.substring(commandIndex + 1); + const hasTrailingSpace = currentLine.endsWith(' '); // Get all non-empty parts of the command. const rawParts = fullPath.split(/\s+/).filter((p) => p); @@ -217,9 +219,10 @@ export function useCompletion( } } + let exactMatchAsParent: SlashCommand | undefined; // Handle the Ambiguous Case if (!hasTrailingSpace && currentLevel) { - const exactMatchAsParent = currentLevel.find( + exactMatchAsParent = currentLevel.find( (cmd) => (cmd.name === partial || cmd.altNames?.includes(partial)) && cmd.subCommands, @@ -253,15 +256,33 @@ export function useCompletion( } const depth = commandPathParts.length; - - // Provide Suggestions based on the now-corrected context - - // Argument Completion - if ( + const isArgumentCompletion = leafCommand?.completion && (hasTrailingSpace || - (rawParts.length > depth && depth > 0 && partial !== '')) - ) { + (rawParts.length > depth && depth > 0 && partial !== '')); + + // Set completion range + if (hasTrailingSpace || exactMatchAsParent) { + completionStart.current = currentLine.length; + completionEnd.current = currentLine.length; + } else if (partial) { + if (isArgumentCompletion) { + const commandSoFar = `/${commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); + completionStart.current = argStartIndex; + } else { + completionStart.current = currentLine.length - partial.length; + } + completionEnd.current = currentLine.length; + } else { + // e.g. / + completionStart.current = commandIndex + 1; + completionEnd.current = currentLine.length; + } + + // Provide Suggestions based on the now-corrected context + if (isArgumentCompletion) { const fetchAndSetSuggestions = async () => { setIsLoadingSuggestions(true); const argString = rawParts.slice(depth).join(' '); @@ -317,9 +338,6 @@ export function useCompletion( } // Handle At Command Completion - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); - completionEnd.current = codePoints.length; for (let i = cursorCol; i < codePoints.length; i++) { if (codePoints[i] === ' ') { @@ -639,73 +657,34 @@ export function useCompletion( if (indexToUse < 0 || indexToUse >= suggestions.length) { return; } - const query = buffer.text; const suggestion = suggestions[indexToUse].value; - if (query.trimStart().startsWith('/')) { - const hasTrailingSpace = query.endsWith(' '); - const parts = query - .trimStart() - .substring(1) - .split(/\s+/) - .filter(Boolean); - - let isParentPath = false; - // If there's no trailing space, we need to check if the current query - // is already a complete path to a parent command. - if (!hasTrailingSpace) { - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const found: SlashCommand | undefined = currentLevel?.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - - if (found) { - if (i === parts.length - 1 && found.subCommands) { - isParentPath = true; - } - currentLevel = found.subCommands as - | readonly SlashCommand[] - | undefined; - } else { - // Path is invalid, so it can't be a parent path. - currentLevel = undefined; - break; - } - } - } - - // Determine the base path of the command. - // - If there's a trailing space, the whole command is the base. - // - If it's a known parent path, the whole command is the base. - // - If the last part is a complete argument, the whole command is the base. - // - Otherwise, the base is everything EXCEPT the last partial part. - const lastPart = parts.length > 0 ? parts[parts.length - 1] : ''; - const isLastPartACompleteArg = - lastPart.startsWith('--') && lastPart.includes('='); - - const basePath = - hasTrailingSpace || isParentPath || isLastPartACompleteArg - ? parts - : parts.slice(0, -1); - const newValue = `/${[...basePath, suggestion].join(' ')} `; - - buffer.setText(newValue); - } else { - if (completionStart.current === -1 || completionEnd.current === -1) { - return; - } - - buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), - suggestion, - ); + if (completionStart.current === -1 || completionEnd.current === -1) { + return; } + + const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; + let suggestionText = suggestion; + if (isSlash) { + // If we are inserting (not replacing), and the preceding character is not a space, add one. + if ( + completionStart.current === completionEnd.current && + completionStart.current > commandIndex + 1 && + (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + ) { + suggestionText = ' ' + suggestionText; + } + suggestionText += ' '; + } + + buffer.replaceRangeByOffset( + logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), + logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + suggestionText, + ); resetCompletionState(); }, - [cursorRow, resetCompletionState, buffer, suggestions, slashCommands], + [cursorRow, resetCompletionState, buffer, suggestions, commandIndex], ); return { From 9382334a5ebb02ed716c73f45b469a8e8c932a13 Mon Sep 17 00:00:00 2001 From: Santhosh Kumar Date: Sat, 2 Aug 2025 00:56:03 +0530 Subject: [PATCH 068/136] feat(github): add workflow to manage stale issues and PRs (#4871) Co-authored-by: Jacob Richman --- .github/workflows/no-response.yml | 32 ++++++++++++++++++++++++++ .github/workflows/stale.yml | 38 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 .github/workflows/no-response.yml create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 00000000..3d3d8e7e --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,32 @@ +name: No Response + +# Run as a daily cron at 1:45 AM +on: + schedule: + - cron: '45 1 * * *' + workflow_dispatch: {} + +jobs: + no-response: + runs-on: ubuntu-latest + if: ${{ github.repository == 'google-gemini/gemini-cli' }} + permissions: + issues: write + pull-requests: write + concurrency: + group: ${{ github.workflow }}-no-response + cancel-in-progress: true + steps: + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-stale: -1 + days-before-close: 14 + stale-issue-label: 'status/need-information' + close-issue-message: > + This issue was marked as needing more information and has not received a response in 14 days. + Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you! + stale-pr-label: 'status/need-information' + close-pr-message: > + This pull request was marked as needing more information and has had no updates in 14 days. + Closing it for now. You are welcome to reopen with the required info. Thanks for contributing! diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..914e9d57 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,38 @@ +name: Mark stale issues and pull requests + +# Run as a daily cron at 1:30 AM +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: {} + +jobs: + stale: + runs-on: ubuntu-latest + if: ${{ github.repository == 'google-gemini/gemini-cli' }} + permissions: + issues: write + pull-requests: write + concurrency: + group: ${{ github.workflow }}-stale + cancel-in-progress: true + steps: + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: > + This issue has been automatically marked as stale due to 60 days of inactivity. + It will be closed in 14 days if no further activity occurs. + stale-pr-message: > + This pull request has been automatically marked as stale due to 60 days of inactivity. + It will be closed in 14 days if no further activity occurs. + close-issue-message: > + This issue has been closed due to 14 additional days of inactivity after being marked as stale. + If you believe this is still relevant, feel free to comment or reopen the issue. Thank you! + close-pr-message: > + This pull request has been closed due to 14 additional days of inactivity after being marked as stale. + If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing! + days-before-stale: 60 + days-before-close: 14 + exempt-issue-labels: pinned,security + exempt-pr-labels: pinned,security From 67d16992cfc6b18f1d242865e0fbd8f5f40cf25f Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 1 Aug 2025 12:30:39 -0700 Subject: [PATCH 069/136] bug(cli): Prefer IPv4 dns resolution by default. (#5338) --- packages/cli/src/config/settings.test.ts | 42 ++++++++++++++++++++++++ packages/cli/src/config/settings.ts | 3 ++ packages/cli/src/gemini.test.tsx | 41 ++++++++++++++++++++++- packages/cli/src/gemini.tsx | 23 +++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index ae655fe1..5a54e46e 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -777,6 +777,48 @@ describe('Settings Loading and Merging', () => { } }); + it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + dnsResolutionOrder: 'ipv4first', + }; + const workspaceSettingsContent = { + dnsResolutionOrder: 'verbatim', + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.dnsResolutionOrder).toBe('verbatim'); + }); + + it('should use user dnsResolutionOrder if workspace is not defined', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + dnsResolutionOrder: 'verbatim', + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.dnsResolutionOrder).toBe('verbatim'); + }); + it('should leave unresolved environment variables as is', () => { const userSettingsContent = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 76eb1745..4f701c6d 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -38,6 +38,8 @@ export function getSystemSettingsPath(): string { } } +export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; + export enum SettingScope { User = 'User', Workspace = 'Workspace', @@ -110,6 +112,7 @@ export interface Settings { disableAutoUpdate?: boolean; memoryDiscoveryMaxDirs?: number; + dnsResolutionOrder?: DnsResolutionOrder; } export interface SettingsError { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 505841c7..c8bb45ab 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -6,7 +6,11 @@ import stripAnsi from 'strip-ansi'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { main, setupUnhandledRejectionHandler } from './gemini.js'; +import { + main, + setupUnhandledRejectionHandler, + validateDnsResolutionOrder, +} from './gemini.js'; import { LoadedSettings, SettingsFile, @@ -211,3 +215,38 @@ describe('gemini.tsx main function', () => { processExitSpy.mockRestore(); }); }); + +describe('validateDnsResolutionOrder', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should return "ipv4first" when the input is "ipv4first"', () => { + expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return "verbatim" when the input is "verbatim"', () => { + expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return the default "ipv4first" when the input is undefined', () => { + expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return the default "ipv4first" and log a warning for an invalid string', () => { + expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); + expect(consoleWarnSpy).toHaveBeenCalledOnce(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', + ); + }); +}); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 73f3fdd0..48dbd271 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -12,9 +12,11 @@ import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; import v8 from 'node:v8'; import os from 'node:os'; +import dns from 'node:dns'; import { spawn } from 'node:child_process'; import { start_sandbox } from './utils/sandbox.js'; import { + DnsResolutionOrder, LoadedSettings, loadSettings, SettingScope, @@ -44,6 +46,23 @@ import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; +export function validateDnsResolutionOrder( + order: string | undefined, +): DnsResolutionOrder { + const defaultValue: DnsResolutionOrder = 'ipv4first'; + if (order === undefined) { + return defaultValue; + } + if (order === 'ipv4first' || order === 'verbatim') { + return order; + } + // We don't want to throw here, just warn and use the default. + console.warn( + `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, + ); + return defaultValue; +} + function getNodeMemoryArgs(config: Config): string[] { const totalMemoryMB = os.totalmem() / (1024 * 1024); const heapStats = v8.getHeapStatistics(); @@ -138,6 +157,10 @@ export async function main() { argv, ); + dns.setDefaultResultOrder( + validateDnsResolutionOrder(settings.merged.dnsResolutionOrder), + ); + if (argv.promptInteractive && !process.stdin.isTTY) { console.error( 'Error: The --prompt-interactive flag is not supported when piping input from stdin.', From a6a386f72aee3c5509a7c9fe7482060cf7d5884e Mon Sep 17 00:00:00 2001 From: owenofbrien <86964623+owenofbrien@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:37:56 -0500 Subject: [PATCH 070/136] Propagate prompt (#5033) --- .../core/src/code_assist/converter.test.ts | 57 ++++++++++-- packages/core/src/code_assist/converter.ts | 3 + packages/core/src/code_assist/server.test.ts | 78 ++++++++++++---- packages/core/src/code_assist/server.ts | 16 +++- packages/core/src/code_assist/setup.test.ts | 10 ++- packages/core/src/code_assist/setup.ts | 2 +- packages/core/src/core/client.test.ts | 90 +++++++++++-------- packages/core/src/core/client.ts | 37 ++++---- packages/core/src/core/contentGenerator.ts | 2 + packages/core/src/core/geminiChat.test.ts | 26 +++--- packages/core/src/core/geminiChat.ts | 26 +++--- 11 files changed, 245 insertions(+), 102 deletions(-) diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 03f388dc..3d3a8ef3 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -24,7 +24,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq, 'my-project'); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: 'my-project', @@ -37,8 +42,9 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: undefined, + session_id: 'my-session', }, + user_prompt_id: 'my-prompt', }); }); @@ -47,7 +53,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + undefined, + 'my-session', + ); expect(codeAssistReq).toEqual({ model: 'gemini-pro', project: undefined, @@ -60,8 +71,9 @@ describe('converter', () => { labels: undefined, safetySettings: undefined, generationConfig: undefined, - session_id: undefined, + session_id: 'my-session', }, + user_prompt_id: 'my-prompt', }); }); @@ -72,6 +84,7 @@ describe('converter', () => { }; const codeAssistReq = toGenerateContentRequest( genaiReq, + 'my-prompt', 'my-project', 'session-123', ); @@ -89,6 +102,7 @@ describe('converter', () => { generationConfig: undefined, session_id: 'session-123', }, + user_prompt_id: 'my-prompt', }); }); @@ -97,7 +111,12 @@ describe('converter', () => { model: 'gemini-pro', contents: 'Hello', }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, ]); @@ -108,7 +127,12 @@ describe('converter', () => { model: 'gemini-pro', contents: [{ text: 'Hello' }, { text: 'World' }], }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.contents).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'user', parts: [{ text: 'World' }] }, @@ -123,7 +147,12 @@ describe('converter', () => { systemInstruction: 'You are a helpful assistant.', }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.systemInstruction).toEqual({ role: 'user', parts: [{ text: 'You are a helpful assistant.' }], @@ -139,7 +168,12 @@ describe('converter', () => { topK: 40, }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.8, topK: 40, @@ -165,7 +199,12 @@ describe('converter', () => { responseMimeType: 'application/json', }, }; - const codeAssistReq = toGenerateContentRequest(genaiReq); + const codeAssistReq = toGenerateContentRequest( + genaiReq, + 'my-prompt', + 'my-project', + 'my-session', + ); expect(codeAssistReq.request.generationConfig).toEqual({ temperature: 0.1, topP: 0.2, diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 8340cfc1..ffd471da 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -32,6 +32,7 @@ import { export interface CAGenerateContentRequest { model: string; project?: string; + user_prompt_id?: string; request: VertexGenerateContentRequest; } @@ -115,12 +116,14 @@ export function fromCountTokenResponse( export function toGenerateContentRequest( req: GenerateContentParameters, + userPromptId: string, project?: string, sessionId?: string, ): CAGenerateContentRequest { return { model: req.model, project, + user_prompt_id: userPromptId, request: toVertexGenerateContentRequest(req, sessionId), }; } diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 6246fd4e..3fc1891f 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -14,13 +14,25 @@ vi.mock('google-auth-library'); describe('CodeAssistServer', () => { it('should be able to be constructed', () => { const auth = new OAuth2Client(); - const server = new CodeAssistServer(auth, 'test-project'); + const server = new CodeAssistServer( + auth, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); expect(server).toBeInstanceOf(CodeAssistServer); }); it('should call the generateContent endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { response: { candidates: [ @@ -38,10 +50,13 @@ describe('CodeAssistServer', () => { }; vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - const response = await server.generateContent({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }); + const response = await server.generateContent( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + ); expect(server.requestPost).toHaveBeenCalledWith( 'generateContent', @@ -55,7 +70,13 @@ describe('CodeAssistServer', () => { it('should call the generateContentStream endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = (async function* () { yield { response: { @@ -75,10 +96,13 @@ describe('CodeAssistServer', () => { })(); vi.spyOn(server, 'requestStreamingPost').mockResolvedValue(mockResponse); - const stream = await server.generateContentStream({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }); + const stream = await server.generateContentStream( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + ); for await (const res of stream) { expect(server.requestStreamingPost).toHaveBeenCalledWith( @@ -92,7 +116,13 @@ describe('CodeAssistServer', () => { it('should call the onboardUser endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { name: 'operations/123', done: true, @@ -114,7 +144,13 @@ describe('CodeAssistServer', () => { it('should call the loadCodeAssist endpoint', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { currentTier: { id: UserTierId.FREE, @@ -140,7 +176,13 @@ describe('CodeAssistServer', () => { it('should return 0 for countTokens', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); const mockResponse = { totalTokens: 100, }; @@ -155,7 +197,13 @@ describe('CodeAssistServer', () => { it('should throw an error for embedContent', async () => { const client = new OAuth2Client(); - const server = new CodeAssistServer(client, 'test-project'); + const server = new CodeAssistServer( + client, + 'test-project', + {}, + 'test-session', + UserTierId.FREE, + ); await expect( server.embedContent({ model: 'test-model', diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 7af643f7..08339bdc 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -53,10 +53,16 @@ export class CodeAssistServer implements ContentGenerator { async generateContentStream( req: GenerateContentParameters, + userPromptId: string, ): Promise> { const resps = await this.requestStreamingPost( 'streamGenerateContent', - toGenerateContentRequest(req, this.projectId, this.sessionId), + toGenerateContentRequest( + req, + userPromptId, + this.projectId, + this.sessionId, + ), req.config?.abortSignal, ); return (async function* (): AsyncGenerator { @@ -68,10 +74,16 @@ export class CodeAssistServer implements ContentGenerator { async generateContent( req: GenerateContentParameters, + userPromptId: string, ): Promise { const resp = await this.requestPost( 'generateContent', - toGenerateContentRequest(req, this.projectId, this.sessionId), + toGenerateContentRequest( + req, + userPromptId, + this.projectId, + this.sessionId, + ), req.config?.abortSignal, ); return fromGenerateContentResponse(resp); diff --git a/packages/core/src/code_assist/setup.test.ts b/packages/core/src/code_assist/setup.test.ts index 6db5fd88..c1260e3f 100644 --- a/packages/core/src/code_assist/setup.test.ts +++ b/packages/core/src/code_assist/setup.test.ts @@ -49,8 +49,11 @@ describe('setupUser', () => { }); await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - expect.any(Object), + {}, 'test-project', + {}, + '', + undefined, ); }); @@ -62,7 +65,10 @@ describe('setupUser', () => { }); const projectId = await setupUser({} as OAuth2Client); expect(CodeAssistServer).toHaveBeenCalledWith( - expect.any(Object), + {}, + undefined, + {}, + '', undefined, ); expect(projectId).toEqual({ diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts index 8831d24b..9c7a8043 100644 --- a/packages/core/src/code_assist/setup.ts +++ b/packages/core/src/code_assist/setup.ts @@ -34,7 +34,7 @@ export interface UserData { */ export async function setupUser(client: OAuth2Client): Promise { let projectId = process.env.GOOGLE_CLOUD_PROJECT || undefined; - const caServer = new CodeAssistServer(client, projectId); + const caServer = new CodeAssistServer(client, projectId, {}, '', undefined); const clientMetadata: ClientMetadata = { ideType: 'IDE_UNSPECIFIED', diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 68d8c231..1e39758a 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -214,7 +214,9 @@ describe('Gemini Client (client.ts)', () => { // We can instantiate the client here since Config is mocked // and the constructor will use the mocked GoogleGenAI - client = new GeminiClient(new Config({} as never)); + client = new GeminiClient( + new Config({ sessionId: 'test-session-id' } as never), + ); mockConfigObject.getGeminiClient.mockReturnValue(client); await client.initialize(contentGeneratorConfig); @@ -353,16 +355,19 @@ describe('Gemini Client (client.ts)', () => { await client.generateContent(contents, generationConfig, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: 'test-model', - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.5, - topP: 1, + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: 'test-model', + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.5, + topP: 1, + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); }); @@ -381,18 +386,21 @@ describe('Gemini Client (client.ts)', () => { await client.generateJson(contents, schema, abortSignal); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: 'test-model', // Should use current model from config - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0, - topP: 1, - responseSchema: schema, - responseMimeType: 'application/json', + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: 'test-model', // Should use current model from config + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0, + topP: 1, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); it('should allow overriding model and config', async () => { @@ -416,19 +424,22 @@ describe('Gemini Client (client.ts)', () => { customConfig, ); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: customModel, - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.9, - topP: 1, // from default - topK: 20, - responseSchema: schema, - responseMimeType: 'application/json', + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: customModel, + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.9, + topP: 1, // from default + topK: 20, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + 'test-session-id', + ); }); }); @@ -1196,11 +1207,14 @@ Here are some files the user has open, with the most recent at the top: config: expect.any(Object), contents, }); - expect(mockGenerateContentFn).toHaveBeenCalledWith({ - model: currentModel, - config: expect.any(Object), - contents, - }); + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: currentModel, + config: expect.any(Object), + contents, + }, + 'test-session-id', + ); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 57457826..3b6b57f9 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -110,7 +110,7 @@ export class GeminiClient { private readonly COMPRESSION_PRESERVE_THRESHOLD = 0.3; private readonly loopDetector: LoopDetectionService; - private lastPromptId?: string; + private lastPromptId: string; constructor(private config: Config) { if (config.getProxy()) { @@ -119,6 +119,7 @@ export class GeminiClient { this.embeddingModel = config.getEmbeddingModel(); this.loopDetector = new LoopDetectionService(config); + this.lastPromptId = this.config.getSessionId(); } async initialize(contentGeneratorConfig: ContentGeneratorConfig) { @@ -493,16 +494,19 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent({ - model: modelToUse, - config: { - ...requestConfig, - systemInstruction, - responseSchema: schema, - responseMimeType: 'application/json', + this.getContentGenerator().generateContent( + { + model: modelToUse, + config: { + ...requestConfig, + systemInstruction, + responseSchema: schema, + responseMimeType: 'application/json', + }, + contents, }, - contents, - }); + this.lastPromptId, + ); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => @@ -601,11 +605,14 @@ export class GeminiClient { }; const apiCall = () => - this.getContentGenerator().generateContent({ - model: modelToUse, - config: requestConfig, - contents, - }); + this.getContentGenerator().generateContent( + { + model: modelToUse, + config: requestConfig, + contents, + }, + this.lastPromptId, + ); const result = await retryWithBackoff(apiCall, { onPersistent429: async (authType?: string, error?: unknown) => diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 44ed7beb..797bad73 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -25,10 +25,12 @@ import { UserTierId } from '../code_assist/types.js'; export interface ContentGenerator { generateContent( request: GenerateContentParameters, + userPromptId: string, ): Promise; generateContentStream( request: GenerateContentParameters, + userPromptId: string, ): Promise>; countTokens(request: CountTokensParameters): Promise; diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 39dd883e..cd5e3841 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -79,11 +79,14 @@ describe('GeminiChat', () => { await chat.sendMessage({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContent).toHaveBeenCalledWith({ - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }); + expect(mockModelsModule.generateContent).toHaveBeenCalledWith( + { + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }, + 'prompt-id-1', + ); }); }); @@ -111,11 +114,14 @@ describe('GeminiChat', () => { await chat.sendMessageStream({ message: 'hello' }, 'prompt-id-1'); - expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith({ - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'hello' }] }], - config: {}, - }); + expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith( + { + model: 'gemini-pro', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + config: {}, + }, + 'prompt-id-1', + ); }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index d3b2e060..bd81400f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -287,11 +287,14 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContent({ - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }); + return this.contentGenerator.generateContent( + { + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }, + prompt_id, + ); }; response = await retryWithBackoff(apiCall, { @@ -394,11 +397,14 @@ export class GeminiChat { ); } - return this.contentGenerator.generateContentStream({ - model: modelToUse, - contents: requestContents, - config: { ...this.generationConfig, ...params.config }, - }); + return this.contentGenerator.generateContentStream( + { + model: modelToUse, + contents: requestContents, + config: { ...this.generationConfig, ...params.config }, + }, + prompt_id, + ); }; // Note: Retrying streams can be complex. If generateContentStream itself doesn't handle retries From dccca91fc944424b032b09d29afb85d225a71dcc Mon Sep 17 00:00:00 2001 From: mrcabbage972 Date: Fri, 1 Aug 2025 17:11:51 -0400 Subject: [PATCH 071/136] Switch utility calls to use the gemini-2.5-flash-lite model (#5193) Co-authored-by: Anjali Sridhar --- packages/core/src/config/models.ts | 1 + packages/core/src/utils/editCorrector.ts | 4 ++-- packages/core/src/utils/summarizer.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index e879268b..175f0579 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -7,4 +7,5 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; + export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001'; diff --git a/packages/core/src/utils/editCorrector.ts b/packages/core/src/utils/editCorrector.ts index a770c491..0ef8d4fe 100644 --- a/packages/core/src/utils/editCorrector.ts +++ b/packages/core/src/utils/editCorrector.ts @@ -17,14 +17,14 @@ import { ReadFileTool } from '../tools/read-file.js'; import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { GrepTool } from '../tools/grep.js'; import { LruCache } from './LruCache.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; import { isFunctionResponse, isFunctionCall, } from '../utils/messageInspectors.js'; import * as fs from 'fs'; -const EditModel = DEFAULT_GEMINI_FLASH_MODEL; +const EditModel = DEFAULT_GEMINI_FLASH_LITE_MODEL; const EditConfig: GenerateContentConfig = { thinkingConfig: { thinkingBudget: 0, diff --git a/packages/core/src/utils/summarizer.ts b/packages/core/src/utils/summarizer.ts index a038b8e3..b6e4f543 100644 --- a/packages/core/src/utils/summarizer.ts +++ b/packages/core/src/utils/summarizer.ts @@ -11,7 +11,7 @@ import { GenerateContentResponse, } from '@google/genai'; import { GeminiClient } from '../core/client.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; import { getResponseText, partToString } from './partUtils.js'; /** @@ -86,7 +86,7 @@ export async function summarizeToolOutput( contents, toolOutputSummarizerConfig, abortSignal, - DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, )) as unknown as GenerateContentResponse; return getResponseText(parsedResponse) || textToSummarize; } catch (error) { From 387706607dfa372f4f0c6fee14286bf4a290b258 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Fri, 1 Aug 2025 14:33:33 -0700 Subject: [PATCH 072/136] fix(tests): refactor integration tests to be less flaky (#4890) Co-authored-by: matt korwel --- integration-tests/file-system.test.js | 77 ++- integration-tests/google_web_search.test.js | 67 ++- integration-tests/list_directory.test.js | 54 +- integration-tests/read_many_files.test.js | 40 +- integration-tests/replace.test.js | 56 +- integration-tests/run-tests.js | 1 + integration-tests/run_shell_command.test.js | 58 +- integration-tests/save_memory.test.js | 28 +- integration-tests/simple-mcp-server.test.js | 227 ++++++-- integration-tests/test-helper.js | 517 +++++++++++++++++- integration-tests/write_file.test.js | 57 +- packages/core/src/utils/bfsFileSearch.test.ts | 6 +- 12 files changed, 1073 insertions(+), 115 deletions(-) diff --git a/integration-tests/file-system.test.js b/integration-tests/file-system.test.js index 87e9efe2..d43f047f 100644 --- a/integration-tests/file-system.test.js +++ b/integration-tests/file-system.test.js @@ -6,25 +6,84 @@ import { strict as assert } from 'assert'; import { test } from 'node:test'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test('reads a file', (t) => { +test('should be able to read a file', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to read a file'); rig.createFile('test.txt', 'hello world'); - const output = rig.run(`read the file name test.txt`); + const result = await rig.run( + `read the file test.txt and show me its contents`, + ); - assert.ok(output.toLowerCase().includes('hello')); + const foundToolCall = await rig.waitForToolCall('read_file'); + + // Add debugging information + if (!foundToolCall || !result.includes('hello world')) { + printDebugInfo(rig, result, { + 'Found tool call': foundToolCall, + 'Contains hello world': result.includes('hello world'), + }); + } + + assert.ok(foundToolCall, 'Expected to find a read_file tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput(result, 'hello world', 'File read test'); }); -test('writes a file', (t) => { +test('should be able to write a file', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to write a file'); rig.createFile('test.txt', ''); - rig.run(`edit test.txt to have a hello world message`); + const result = await rig.run(`edit test.txt to have a hello world message`); + + // Accept multiple valid tools for editing files + const foundToolCall = await rig.waitForAnyToolCall([ + 'write_file', + 'edit', + 'replace', + ]); + + // Add debugging information + if (!foundToolCall) { + printDebugInfo(rig, result); + } + + assert.ok( + foundToolCall, + 'Expected to find a write_file, edit, or replace tool call', + ); + + // Validate model output - will throw if no output + validateModelOutput(result, null, 'File write test'); const fileContent = rig.readFile('test.txt'); - assert.ok(fileContent.toLowerCase().includes('hello')); + + // Add debugging for file content + if (!fileContent.toLowerCase().includes('hello')) { + const writeCalls = rig + .readToolLogs() + .filter((t) => t.toolRequest.name === 'write_file') + .map((t) => t.toolRequest.args); + + printDebugInfo(rig, result, { + 'File content mismatch': true, + 'Expected to contain': 'hello', + 'Actual content': fileContent, + 'Write tool calls': JSON.stringify(writeCalls), + }); + } + + assert.ok( + fileContent.toLowerCase().includes('hello'), + 'Expected file to contain hello', + ); + + // Log success info if verbose + if (process.env.VERBOSE === 'true') { + console.log('File written successfully with hello message.'); + } }); diff --git a/integration-tests/google_web_search.test.js b/integration-tests/google_web_search.test.js index a8968117..31747421 100644 --- a/integration-tests/google_web_search.test.js +++ b/integration-tests/google_web_search.test.js @@ -6,14 +6,69 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test('should be able to search the web', async (t) => { +test('should be able to search the web', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to search the web'); - const prompt = `what planet do we live on`; - const result = await rig.run(prompt); + let result; + try { + result = await rig.run(`what is the weather in London`); + } catch (error) { + // Network errors can occur in CI environments + if ( + error.message.includes('network') || + error.message.includes('timeout') + ) { + console.warn('Skipping test due to network error:', error.message); + return; // Skip the test + } + throw error; // Re-throw if not a network error + } - assert.ok(result.toLowerCase().includes('earth')); + const foundToolCall = await rig.waitForToolCall('google_web_search'); + + // Add debugging information + if (!foundToolCall) { + const allTools = printDebugInfo(rig, result); + + // Check if the tool call failed due to network issues + const failedSearchCalls = allTools.filter( + (t) => + t.toolRequest.name === 'google_web_search' && !t.toolRequest.success, + ); + if (failedSearchCalls.length > 0) { + console.warn( + 'google_web_search tool was called but failed, possibly due to network issues', + ); + console.warn( + 'Failed calls:', + failedSearchCalls.map((t) => t.toolRequest.args), + ); + return; // Skip the test if network issues + } + } + + assert.ok(foundToolCall, 'Expected to find a call to google_web_search'); + + // Validate model output - will throw if no output, warn if missing expected content + const hasExpectedContent = validateModelOutput( + result, + ['weather', 'london'], + 'Google web search test', + ); + + // If content was missing, log the search queries used + if (!hasExpectedContent) { + const searchCalls = rig + .readToolLogs() + .filter((t) => t.toolRequest.name === 'google_web_search'); + if (searchCalls.length > 0) { + console.warn( + 'Search queries used:', + searchCalls.map((t) => t.toolRequest.args), + ); + } + } }); diff --git a/integration-tests/list_directory.test.js b/integration-tests/list_directory.test.js index af7aae78..16f49f4b 100644 --- a/integration-tests/list_directory.test.js +++ b/integration-tests/list_directory.test.js @@ -6,19 +6,57 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { existsSync } from 'fs'; +import { join } from 'path'; -test('should be able to list a directory', async (t) => { +test('should be able to list a directory', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to list a directory'); rig.createFile('file1.txt', 'file 1 content'); rig.mkdir('subdir'); rig.sync(); - const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`; - const result = rig.run(prompt); + // Poll for filesystem changes to propagate in containers + await rig.poll( + () => { + // Check if the files exist in the test directory + const file1Path = join(rig.testDir, 'file1.txt'); + const subdirPath = join(rig.testDir, 'subdir'); + return existsSync(file1Path) && existsSync(subdirPath); + }, + 1000, // 1 second max wait + 50, // check every 50ms + ); - const lines = result.split('\n').filter((line) => line.trim() !== ''); - assert.ok(lines.some((line) => line.includes('file1.txt'))); - assert.ok(lines.some((line) => line.includes('subdir'))); + const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`; + + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('list_directory'); + + // Add debugging information + if ( + !foundToolCall || + !result.includes('file1.txt') || + !result.includes('subdir') + ) { + const allTools = printDebugInfo(rig, result, { + 'Found tool call': foundToolCall, + 'Contains file1.txt': result.includes('file1.txt'), + 'Contains subdir': result.includes('subdir'), + }); + + console.error( + 'List directory calls:', + allTools + .filter((t) => t.toolRequest.name === 'list_directory') + .map((t) => t.toolRequest.args), + ); + } + + assert.ok(foundToolCall, 'Expected to find a list_directory tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test'); }); diff --git a/integration-tests/read_many_files.test.js b/integration-tests/read_many_files.test.js index 7e770036..74d2f358 100644 --- a/integration-tests/read_many_files.test.js +++ b/integration-tests/read_many_files.test.js @@ -6,17 +6,45 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test.skip('should be able to read multiple files', async (t) => { +test('should be able to read multiple files', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to read multiple files'); rig.createFile('file1.txt', 'file 1 content'); rig.createFile('file2.txt', 'file 2 content'); - const prompt = `Read the files in this directory, list them and print them to the screen`; + const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`; + const result = await rig.run(prompt); - assert.ok(result.includes('file 1 content')); - assert.ok(result.includes('file 2 content')); + // Check for either read_many_files or multiple read_file calls + const allTools = rig.readToolLogs(); + const readManyFilesCall = await rig.waitForToolCall('read_many_files'); + const readFileCalls = allTools.filter( + (t) => t.toolRequest.name === 'read_file', + ); + + // Accept either read_many_files OR at least 2 read_file calls + const foundValidPattern = readManyFilesCall || readFileCalls.length >= 2; + + // Add debugging information + if (!foundValidPattern) { + printDebugInfo(rig, result, { + 'read_many_files called': readManyFilesCall, + 'read_file calls': readFileCalls.length, + }); + } + + assert.ok( + foundValidPattern, + 'Expected to find either read_many_files or multiple read_file tool calls', + ); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput( + result, + ['file 1 content', 'file 2 content'], + 'Read many files test', + ); }); diff --git a/integration-tests/replace.test.js b/integration-tests/replace.test.js index 060aba55..1ac6f5a4 100644 --- a/integration-tests/replace.test.js +++ b/integration-tests/replace.test.js @@ -6,17 +6,61 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test('should be able to replace content in a file', async (t) => { +test('should be able to replace content in a file', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to replace content in a file'); const fileName = 'file_to_replace.txt'; - rig.createFile(fileName, 'original content'); + const originalContent = 'original content'; + const expectedContent = 'replaced content'; + + rig.createFile(fileName, originalContent); const prompt = `Can you replace 'original' with 'replaced' in the file 'file_to_replace.txt'`; - await rig.run(prompt); + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('replace'); + + // Add debugging information + if (!foundToolCall) { + printDebugInfo(rig, result); + } + + assert.ok(foundToolCall, 'Expected to find a replace tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput( + result, + ['replaced', 'file_to_replace.txt'], + 'Replace content test', + ); + const newFileContent = rig.readFile(fileName); - assert.strictEqual(newFileContent, 'replaced content'); + + // Add debugging for file content + if (newFileContent !== expectedContent) { + console.error('File content mismatch - Debug info:'); + console.error('Expected:', expectedContent); + console.error('Actual:', newFileContent); + console.error( + 'Tool calls:', + rig.readToolLogs().map((t) => ({ + name: t.toolRequest.name, + args: t.toolRequest.args, + })), + ); + } + + assert.strictEqual( + newFileContent, + expectedContent, + 'File content should be updated correctly', + ); + + // Log success info if verbose + if (process.env.VERBOSE === 'true') { + console.log('File replaced successfully. New content:', newFileContent); + } }); diff --git a/integration-tests/run-tests.js b/integration-tests/run-tests.js index 4b4a9a94..05fb349e 100644 --- a/integration-tests/run-tests.js +++ b/integration-tests/run-tests.js @@ -101,6 +101,7 @@ async function main() { KEEP_OUTPUT: keepOutput.toString(), VERBOSE: verbose.toString(), TEST_FILE_NAME: testFileName, + TELEMETRY_LOG_FILE: join(testFileDir, 'telemetry.log'), }, }); diff --git a/integration-tests/run_shell_command.test.js b/integration-tests/run_shell_command.test.js index 52aee194..2a5f9ed4 100644 --- a/integration-tests/run_shell_command.test.js +++ b/integration-tests/run_shell_command.test.js @@ -6,26 +6,58 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test('should be able to run a shell command', async (t) => { +test('should be able to run a shell command', async () => { const rig = new TestRig(); - rig.setup(t.name); - rig.createFile('blah.txt', 'some content'); + await rig.setup('should be able to run a shell command'); - const prompt = `Can you use ls to list the contexts of the current folder`; - const result = rig.run(prompt); + const prompt = `Please run the command "echo hello-world" and show me the output`; - assert.ok(result.includes('blah.txt')); + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('run_shell_command'); + + // Add debugging information + if (!foundToolCall || !result.includes('hello-world')) { + printDebugInfo(rig, result, { + 'Found tool call': foundToolCall, + 'Contains hello-world': result.includes('hello-world'), + }); + } + + assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + // Model often reports exit code instead of showing output + validateModelOutput( + result, + ['hello-world', 'exit code 0'], + 'Shell command test', + ); }); -test('should be able to run a shell command via stdin', async (t) => { +test('should be able to run a shell command via stdin', async () => { const rig = new TestRig(); - rig.setup(t.name); - rig.createFile('blah.txt', 'some content'); + await rig.setup('should be able to run a shell command via stdin'); - const prompt = `Can you use ls to list the contexts of the current folder`; - const result = rig.run({ stdin: prompt }); + const prompt = `Please run the command "echo test-stdin" and show me what it outputs`; - assert.ok(result.includes('blah.txt')); + const result = await rig.run({ stdin: prompt }); + + const foundToolCall = await rig.waitForToolCall('run_shell_command'); + + // Add debugging information + if (!foundToolCall || !result.includes('test-stdin')) { + printDebugInfo(rig, result, { + 'Test type': 'Stdin test', + 'Found tool call': foundToolCall, + 'Contains test-stdin': result.includes('test-stdin'), + }); + } + + assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput(result, 'test-stdin', 'Shell command stdin test'); }); diff --git a/integration-tests/save_memory.test.js b/integration-tests/save_memory.test.js index 0716f978..3ec641d4 100644 --- a/integration-tests/save_memory.test.js +++ b/integration-tests/save_memory.test.js @@ -6,16 +6,36 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; -test('should be able to save to memory', async (t) => { +test('should be able to save to memory', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to save to memory'); const prompt = `remember that my favorite color is blue. what is my favorite color? tell me that and surround it with $ symbol`; const result = await rig.run(prompt); - assert.ok(result.toLowerCase().includes('$blue$')); + const foundToolCall = await rig.waitForToolCall('save_memory'); + + // Add debugging information + if (!foundToolCall || !result.toLowerCase().includes('blue')) { + const allTools = printDebugInfo(rig, result, { + 'Found tool call': foundToolCall, + 'Contains blue': result.toLowerCase().includes('blue'), + }); + + console.error( + 'Memory tool calls:', + allTools + .filter((t) => t.toolRequest.name === 'save_memory') + .map((t) => t.toolRequest.args), + ); + } + + assert.ok(foundToolCall, 'Expected to find a save_memory tool call'); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput(result, 'blue', 'Save memory test'); }); diff --git a/integration-tests/simple-mcp-server.test.js b/integration-tests/simple-mcp-server.test.js index fc88522d..987f69d2 100644 --- a/integration-tests/simple-mcp-server.test.js +++ b/integration-tests/simple-mcp-server.test.js @@ -4,67 +4,208 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { test, describe, before, after } from 'node:test'; +/** + * This test verifies MCP (Model Context Protocol) server integration. + * It uses a minimal MCP server implementation that doesn't require + * external dependencies, making it compatible with Docker sandbox mode. + */ + +import { test, describe, before } from 'node:test'; import { strict as assert } from 'node:assert'; -import { TestRig } from './test-helper.js'; -import { spawn } from 'child_process'; +import { TestRig, validateModelOutput } from './test-helper.js'; import { join } from 'path'; import { fileURLToPath } from 'url'; -import { writeFileSync, unlinkSync } from 'fs'; +import { writeFileSync } from 'fs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const serverScriptPath = join(__dirname, './temp-server.js'); -const serverScript = ` -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; +// Create a minimal MCP server that doesn't require external dependencies +// This implements the MCP protocol directly using Node.js built-ins +const serverScript = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ -const server = new McpServer({ - name: 'addition-server', - version: '1.0.0', +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'addition-server', + version: '1.0.0' + } + }; }); -server.registerTool( - 'add', - { - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { a: z.number(), b: z.number() }, - }, - async ({ a, b }) => ({ - content: [{ type: 'text', text: String(a + b) }], - }), -); +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [{ + name: 'add', + description: 'Add two numbers', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }] + }; +}); -const transport = new StdioServerTransport(); -await server.connect(transport); +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); `; describe('simple-mcp-server', () => { const rig = new TestRig(); - let child; - before(() => { - writeFileSync(serverScriptPath, serverScript); - child = spawn('node', [serverScriptPath], { - stdio: ['pipe', 'pipe', 'pipe'], + before(async () => { + // Setup test directory with MCP server configuration + await rig.setup('simple-mcp-server', { + settings: { + mcpServers: { + 'addition-server': { + command: 'node', + args: ['mcp-server.cjs'], + }, + }, + }, }); - child.stderr.on('data', (data) => { - console.error(`stderr: ${data}`); - }); - // Wait for the server to be ready - return new Promise((resolve) => setTimeout(resolve, 2000)); + + // Create server script in the test directory + const testServerPath = join(rig.testDir, 'mcp-server.cjs'); + writeFileSync(testServerPath, serverScript); + + // Make the script executable (though running with 'node' should work anyway) + if (process.platform !== 'win32') { + const { chmodSync } = await import('fs'); + chmodSync(testServerPath, 0o755); + } }); - after(() => { - child.kill(); - unlinkSync(serverScriptPath); - }); + test('should add two numbers', async () => { + // Test directory is already set up in before hook + // Just run the command - MCP server config is in settings.json + const output = await rig.run('add 5 and 10'); - test('should add two numbers', () => { - rig.setup('should add two numbers'); - const output = rig.run('add 5 and 10'); - assert.ok(output.includes('15')); + const foundToolCall = await rig.waitForToolCall('add'); + + assert.ok(foundToolCall, 'Expected to find an add tool call'); + + // Validate model output - will throw if no output, fail if missing expected content + validateModelOutput(output, '15', 'MCP server test'); + assert.ok(output.includes('15'), 'Expected output to contain the sum (15)'); }); }); diff --git a/integration-tests/test-helper.js b/integration-tests/test-helper.js index 7ee3db87..9526ea5f 100644 --- a/integration-tests/test-helper.js +++ b/integration-tests/test-helper.js @@ -4,11 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; +import { parse } from 'shell-quote'; import { mkdirSync, writeFileSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { env } from 'process'; +import { fileExists } from '../scripts/telemetry_utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -19,17 +21,129 @@ function sanitizeTestName(name) { .replace(/-+/g, '-'); } +// Helper to create detailed error messages +export function createToolCallErrorMessage(expectedTools, foundTools, result) { + const expectedStr = Array.isArray(expectedTools) + ? expectedTools.join(' or ') + : expectedTools; + return ( + `Expected to find ${expectedStr} tool call(s). ` + + `Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` + + `Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}` + ); +} + +// Helper to print debug information when tests fail +export function printDebugInfo(rig, result, context = {}) { + console.error('Test failed - Debug info:'); + console.error('Result length:', result.length); + console.error('Result (first 500 chars):', result.substring(0, 500)); + console.error( + 'Result (last 500 chars):', + result.substring(result.length - 500), + ); + + // Print any additional context provided + Object.entries(context).forEach(([key, value]) => { + console.error(`${key}:`, value); + }); + + // Check what tools were actually called + const allTools = rig.readToolLogs(); + console.error( + 'All tool calls found:', + allTools.map((t) => t.toolRequest.name), + ); + + return allTools; +} + +// Helper to validate model output and warn about unexpected content +export function validateModelOutput( + result, + expectedContent = null, + testName = '', +) { + // First, check if there's any output at all (this should fail the test if missing) + if (!result || result.trim().length === 0) { + throw new Error('Expected LLM to return some output'); + } + + // If expectedContent is provided, check for it and warn if missing + if (expectedContent) { + const contents = Array.isArray(expectedContent) + ? expectedContent + : [expectedContent]; + const missingContent = contents.filter((content) => { + if (typeof content === 'string') { + return !result.toLowerCase().includes(content.toLowerCase()); + } else if (content instanceof RegExp) { + return !content.test(result); + } + return false; + }); + + if (missingContent.length > 0) { + console.warn( + `Warning: LLM did not include expected content in response: ${missingContent.join(', ')}.`, + 'This is not ideal but not a test failure.', + ); + console.warn( + 'The tool was called successfully, which is the main requirement.', + ); + return false; + } else if (process.env.VERBOSE === 'true') { + console.log(`${testName}: Model output validated successfully.`); + } + return true; + } + + return true; +} + export class TestRig { constructor() { this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); this.testDir = null; } - setup(testName) { + // Get timeout based on environment + getDefaultTimeout() { + if (env.CI) return 60000; // 1 minute in CI + if (env.GEMINI_SANDBOX) return 30000; // 30s in containers + return 15000; // 15s locally + } + + setup(testName, options = {}) { this.testName = testName; const sanitizedName = sanitizeTestName(testName); this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName); mkdirSync(this.testDir, { recursive: true }); + + // Create a settings file to point the CLI to the local collector + const geminiDir = join(this.testDir, '.gemini'); + mkdirSync(geminiDir, { recursive: true }); + // In sandbox mode, use an absolute path for telemetry inside the container + // The container mounts the test directory at the same path as the host + const telemetryPath = + env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' + ? join(this.testDir, 'telemetry.log') // Absolute path in test directory + : env.TELEMETRY_LOG_FILE; // Absolute path for non-sandbox + + const settings = { + telemetry: { + enabled: true, + target: 'local', + otlpEndpoint: '', + outfile: telemetryPath, + }, + sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false, + ...options.settings, // Allow tests to override/add settings + }; + writeFileSync( + join(geminiDir, 'settings.json'), + JSON.stringify(settings, null, 2), + ); } createFile(fileName, content) { @@ -39,7 +153,7 @@ export class TestRig { } mkdir(dir) { - mkdirSync(join(this.testDir, dir)); + mkdirSync(join(this.testDir, dir), { recursive: true }); } sync() { @@ -70,19 +184,88 @@ export class TestRig { command += ` ${args.join(' ')}`; - const output = execSync(command, execOptions); + const commandArgs = parse(command); + const node = commandArgs.shift(); - if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { - const testId = `${env.TEST_FILE_NAME.replace( - '.test.js', - '', - )}:${this.testName.replace(/ /g, '-')}`; - console.log(`--- TEST: ${testId} ---`); - console.log(output); - console.log(`--- END TEST: ${testId} ---`); + const child = spawn(node, commandArgs, { + cwd: this.testDir, + stdio: 'pipe', + }); + + let stdout = ''; + let stderr = ''; + + // Handle stdin if provided + if (execOptions.input) { + child.stdin.write(execOptions.input); + child.stdin.end(); } - return output; + child.stdout.on('data', (data) => { + stdout += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stdout.write(data); + } + }); + + child.stderr.on('data', (data) => { + stderr += data; + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + process.stderr.write(data); + } + }); + + const promise = new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + // Store the raw stdout for Podman telemetry parsing + this._lastRunStdout = stdout; + + // Filter out telemetry output when running with Podman + // Podman seems to output telemetry to stdout even when writing to file + let result = stdout; + if (env.GEMINI_SANDBOX === 'podman') { + // Remove telemetry JSON objects from output + // They are multi-line JSON objects that start with { and contain telemetry fields + const lines = result.split('\n'); + const filteredLines = []; + let inTelemetryObject = false; + let braceDepth = 0; + + for (const line of lines) { + if (!inTelemetryObject && line.trim() === '{') { + // Check if this might be start of telemetry object + inTelemetryObject = true; + braceDepth = 1; + } else if (inTelemetryObject) { + // Count braces to track nesting + for (const char of line) { + if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + } + + // Check if we've closed all braces + if (braceDepth === 0) { + inTelemetryObject = false; + // Skip this line (the closing brace) + continue; + } + } else { + // Not in telemetry object, keep the line + filteredLines.push(line); + } + } + + result = filteredLines.join('\n'); + } + resolve(result); + } else { + reject(new Error(`Process exited with code ${code}:\n${stderr}`)); + } + }); + }); + + return promise; } readFile(fileName) { @@ -98,4 +281,312 @@ export class TestRig { } return content; } + + async cleanup() { + // Clean up test directory + if (this.testDir && !env.KEEP_OUTPUT) { + try { + execSync(`rm -rf ${this.testDir}`); + } catch (error) { + // Ignore cleanup errors + if (env.VERBOSE === 'true') { + console.warn('Cleanup warning:', error.message); + } + } + } + } + + async waitForTelemetryReady() { + // In sandbox mode, telemetry is written to a relative path in the test directory + const logFilePath = + env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' + ? join(this.testDir, 'telemetry.log') + : env.TELEMETRY_LOG_FILE; + + if (!logFilePath) return; + + // Wait for telemetry file to exist and have content + await this.poll( + () => { + if (!fileExists(logFilePath)) return false; + try { + const content = readFileSync(logFilePath, 'utf-8'); + // Check if file has meaningful content (at least one complete JSON object) + return content.includes('"event.name"'); + } catch (_e) { + return false; + } + }, + 2000, // 2 seconds max - reduced since telemetry should flush on exit now + 100, // check every 100ms + ); + } + + async waitForToolCall(toolName, timeout) { + // Use environment-specific timeout + if (!timeout) { + timeout = this.getDefaultTimeout(); + } + + // Wait for telemetry to be ready before polling for tool calls + await this.waitForTelemetryReady(); + + return this.poll( + () => { + const toolLogs = this.readToolLogs(); + return toolLogs.some((log) => log.toolRequest.name === toolName); + }, + timeout, + 100, + ); + } + + async waitForAnyToolCall(toolNames, timeout) { + // Use environment-specific timeout + if (!timeout) { + timeout = this.getDefaultTimeout(); + } + + // Wait for telemetry to be ready before polling for tool calls + await this.waitForTelemetryReady(); + + return this.poll( + () => { + const toolLogs = this.readToolLogs(); + return toolNames.some((name) => + toolLogs.some((log) => log.toolRequest.name === name), + ); + }, + timeout, + 100, + ); + } + + async poll(predicate, timeout, interval) { + const startTime = Date.now(); + let attempts = 0; + while (Date.now() - startTime < timeout) { + attempts++; + const result = predicate(); + if (env.VERBOSE === 'true' && attempts % 5 === 0) { + console.log( + `Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`, + ); + } + if (result) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + if (env.VERBOSE === 'true') { + console.log(`Poll timed out after ${attempts} attempts`); + } + return false; + } + + _parseToolLogsFromStdout(stdout) { + const logs = []; + + // The console output from Podman is JavaScript object notation, not JSON + // Look for tool call events in the output + // Updated regex to handle tool names with hyphens and underscores + const toolCallPattern = + /body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g; + const matches = [...stdout.matchAll(toolCallPattern)]; + + for (const match of matches) { + const toolName = match[1]; + const success = match[2] === 'true'; + const duration = parseInt(match[3], 10); + + // Try to find function_args nearby + const matchIndex = match.index || 0; + const contextStart = Math.max(0, matchIndex - 500); + const contextEnd = Math.min(stdout.length, matchIndex + 500); + const context = stdout.substring(contextStart, contextEnd); + + // Look for function_args in the context + let args = '{}'; + const argsMatch = context.match(/function_args:\s*'([^']+)'/); + if (argsMatch) { + args = argsMatch[1]; + } + + // Also try to find function_name to double-check + // Updated regex to handle tool names with hyphens and underscores + const nameMatch = context.match(/function_name:\s*'([\w-]+)'/); + const actualToolName = nameMatch ? nameMatch[1] : toolName; + + logs.push({ + timestamp: Date.now(), + toolRequest: { + name: actualToolName, + args: args, + success: success, + duration_ms: duration, + }, + }); + } + + // If no matches found with the simple pattern, try the JSON parsing approach + // in case the format changes + if (logs.length === 0) { + const lines = stdout.split('\n'); + let currentObject = ''; + let inObject = false; + let braceDepth = 0; + + for (const line of lines) { + if (!inObject && line.trim() === '{') { + inObject = true; + braceDepth = 1; + currentObject = line + '\n'; + } else if (inObject) { + currentObject += line + '\n'; + + // Count braces + for (const char of line) { + if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + } + + // If we've closed all braces, try to parse the object + if (braceDepth === 0) { + inObject = false; + try { + const obj = JSON.parse(currentObject); + + // Check for tool call in different formats + if ( + obj.body && + obj.body.includes('Tool call:') && + obj.attributes + ) { + const bodyMatch = obj.body.match(/Tool call: (\w+)\./); + if (bodyMatch) { + logs.push({ + timestamp: obj.timestamp || Date.now(), + toolRequest: { + name: bodyMatch[1], + args: obj.attributes.function_args || '{}', + success: obj.attributes.success !== false, + duration_ms: obj.attributes.duration_ms || 0, + }, + }); + } + } else if ( + obj.attributes && + obj.attributes['event.name'] === 'gemini_cli.tool_call' + ) { + logs.push({ + timestamp: obj.attributes['event.timestamp'], + toolRequest: { + name: obj.attributes.function_name, + args: obj.attributes.function_args, + success: obj.attributes.success, + duration_ms: obj.attributes.duration_ms, + }, + }); + } + } catch (_e) { + // Not valid JSON + } + currentObject = ''; + } + } + } + } + + return logs; + } + + readToolLogs() { + // For Podman, first check if telemetry file exists and has content + // If not, fall back to parsing from stdout + if (env.GEMINI_SANDBOX === 'podman') { + // Try reading from file first + const logFilePath = join(this.testDir, 'telemetry.log'); + + if (fileExists(logFilePath)) { + try { + const content = readFileSync(logFilePath, 'utf-8'); + if (content && content.includes('"event.name"')) { + // File has content, use normal file parsing + // Continue to the normal file parsing logic below + } else if (this._lastRunStdout) { + // File exists but is empty or doesn't have events, parse from stdout + return this._parseToolLogsFromStdout(this._lastRunStdout); + } + } catch (_e) { + // Error reading file, fall back to stdout + if (this._lastRunStdout) { + return this._parseToolLogsFromStdout(this._lastRunStdout); + } + } + } else if (this._lastRunStdout) { + // No file exists, parse from stdout + return this._parseToolLogsFromStdout(this._lastRunStdout); + } + } + + // In sandbox mode, telemetry is written to a relative path in the test directory + const logFilePath = + env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false' + ? join(this.testDir, 'telemetry.log') + : env.TELEMETRY_LOG_FILE; + + if (!logFilePath) { + console.warn(`TELEMETRY_LOG_FILE environment variable not set`); + return []; + } + + // Check if file exists, if not return empty array (file might not be created yet) + if (!fileExists(logFilePath)) { + return []; + } + + const content = readFileSync(logFilePath, 'utf-8'); + + // Split the content into individual JSON objects + // They are separated by "}\n{" pattern + const jsonObjects = content + .split(/}\s*\n\s*{/) + .map((obj, index, array) => { + // Add back the braces we removed during split + if (index > 0) obj = '{' + obj; + if (index < array.length - 1) obj = obj + '}'; + return obj.trim(); + }) + .filter((obj) => obj); + + const logs = []; + + for (const jsonStr of jsonObjects) { + try { + const logData = JSON.parse(jsonStr); + // Look for tool call logs + if ( + logData.attributes && + logData.attributes['event.name'] === 'gemini_cli.tool_call' + ) { + const toolName = logData.attributes.function_name; + logs.push({ + toolRequest: { + name: toolName, + args: logData.attributes.function_args, + success: logData.attributes.success, + duration_ms: logData.attributes.duration_ms, + }, + }); + } + } catch (_e) { + // Skip objects that aren't valid JSON + if (env.VERBOSE === 'true') { + console.error('Failed to parse telemetry object:', _e.message); + } + } + } + + return logs; + } } diff --git a/integration-tests/write_file.test.js b/integration-tests/write_file.test.js index 46a15f3c..7809161e 100644 --- a/integration-tests/write_file.test.js +++ b/integration-tests/write_file.test.js @@ -6,16 +6,63 @@ import { test } from 'node:test'; import { strict as assert } from 'assert'; -import { TestRig } from './test-helper.js'; +import { + TestRig, + createToolCallErrorMessage, + printDebugInfo, + validateModelOutput, +} from './test-helper.js'; -test('should be able to write a file', async (t) => { +test('should be able to write a file', async () => { const rig = new TestRig(); - rig.setup(t.name); + await rig.setup('should be able to write a file'); const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`; - await rig.run(prompt); + const result = await rig.run(prompt); + + const foundToolCall = await rig.waitForToolCall('write_file'); + + // Add debugging information + if (!foundToolCall) { + printDebugInfo(rig, result); + } + + const allTools = rig.readToolLogs(); + assert.ok( + foundToolCall, + createToolCallErrorMessage( + 'write_file', + allTools.map((t) => t.toolRequest.name), + result, + ), + ); + + // Validate model output - will throw if no output, warn if missing expected content + validateModelOutput(result, 'dad.txt', 'Write file test'); + const newFilePath = 'dad.txt'; const newFileContent = rig.readFile(newFilePath); - assert.notEqual(newFileContent, ''); + + // Add debugging for file content + if (newFileContent === '') { + console.error('File was created but is empty'); + console.error( + 'Tool calls:', + rig.readToolLogs().map((t) => ({ + name: t.toolRequest.name, + args: t.toolRequest.args, + })), + ); + } + + assert.notEqual(newFileContent, '', 'Expected file to have content'); + + // Log success info if verbose + if (process.env.VERBOSE === 'true') { + console.log( + 'File created successfully with content:', + newFileContent.substring(0, 100) + '...', + ); + } }); diff --git a/packages/core/src/utils/bfsFileSearch.test.ts b/packages/core/src/utils/bfsFileSearch.test.ts index 3d5a0010..ce19f80e 100644 --- a/packages/core/src/utils/bfsFileSearch.test.ts +++ b/packages/core/src/utils/bfsFileSearch.test.ts @@ -258,10 +258,12 @@ describe('bfsFileSearch', () => { expect(avgDuration).toBeLessThan(2000); // Very generous limit // Ensure consistency across runs (variance should not be too high) - expect(consistencyRatio).toBeLessThan(1.5); // Max variance should be less than 150% of average + // More tolerant in CI environments where performance can be variable + const maxConsistencyRatio = process.env.CI ? 3.0 : 1.5; + expect(consistencyRatio).toBeLessThan(maxConsistencyRatio); // Max variance should be reasonable console.log( - `✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}%`, + `✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}% (threshold: ${(maxConsistencyRatio * 100).toFixed(0)}%)`, ); }); }); From 15a1f1af9d0e4628e9e82f81d384d614899770e3 Mon Sep 17 00:00:00 2001 From: TIRUMALASETTI PRANITH Date: Sat, 2 Aug 2025 03:52:17 +0530 Subject: [PATCH 073/136] fix(config): Resolve duplicate config loading from home directory (#5090) Co-authored-by: Allen Hutchison Co-authored-by: Allen Hutchison --- packages/cli/src/config/config.ts | 17 ++- packages/cli/src/config/settings.test.ts | 16 ++- packages/cli/src/config/settings.ts | 59 ++++++--- packages/core/src/utils/memoryDiscovery.ts | 135 ++++++++------------- 4 files changed, 117 insertions(+), 110 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a147bca8..9274b65e 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'fs'; +import * as path from 'path'; +import { homedir } from 'node:os'; import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; @@ -244,16 +247,24 @@ export async function loadHierarchicalGeminiMemory( memoryImportFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, ): Promise<{ memoryContent: string; fileCount: number }> { + // FIX: Use real, canonical paths for a reliable comparison to handle symlinks. + const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory)); + const realHome = fs.realpathSync(path.resolve(homedir())); + const isHomeDirectory = realCwd === realHome; + + // If it is the home directory, pass an empty string to the core memory + // function to signal that it should skip the workspace search. + const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory; + if (debugMode) { logger.debug( `CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`, ); } - // Directly call the server function. - // The server function will use its own homedir() for the global path. + // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( - currentWorkingDirectory, + effectiveCwd, debugMode, fileService, extensionContextFilePaths, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 5a54e46e..b8ecbb62 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -59,7 +59,21 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join( 'settings.json', ); -vi.mock('fs'); +vi.mock('fs', async (importOriginal) => { + // Get all the functions from the real 'fs' module + const actualFs = await importOriginal(); + + return { + ...actualFs, // Keep all the real functions + // Now, just override the ones we need for the test + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + realpathSync: (p: string) => p, + }; +}); + vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 4f701c6d..a875ffc1 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -312,6 +312,22 @@ export function loadSettings(workspaceDir: string): LoadedSettings { let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); + + // FIX: Resolve paths to their canonical representation to handle symlinks + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const resolvedHomeDir = path.resolve(homedir()); + + let realWorkspaceDir = resolvedWorkspaceDir; + try { + // fs.realpathSync gets the "true" path, resolving any symlinks + realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); + } catch (_e) { + // This is okay. The path might not exist yet, and that's a valid state. + } + + // We expect homedir to always exist and be resolvable. + const realHomeDir = fs.realpathSync(resolvedHomeDir); + // Load system settings try { if (fs.existsSync(systemSettingsPath)) { @@ -356,28 +372,31 @@ export function loadSettings(workspaceDir: string): LoadedSettings { 'settings.json', ); - // Load workspace settings - try { - if (fs.existsSync(workspaceSettingsPath)) { - const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); - const parsedWorkspaceSettings = JSON.parse( - stripJsonComments(projectContent), - ) as Settings; - workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); - if (workspaceSettings.theme && workspaceSettings.theme === 'VS') { - workspaceSettings.theme = DefaultLight.name; - } else if ( - workspaceSettings.theme && - workspaceSettings.theme === 'VS2015' - ) { - workspaceSettings.theme = DefaultDark.name; + // This comparison is now much more reliable. + if (realWorkspaceDir !== realHomeDir) { + // Load workspace settings + try { + if (fs.existsSync(workspaceSettingsPath)) { + const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); + const parsedWorkspaceSettings = JSON.parse( + stripJsonComments(projectContent), + ) as Settings; + workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); + if (workspaceSettings.theme && workspaceSettings.theme === 'VS') { + workspaceSettings.theme = DefaultLight.name; + } else if ( + workspaceSettings.theme && + workspaceSettings.theme === 'VS2015' + ) { + workspaceSettings.theme = DefaultDark.name; + } } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: workspaceSettingsPath, + }); } - } catch (error: unknown) { - settingsErrors.push({ - message: getErrorMessage(error), - path: workspaceSettingsPath, - }); } return new LoadedSettings( diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index a673a75e..323b13c5 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -94,7 +94,6 @@ async function getGeminiMdFilePathsInternal( const geminiMdFilenames = getAllGeminiMdFilenames(); for (const geminiMdFilename of geminiMdFilenames) { - const resolvedCwd = path.resolve(currentWorkingDirectory); const resolvedHome = path.resolve(userHomePath); const globalMemoryPath = path.join( resolvedHome, @@ -102,12 +101,7 @@ async function getGeminiMdFilePathsInternal( geminiMdFilename, ); - if (debugMode) - logger.debug( - `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, - ); - if (debugMode) logger.debug(`User home directory: ${resolvedHome}`); - + // This part that finds the global file always runs. try { await fs.access(globalMemoryPath, fsSync.constants.R_OK); allPaths.add(globalMemoryPath); @@ -116,102 +110,71 @@ async function getGeminiMdFilePathsInternal( `Found readable global ${geminiMdFilename}: ${globalMemoryPath}`, ); } catch { + // It's okay if it's not found. + } + + // FIX: Only perform the workspace search (upward and downward scans) + // if a valid currentWorkingDirectory is provided. + if (currentWorkingDirectory) { + const resolvedCwd = path.resolve(currentWorkingDirectory); if (debugMode) logger.debug( - `Global ${geminiMdFilename} not found or not readable: ${globalMemoryPath}`, + `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, ); - } - const projectRoot = await findProjectRoot(resolvedCwd); - if (debugMode) - logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); + const projectRoot = await findProjectRoot(resolvedCwd); + if (debugMode) + logger.debug(`Determined project root: ${projectRoot ?? 'None'}`); - const upwardPaths: string[] = []; - let currentDir = resolvedCwd; - // Determine the directory that signifies the top of the project or user-specific space. - const ultimateStopDir = projectRoot - ? path.dirname(projectRoot) - : path.dirname(resolvedHome); + const upwardPaths: string[] = []; + let currentDir = resolvedCwd; + const ultimateStopDir = projectRoot + ? path.dirname(projectRoot) + : path.dirname(resolvedHome); - while (currentDir && currentDir !== path.dirname(currentDir)) { - // Loop until filesystem root or currentDir is empty - if (debugMode) { - logger.debug( - `Checking for ${geminiMdFilename} in (upward scan): ${currentDir}`, - ); - } - - // Skip the global .gemini directory itself during upward scan from CWD, - // as global is handled separately and explicitly first. - if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) { - if (debugMode) { - logger.debug( - `Upward scan reached global config dir path, stopping upward search here: ${currentDir}`, - ); + while (currentDir && currentDir !== path.dirname(currentDir)) { + if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) { + break; } - break; - } - const potentialPath = path.join(currentDir, geminiMdFilename); - try { - await fs.access(potentialPath, fsSync.constants.R_OK); - // Add to upwardPaths only if it's not the already added globalMemoryPath - if (potentialPath !== globalMemoryPath) { - upwardPaths.unshift(potentialPath); - if (debugMode) { - logger.debug( - `Found readable upward ${geminiMdFilename}: ${potentialPath}`, - ); + const potentialPath = path.join(currentDir, geminiMdFilename); + try { + await fs.access(potentialPath, fsSync.constants.R_OK); + if (potentialPath !== globalMemoryPath) { + upwardPaths.unshift(potentialPath); } + } catch { + // Not found, continue. } - } catch { - if (debugMode) { - logger.debug( - `Upward ${geminiMdFilename} not found or not readable in: ${currentDir}`, - ); + + if (currentDir === ultimateStopDir) { + break; } + + currentDir = path.dirname(currentDir); } + upwardPaths.forEach((p) => allPaths.add(p)); - // Stop condition: if currentDir is the ultimateStopDir, break after this iteration. - if (currentDir === ultimateStopDir) { - if (debugMode) - logger.debug( - `Reached ultimate stop directory for upward scan: ${currentDir}`, - ); - break; + const mergedOptions = { + ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, + ...fileFilteringOptions, + }; + + const downwardPaths = await bfsFileSearch(resolvedCwd, { + fileName: geminiMdFilename, + maxDirs, + debug: debugMode, + fileService, + fileFilteringOptions: mergedOptions, + }); + downwardPaths.sort(); + for (const dPath of downwardPaths) { + allPaths.add(dPath); } - - currentDir = path.dirname(currentDir); - } - upwardPaths.forEach((p) => allPaths.add(p)); - - // Merge options with memory defaults, with options taking precedence - const mergedOptions = { - ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, - ...fileFilteringOptions, - }; - - const downwardPaths = await bfsFileSearch(resolvedCwd, { - fileName: geminiMdFilename, - maxDirs, - debug: debugMode, - fileService, - fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter - }); - downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex - if (debugMode && downwardPaths.length > 0) - logger.debug( - `Found downward ${geminiMdFilename} files (sorted): ${JSON.stringify( - downwardPaths, - )}`, - ); - // Add downward paths only if they haven't been included already (e.g. from upward scan) - for (const dPath of downwardPaths) { - allPaths.add(dPath); } } - // Add extension context file paths + // Add extension context file paths. for (const extensionPath of extensionContextFilePaths) { allPaths.add(extensionPath); } From 820169ba2e0777ee2bee240f063649cc4b2b107f Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:17:32 -0700 Subject: [PATCH 074/136] feat(autoupdate): Improve update check and refactor for testability (#5389) --- packages/cli/src/ui/utils/updateCheck.test.ts | 51 ++++-- packages/cli/src/ui/utils/updateCheck.ts | 85 ++++++--- .../cli/src/utils/handleAutoUpdate.test.ts | 171 +++++++++++++++--- packages/cli/src/utils/handleAutoUpdate.ts | 10 +- packages/cli/src/utils/spawnWrapper.ts | 9 + 5 files changed, 258 insertions(+), 68 deletions(-) create mode 100644 packages/cli/src/utils/spawnWrapper.ts diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index fa6f342e..c2b56a03 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -5,7 +5,7 @@ */ import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { checkForUpdates, FETCH_TIMEOUT_MS } from './updateCheck.js'; +import { checkForUpdates } from './updateCheck.js'; const getPackageJson = vi.hoisted(() => vi.fn()); vi.mock('../../utils/package.js', () => ({ @@ -109,24 +109,16 @@ describe('checkForUpdates', () => { expect(result).toBeNull(); }); - it('should return null if fetchInfo times out', async () => { + it('should return null if fetchInfo rejects', async () => { getPackageJson.mockResolvedValue({ name: 'test-package', version: '1.0.0', }); updateNotifier.mockReturnValue({ - fetchInfo: vi.fn( - async () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ current: '1.0.0', latest: '1.1.0' }); - }, FETCH_TIMEOUT_MS + 1); - }), - ), + fetchInfo: vi.fn().mockRejectedValue(new Error('Timeout')), }); - const promise = checkForUpdates(); - await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); - const result = await promise; + + const result = await checkForUpdates(); expect(result).toBeNull(); }); @@ -135,4 +127,37 @@ describe('checkForUpdates', () => { const result = await checkForUpdates(); expect(result).toBeNull(); }); + + describe('nightly updates', () => { + it('should notify for a newer nightly version when current is nightly', async () => { + getPackageJson.mockResolvedValue({ + name: 'test-package', + version: '1.2.3-nightly.1', + }); + + const fetchInfoMock = vi.fn().mockImplementation(({ distTag }) => { + if (distTag === 'nightly') { + return Promise.resolve({ + latest: '1.2.3-nightly.2', + current: '1.2.3-nightly.1', + }); + } + if (distTag === 'latest') { + return Promise.resolve({ + latest: '1.2.3', + current: '1.2.3-nightly.1', + }); + } + return Promise.resolve(null); + }); + + updateNotifier.mockImplementation(({ pkg, distTag }) => ({ + fetchInfo: () => fetchInfoMock({ pkg, distTag }), + })); + + const result = await checkForUpdates(); + expect(result?.message).toContain('1.2.3-nightly.1 → 1.2.3-nightly.2'); + expect(result?.update.latest).toBe('1.2.3-nightly.2'); + }); + }); }); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 2fe5df39..f4c18586 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -15,38 +15,81 @@ export interface UpdateObject { update: UpdateInfo; } +/** + * From a nightly and stable update, determines which is the "best" one to offer. + * The rule is to always prefer nightly if the base versions are the same. + */ +function getBestAvailableUpdate( + nightly?: UpdateInfo, + stable?: UpdateInfo, +): UpdateInfo | null { + if (!nightly) return stable || null; + if (!stable) return nightly || null; + + const nightlyVer = nightly.latest; + const stableVer = stable.latest; + + if ( + semver.coerce(stableVer)?.version === semver.coerce(nightlyVer)?.version + ) { + return nightly; + } + + return semver.gt(stableVer, nightlyVer) ? stable : nightly; +} + export async function checkForUpdates(): Promise { try { // Skip update check when running from source (development mode) if (process.env.DEV === 'true') { return null; } - const packageJson = await getPackageJson(); if (!packageJson || !packageJson.name || !packageJson.version) { return null; } - const notifier = updateNotifier({ - pkg: { - name: packageJson.name, - version: packageJson.version, - }, - // check every time - updateCheckInterval: 0, - // allow notifier to run in scripts - shouldNotifyInNpmScript: true, - }); - // avoid blocking by waiting at most FETCH_TIMEOUT_MS for fetchInfo to resolve - const timeout = new Promise((resolve) => - setTimeout(resolve, FETCH_TIMEOUT_MS, null), - ); - const updateInfo = await Promise.race([notifier.fetchInfo(), timeout]); - if (updateInfo && semver.gt(updateInfo.latest, updateInfo.current)) { - return { - message: `Gemini CLI update available! ${updateInfo.current} → ${updateInfo.latest}`, - update: updateInfo, - }; + const { name, version: currentVersion } = packageJson; + const isNightly = currentVersion.includes('nightly'); + const createNotifier = (distTag: 'latest' | 'nightly') => + updateNotifier({ + pkg: { + name, + version: currentVersion, + }, + updateCheckInterval: 0, + shouldNotifyInNpmScript: true, + distTag, + }); + + if (isNightly) { + const [nightlyUpdateInfo, latestUpdateInfo] = await Promise.all([ + createNotifier('nightly').fetchInfo(), + createNotifier('latest').fetchInfo(), + ]); + + const bestUpdate = getBestAvailableUpdate( + nightlyUpdateInfo, + latestUpdateInfo, + ); + + if (bestUpdate && semver.gt(bestUpdate.latest, currentVersion)) { + const message = `A new version of Gemini CLI is available! ${currentVersion} → ${bestUpdate.latest}`; + return { + message, + update: { ...bestUpdate, current: currentVersion }, + }; + } + } else { + const updateInfo = await createNotifier('latest').fetchInfo(); + + if (updateInfo && semver.gt(updateInfo.latest, currentVersion)) { + const message = `Gemini CLI update available! ${currentVersion} → ${updateInfo.latest}`; + return { + message, + update: { ...updateInfo, current: currentVersion }, + }; + } } return null; diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index adaed932..f292d0c2 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -4,22 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ChildProcess, spawn } from 'node:child_process'; -import { handleAutoUpdate } from './handleAutoUpdate.js'; +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { getInstallationInfo, PackageManager } from './installationInfo.js'; import { updateEventEmitter } from './updateEventEmitter.js'; import { UpdateObject } from '../ui/utils/updateCheck.js'; import { LoadedSettings } from '../config/settings.js'; - -// Mock dependencies -vi.mock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process'); - return { - ...actual, - spawn: vi.fn(), - }; -}); +import EventEmitter from 'node:events'; +import { handleAutoUpdate } from './handleAutoUpdate.js'; vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); @@ -40,21 +31,26 @@ vi.mock('./updateEventEmitter.js', async () => { }; }); -const mockSpawn = vi.mocked(spawn); +interface MockChildProcess extends EventEmitter { + stdin: EventEmitter & { + write: Mock; + end: Mock; + }; + stderr: EventEmitter; +} + const mockGetInstallationInfo = vi.mocked(getInstallationInfo); const mockUpdateEventEmitter = vi.mocked(updateEventEmitter); describe('handleAutoUpdate', () => { + let mockSpawn: Mock; let mockUpdateInfo: UpdateObject; let mockSettings: LoadedSettings; - let mockChildProcess: { - stderr: { on: ReturnType }; - stdout: { on: ReturnType }; - on: ReturnType; - unref: ReturnType; - }; + let mockChildProcess: MockChildProcess; beforeEach(() => { + mockSpawn = vi.fn(); + vi.clearAllMocks(); mockUpdateInfo = { update: { latest: '2.0.0', @@ -71,13 +67,17 @@ describe('handleAutoUpdate', () => { }, } as LoadedSettings; - mockChildProcess = { - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - unref: vi.fn(), - }; - mockSpawn.mockReturnValue(mockChildProcess as unknown as ChildProcess); + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }) as MockChildProcess; + + mockSpawn.mockReturnValue( + mockChildProcess as unknown as ReturnType, + ); }); afterEach(() => { @@ -85,7 +85,7 @@ describe('handleAutoUpdate', () => { }); it('should do nothing if update info is null', () => { - handleAutoUpdate(null, mockSettings, '/root'); + handleAutoUpdate(null, mockSettings, '/root', mockSpawn); expect(mockGetInstallationInfo).not.toHaveBeenCalled(); expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled(); expect(mockSpawn).not.toHaveBeenCalled(); @@ -100,7 +100,7 @@ describe('handleAutoUpdate', () => { packageManager: PackageManager.NPM, }); - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( @@ -120,7 +120,7 @@ describe('handleAutoUpdate', () => { packageManager: PackageManager.NPM, }); - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( @@ -140,7 +140,7 @@ describe('handleAutoUpdate', () => { packageManager: PackageManager.NPM, }); - handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( @@ -150,4 +150,115 @@ describe('handleAutoUpdate', () => { }, ); }); + + it('should attempt to perform an update when conditions are met', async () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate successful execution + setTimeout(() => { + mockChildProcess.emit('close', 0); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + + expect(mockSpawn).toHaveBeenCalledOnce(); + }); + + it('should emit "update-failed" when the update process fails', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate failed execution + setTimeout(() => { + mockChildProcess.stderr.emit('data', 'An error occurred'); + mockChildProcess.emit('close', 1); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + }); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0, stderr: An error occurred)', + }); + }); + + it('should emit "update-failed" when the spawn function throws an error', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate an error event + setTimeout(() => { + mockChildProcess.emit('error', new Error('Spawn error')); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + }); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-failed', { + message: + 'Automatic update failed. Please try updating manually. (error: Spawn error)', + }); + }); + + it('should use the "@nightly" tag for nightly updates', async () => { + mockUpdateInfo.update.latest = '2.0.0-nightly'; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + + expect(mockSpawn).toHaveBeenCalledWith( + 'npm i -g @google/gemini-cli@nightly', + { + shell: true, + stdio: 'pipe', + }, + ); + }); + + it('should emit "update-success" when the update process succeeds', async () => { + await new Promise((resolve) => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + // Simulate successful execution + setTimeout(() => { + mockChildProcess.emit('close', 0); + resolve(); + }, 0); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + }); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith('update-success', { + message: + 'Update successful! The new version will be used on your next run.', + }); + }); }); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 1ef2d475..03a6a8d6 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -4,17 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { spawn } from 'node:child_process'; import { UpdateObject } from '../ui/utils/updateCheck.js'; import { LoadedSettings } from '../config/settings.js'; import { getInstallationInfo } from './installationInfo.js'; import { updateEventEmitter } from './updateEventEmitter.js'; import { HistoryItem, MessageType } from '../ui/types.js'; +import { spawnWrapper } from './spawnWrapper.js'; +import { spawn } from 'child_process'; export function handleAutoUpdate( info: UpdateObject | null, settings: LoadedSettings, projectRoot: string, + spawnFn: typeof spawn = spawnWrapper, ) { if (!info) { return; @@ -37,13 +39,13 @@ export function handleAutoUpdate( if (!installationInfo.updateCommand || settings.merged.disableAutoUpdate) { return; } + const isNightly = info.update.latest.includes('nightly'); const updateCommand = installationInfo.updateCommand.replace( '@latest', - `@${info.update.latest}`, + isNightly ? '@nightly' : `@${info.update.latest}`, ); - - const updateProcess = spawn(updateCommand, { stdio: 'pipe', shell: true }); + const updateProcess = spawnFn(updateCommand, { stdio: 'pipe', shell: true }); let errorOutput = ''; updateProcess.stderr.on('data', (data) => { errorOutput += data.toString(); diff --git a/packages/cli/src/utils/spawnWrapper.ts b/packages/cli/src/utils/spawnWrapper.ts new file mode 100644 index 00000000..3f3cca94 --- /dev/null +++ b/packages/cli/src/utils/spawnWrapper.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'child_process'; + +export const spawnWrapper = spawn; From bedcbb9feb3b4f5fcdd9d64782f3ee1e06376715 Mon Sep 17 00:00:00 2001 From: Billy Biggs Date: Sun, 3 Aug 2025 11:20:55 -0700 Subject: [PATCH 075/136] Add a setting to disable the version update nag message (#5449) --- packages/cli/src/config/settings.ts | 3 +++ packages/cli/src/utils/handleAutoUpdate.test.ts | 8 ++++++++ packages/cli/src/utils/handleAutoUpdate.ts | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index a875ffc1..84f996ba 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -111,6 +111,9 @@ export interface Settings { // Setting for disabling auto-update. disableAutoUpdate?: boolean; + // Setting for disabling the update nag message. + disableUpdateNag?: boolean; + memoryDiscoveryMaxDirs?: number; dnsResolutionOrder?: DnsResolutionOrder; } diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index f292d0c2..c7c3da67 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -91,6 +91,14 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); + it('should do nothing if update nag is disabled', () => { + mockSettings.merged.disableUpdateNag = true; + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + expect(mockGetInstallationInfo).not.toHaveBeenCalled(); + expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('should emit "update-received" but not update if auto-updates are disabled', () => { mockSettings.merged.disableAutoUpdate = true; mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 03a6a8d6..cbcdb2e0 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -22,6 +22,10 @@ export function handleAutoUpdate( return; } + if (settings.merged.disableUpdateNag) { + return; + } + const installationInfo = getInstallationInfo( projectRoot, settings.merged.disableAutoUpdate ?? false, From 03ed37d0dc2b5e2077b53073517abaab3d24d9c2 Mon Sep 17 00:00:00 2001 From: Oleksandr Gotgelf Date: Sun, 3 Aug 2025 20:44:15 +0200 Subject: [PATCH 076/136] fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289) Co-authored-by: Jacob Richman --- CONTRIBUTING.md | 2 + docs/cli/authentication.md | 2 + docs/cli/configuration.md | 14 +- docs/sandbox.md | 2 + docs/troubleshooting.md | 5 + packages/cli/src/config/settings.test.ts | 216 +++++++++++++++++++++++ packages/cli/src/config/settings.ts | 75 ++++++-- 7 files changed, 305 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eaa7bf0d..ff31ef8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run: DEBUG=1 gemini ``` +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. + ### React DevTools To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x. diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index 8e534d0b..d9adcfb1 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -91,6 +91,8 @@ The Gemini CLI requires you to authenticate with Google's AI services. On initia You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools. +**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. Use `.gemini/.env` files for gemini-cli specific variables. + Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order: 1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks: diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 695a7c53..ce9b55bc 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -240,6 +240,14 @@ In addition to a project settings file, a project's `.gemini` directory can cont } ``` +- **`excludedProjectEnvVars`** (array of strings): + - **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. + - **Default:** `["DEBUG", "DEBUG_MODE"]` + - **Example:** + ```json + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + ``` + ### Example `settings.json`: ```json @@ -271,7 +279,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont "run_shell_command": { "tokenBudget": 100 } - } + }, + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] } ``` @@ -293,6 +302,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi 2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory. 3. If still not found, it looks for `~/.env` (in the user's home directory). +**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file. + - **`GEMINI_API_KEY`** (Required): - Your API key for the Gemini API. - **Crucial for operation.** The CLI will not function without it. @@ -332,6 +343,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.gemini/` directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). - **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. + - **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically. - **`NO_COLOR`**: - Set to any value to disable all color output in the CLI. - **`CLI_TITLE`**: diff --git a/docs/sandbox.md b/docs/sandbox.md index 508a0d03..20a1a3b5 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -129,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. + ### Inspect sandbox ```bash diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fa88e26e..8c500445 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -53,6 +53,11 @@ This guide provides solutions to common issues and debugging tips. - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode. - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini` +- **DEBUG mode not working from project .env file** + - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli. + - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. + - **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables. + ## Debugging Tips - **CLI debugging:** diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index b8ecbb62..4099e778 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -334,6 +334,86 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md'); }); + it('should handle excludedProjectEnvVars correctly when only in user settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'], + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'CUSTOM_VAR', + ]); + }); + + it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + ); + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return ''; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const userSettingsContent = { theme: 'dark' }; @@ -1055,4 +1135,140 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.theme).toBe('ocean'); }); }); + + describe('excludedProjectEnvVars integration', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => { + // Create a workspace settings file with excludedProjectEnvVars + const workspaceSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'], + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + // Mock findEnvFile to return a project .env file + const originalFindEnvFile = ( + loadSettings as unknown as { findEnvFile: () => string } + ).findEnvFile; + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + () => '/mock/project/.env'; + + // Mock fs.readFileSync for .env file content + const originalReadFileSync = fs.readFileSync; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === '/mock/project/.env') { + return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; + } + if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + return JSON.stringify(workspaceSettingsContent); + } + return '{}'; + }, + ); + + try { + // This will call loadEnvironment internally with the merged settings + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + // Verify the settings were loaded correctly + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'DEBUG_MODE', + ]); + + // Note: We can't directly test process.env changes here because the mocking + // prevents the actual file system operations, but we can verify the settings + // are correctly merged and passed to loadEnvironment + } finally { + (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = + originalFindEnvFile; + (fs.readFileSync as Mock).mockImplementation(originalReadFileSync); + } + }); + + it('should respect custom excludedProjectEnvVars from user settings', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'], + }; + + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'NODE_ENV', + 'DEBUG', + ]); + }); + + it('should merge excludedProjectEnvVars with workspace taking precedence', () => { + const userSettingsContent = { + excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'], + }; + const workspaceSettingsContent = { + excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'], + }; + + (mockFsExistsSync as Mock).mockReturnValue(true); + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ + 'DEBUG', + 'NODE_ENV', + 'USER_VAR', + ]); + expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + expect(settings.merged.excludedProjectEnvVars).toEqual([ + 'WORKSPACE_DEBUG', + 'WORKSPACE_VAR', + ]); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 84f996ba..05d4313f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js'; export const SETTINGS_DIRECTORY_NAME = '.gemini'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); +export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; export function getSystemSettingsPath(): string { if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) { @@ -38,6 +39,10 @@ export function getSystemSettingsPath(): string { } } +export function getWorkspaceSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json'); +} + export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; export enum SettingScope { @@ -115,6 +120,9 @@ export interface Settings { disableUpdateNag?: boolean; memoryDiscoveryMaxDirs?: number; + + // Environment variables to exclude from project .env files + excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; } @@ -292,15 +300,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void { } } -export function loadEnvironment(): void { +export function loadEnvironment(settings?: Settings): void { const envFilePath = findEnvFile(process.cwd()); + // Cloud Shell environment variable handling if (process.env.CLOUD_SHELL === 'true') { setUpCloudShellEnvironment(envFilePath); } + // If no settings provided, try to load workspace settings for exclusions + let resolvedSettings = settings; + if (!resolvedSettings) { + const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd()); + try { + if (fs.existsSync(workspaceSettingsPath)) { + const workspaceContent = fs.readFileSync( + workspaceSettingsPath, + 'utf-8', + ); + const parsedWorkspaceSettings = JSON.parse( + stripJsonComments(workspaceContent), + ) as Settings; + resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); + } + } catch (_e) { + // Ignore errors loading workspace settings + } + } + if (envFilePath) { - dotenv.config({ path: envFilePath, quiet: true }); + // Manually parse and load environment variables to handle exclusions correctly. + // This avoids modifying environment variables that were already set from the shell. + try { + const envFileContent = fs.readFileSync(envFilePath, 'utf-8'); + const parsedEnv = dotenv.parse(envFileContent); + + const excludedVars = + resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS; + const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); + + for (const key in parsedEnv) { + if (Object.hasOwn(parsedEnv, key)) { + // If it's a project .env file, skip loading excluded variables. + if (isProjectEnvFile && excludedVars.includes(key)) { + continue; + } + + // Load variable only if it's not already set in the environment. + if (!Object.hasOwn(process.env, key)) { + process.env[key] = parsedEnv[key]; + } + } + } + } catch (_e) { + // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. + } } } @@ -309,7 +363,6 @@ export function loadEnvironment(): void { * Project settings override user settings. */ export function loadSettings(workspaceDir: string): LoadedSettings { - loadEnvironment(); let systemSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; @@ -331,6 +384,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); + const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir); + // Load system settings try { if (fs.existsSync(systemSettingsPath)) { @@ -369,12 +424,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }); } - const workspaceSettingsPath = path.join( - workspaceDir, - SETTINGS_DIRECTORY_NAME, - 'settings.json', - ); - // This comparison is now much more reliable. if (realWorkspaceDir !== realHomeDir) { // Load workspace settings @@ -402,7 +451,8 @@ export function loadSettings(workspaceDir: string): LoadedSettings { } } - return new LoadedSettings( + // Create LoadedSettings first + const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, @@ -417,6 +467,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }, settingsErrors, ); + + // Load environment with merged settings + loadEnvironment(loadedSettings.merged); + + return loadedSettings; } export function saveSettings(settingsFile: SettingsFile): void { From 072d8ba2899f2601dad6d4b0333fdcb80555a7dd Mon Sep 17 00:00:00 2001 From: Ayesha Shafique <79274585+Aisha630@users.noreply.github.com> Date: Mon, 4 Aug 2025 00:53:24 +0500 Subject: [PATCH 077/136] feat: Add reverse search capability for shell commands (#4793) --- .../src/ui/components/InputPrompt.test.tsx | 282 +++++--- .../cli/src/ui/components/InputPrompt.tsx | 126 +++- .../cli/src/ui/components/PrepareLabel.tsx | 48 ++ .../src/ui/components/SuggestionsDisplay.tsx | 17 +- packages/cli/src/ui/hooks/useCompletion.ts | 604 +--------------- .../hooks/useReverseSearchCompletion.test.tsx | 260 +++++++ .../ui/hooks/useReverseSearchCompletion.tsx | 91 +++ packages/cli/src/ui/hooks/useShellHistory.ts | 37 +- ...ion.test.ts => useSlashCompletion.test.ts} | 123 ++-- .../cli/src/ui/hooks/useSlashCompletion.tsx | 654 ++++++++++++++++++ 10 files changed, 1505 insertions(+), 737 deletions(-) create mode 100644 packages/cli/src/ui/components/PrepareLabel.tsx create mode 100644 packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx create mode 100644 packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx rename packages/cli/src/ui/hooks/{useCompletion.test.ts => useSlashCompletion.test.ts} (95%) create mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.tsx diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index e0d967da..6b7bc7ce 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -19,7 +19,10 @@ import { useShellHistory, UseShellHistoryReturn, } from '../hooks/useShellHistory.js'; -import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js'; +import { + useSlashCompletion, + UseSlashCompletionReturn, +} from '../hooks/useSlashCompletion.js'; import { useInputHistory, UseInputHistoryReturn, @@ -28,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; vi.mock('../hooks/useShellHistory.js'); -vi.mock('../hooks/useCompletion.js'); +vi.mock('../hooks/useSlashCompletion.js'); vi.mock('../hooks/useInputHistory.js'); vi.mock('../utils/clipboardUtils.js'); @@ -83,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [ describe('InputPrompt', () => { let props: InputPromptProps; let mockShellHistory: UseShellHistoryReturn; - let mockCompletion: UseCompletionReturn; + let mockSlashCompletion: UseSlashCompletionReturn; let mockInputHistory: UseInputHistoryReturn; let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; const mockedUseShellHistory = vi.mocked(useShellHistory); - const mockedUseCompletion = vi.mocked(useCompletion); + const mockedUseSlashCompletion = vi.mocked(useSlashCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); beforeEach(() => { @@ -115,7 +118,9 @@ describe('InputPrompt', () => { visualScrollRow: 0, handleInput: vi.fn(), move: vi.fn(), - moveToOffset: vi.fn(), + moveToOffset: (offset: number) => { + mockBuffer.cursor = [0, offset]; + }, killLineRight: vi.fn(), killLineLeft: vi.fn(), openInExternalEditor: vi.fn(), @@ -133,6 +138,7 @@ describe('InputPrompt', () => { } as unknown as TextBuffer; mockShellHistory = { + history: [], addCommandToHistory: vi.fn(), getPreviousCommand: vi.fn().mockReturnValue(null), getNextCommand: vi.fn().mockReturnValue(null), @@ -140,7 +146,7 @@ describe('InputPrompt', () => { }; mockedUseShellHistory.mockReturnValue(mockShellHistory); - mockCompletion = { + mockSlashCompletion = { suggestions: [], activeSuggestionIndex: -1, isLoadingSuggestions: false, @@ -154,7 +160,7 @@ describe('InputPrompt', () => { setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), }; - mockedUseCompletion.mockReturnValue(mockCompletion); + mockedUseSlashCompletion.mockReturnValue(mockSlashCompletion); mockInputHistory = { navigateUp: vi.fn(), @@ -265,8 +271,8 @@ describe('InputPrompt', () => { }); it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -285,15 +291,15 @@ describe('InputPrompt', () => { stdin.write('\u0010'); // Ctrl+P await wait(); - expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2); - expect(mockCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockSlashCompletion.navigateUp).toHaveBeenCalledTimes(2); + expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -311,15 +317,15 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2); - expect(mockCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockSlashCompletion.navigateDown).toHaveBeenCalledTimes(2); + expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); unmount(); }); it('should NOT call completion navigation when suggestions are not showing', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, }); props.buffer.setText('some text'); @@ -336,8 +342,8 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockCompletion.navigateUp).not.toHaveBeenCalled(); - expect(mockCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); @@ -466,8 +472,8 @@ describe('InputPrompt', () => { it('should complete a partial parent command', async () => { // SCENARIO: /mem -> Tab - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory', description: '...' }], activeSuggestionIndex: 0, @@ -480,14 +486,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should append a sub-command when the parent command is already complete', async () => { // SCENARIO: /memory -> Tab (to accept 'add') - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -503,14 +509,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(1); unmount(); }); it('should handle the "backspace" edge case correctly', async () => { // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show') - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -528,14 +534,14 @@ describe('InputPrompt', () => { await wait(); // It should NOT become '/show'. It should correctly become '/memory show'. - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should complete a partial argument for a command', async () => { // SCENARIO: /chat resume fi- -> Tab - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'fix-foo', value: 'fix-foo' }], activeSuggestionIndex: 0, @@ -548,13 +554,13 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should autocomplete on Enter when suggestions are active, without submitting', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory' }], activeSuggestionIndex: 0, @@ -568,7 +574,7 @@ describe('InputPrompt', () => { await wait(); // The app should autocomplete the text, NOT submit. - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); @@ -584,8 +590,8 @@ describe('InputPrompt', () => { }, ]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'help', value: 'help' }], activeSuggestionIndex: 0, @@ -598,7 +604,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab for autocomplete await wait(); - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -616,8 +622,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when isPerfectMatch is true', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, isPerfectMatch: true, }); @@ -634,8 +640,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when a complete leaf command is typed', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, isPerfectMatch: false, // Added explicit isPerfectMatch false }); @@ -652,8 +658,8 @@ describe('InputPrompt', () => { }); it('should autocomplete an @-path on Enter without submitting', async () => { - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'index.ts', value: 'index.ts' }], activeSuggestionIndex: 0, @@ -666,7 +672,7 @@ describe('InputPrompt', () => { stdin.write('\r'); await wait(); - expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -698,7 +704,7 @@ describe('InputPrompt', () => { await wait(); expect(props.buffer.setText).toHaveBeenCalledWith(''); - expect(mockCompletion.resetCompletionState).toHaveBeenCalled(); + expect(mockSlashCompletion.resetCompletionState).toHaveBeenCalled(); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -722,8 +728,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/components']; mockBuffer.cursor = [0, 15]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], }); @@ -732,12 +738,13 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with correct signature - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -749,8 +756,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -758,12 +765,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -775,8 +783,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file.ts hello']; mockBuffer.cursor = [0, 18]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -784,12 +792,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -801,8 +810,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory add']; mockBuffer.cursor = [0, 11]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -810,12 +819,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -827,8 +837,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['hello world']; mockBuffer.cursor = [0, 5]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -836,12 +846,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -853,8 +864,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['first line', '/memory']; mockBuffer.cursor = [1, 7]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -863,12 +874,13 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with the buffer - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -880,8 +892,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -889,12 +901,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -907,8 +920,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file👍.txt']; mockBuffer.cursor = [0, 14]; // After the emoji character - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }], }); @@ -916,12 +929,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -934,8 +948,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file👍.txt hello']; mockBuffer.cursor = [0, 20]; // After the space - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -943,12 +957,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -961,8 +976,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/my\\ file.txt']; mockBuffer.cursor = [0, 16]; // After the escaped space and filename - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], }); @@ -970,12 +985,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -988,8 +1004,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@path/my\\ file.txt hello']; mockBuffer.cursor = [0, 24]; // After "hello" - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: false, suggestions: [], }); @@ -997,12 +1013,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -1015,8 +1032,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md']; mockBuffer.cursor = [0, 29]; // At the end - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'my long file name.md', value: 'my long file name.md' }, @@ -1026,12 +1043,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -1044,8 +1062,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory\\ test']; mockBuffer.cursor = [0, 13]; // At the end - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [{ label: 'test-command', value: 'test-command' }], }); @@ -1053,12 +1071,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -1071,8 +1090,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')]; mockBuffer.cursor = [0, 25]; // After the escaped space and emoji - mockedUseCompletion.mockReturnValue({ - ...mockCompletion, + mockedUseSlashCompletion.mockReturnValue({ + ...mockSlashCompletion, showSuggestions: true, suggestions: [ { label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' }, @@ -1082,12 +1101,13 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseCompletion).toHaveBeenCalledWith( + expect(mockedUseSlashCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), mockSlashCommands, mockCommandContext, + false, expect.any(Object), ); @@ -1169,4 +1189,92 @@ describe('InputPrompt', () => { unmount(); }); }); + + describe('reverse search', () => { + beforeEach(async () => { + props.shellModeActive = true; + + vi.mocked(useShellHistory).mockReturnValue({ + history: ['echo hello', 'echo world', 'ls'], + getPreviousCommand: vi.fn(), + getNextCommand: vi.fn(), + addCommandToHistory: vi.fn(), + resetHistoryPosition: vi.fn(), + }); + }); + + it('invokes reverse search on Ctrl+R', async () => { + const { stdin, stdout, unmount } = render(); + await wait(); + + stdin.write('\x12'); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain('(r:)'); + expect(frame).toContain('echo hello'); + expect(frame).toContain('echo world'); + expect(frame).toContain('ls'); + + unmount(); + }); + + it('resets reverse search state on Escape', async () => { + const { stdin, stdout, unmount } = render(); + await wait(); + + stdin.write('\x12'); + await wait(); + stdin.write('\x1B'); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).not.toContain('(r:)'); + expect(frame).not.toContain('echo hello'); + + unmount(); + }); + + it('completes the highlighted entry on Tab and exits reverse-search', async () => { + const { stdin, stdout, unmount } = render(); + stdin.write('\x12'); + await wait(); + stdin.write('\t'); + await wait(); + + expect(stdout.lastFrame()).not.toContain('(r:)'); + expect(props.buffer.setText).toHaveBeenCalledWith('echo hello'); + unmount(); + }); + + it('submits the highlighted entry on Enter and exits reverse-search', async () => { + const { stdin, stdout, unmount } = render(); + stdin.write('\x12'); + await wait(); + expect(stdout.lastFrame()).toContain('(r:)'); + stdin.write('\r'); + await wait(); + + expect(stdout.lastFrame()).not.toContain('(r:)'); + expect(props.onSubmit).toHaveBeenCalledWith('echo hello'); + unmount(); + }); + + it('text and cursor position should be restored after reverse search', async () => { + props.buffer.setText('initial text'); + props.buffer.cursor = [0, 3]; + const { stdin, stdout, unmount } = render(); + stdin.write('\x12'); + await wait(); + expect(stdout.lastFrame()).toContain('(r:)'); + stdin.write('\x1B'); + await wait(); + + expect(stdout.lastFrame()).not.toContain('(r:)'); + expect(props.buffer.text).toBe('initial text'); + expect(props.buffer.cursor).toEqual([0, 3]); + + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5a7b6353..db4eec1b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -9,12 +9,13 @@ import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; -import { TextBuffer } from './shared/text-buffer.js'; +import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js'; import { cpSlice, cpLen } from '../utils/textUtils.js'; import chalk from 'chalk'; import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; -import { useCompletion } from '../hooks/useCompletion.js'; +import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; +import { useSlashCompletion } from '../hooks/useSlashCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; @@ -69,18 +70,32 @@ export const InputPrompt: React.FC = ({ setDirs(dirsChanged); } }, [dirs.length, dirsChanged]); + const [reverseSearchActive, setReverseSearchActive] = useState(false); + const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState(''); + const [cursorPosition, setCursorPosition] = useState<[number, number]>([ + 0, 0, + ]); + const shellHistory = useShellHistory(config.getProjectRoot()); + const historyData = shellHistory.history; - const completion = useCompletion( + const completion = useSlashCompletion( buffer, dirs, config.getTargetDir(), slashCommands, commandContext, + reverseSearchActive, config, ); + const reverseSearchCompletion = useReverseSearchCompletion( + buffer, + historyData, + reverseSearchActive, + ); const resetCompletionState = completion.resetCompletionState; - const shellHistory = useShellHistory(config.getProjectRoot()); + const resetReverseSearchCompletionState = + reverseSearchCompletion.resetCompletionState; const handleSubmitAndClear = useCallback( (submittedValue: string) => { @@ -92,8 +107,16 @@ export const InputPrompt: React.FC = ({ buffer.setText(''); onSubmit(submittedValue); resetCompletionState(); + resetReverseSearchCompletionState(); }, - [onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory], + [ + onSubmit, + buffer, + resetCompletionState, + shellModeActive, + shellHistory, + resetReverseSearchCompletionState, + ], ); const customSetTextAndResetCompletionSignal = useCallback( @@ -118,6 +141,7 @@ export const InputPrompt: React.FC = ({ useEffect(() => { if (justNavigatedHistory) { resetCompletionState(); + resetReverseSearchCompletionState(); setJustNavigatedHistory(false); } }, [ @@ -125,6 +149,7 @@ export const InputPrompt: React.FC = ({ buffer.text, resetCompletionState, setJustNavigatedHistory, + resetReverseSearchCompletionState, ]); // Handle clipboard image pasting with Ctrl+V @@ -197,6 +222,19 @@ export const InputPrompt: React.FC = ({ } if (key.name === 'escape') { + if (reverseSearchActive) { + setReverseSearchActive(false); + reverseSearchCompletion.resetCompletionState(); + buffer.setText(textBeforeReverseSearch); + const offset = logicalPosToOffset( + buffer.lines, + cursorPosition[0], + cursorPosition[1], + ); + buffer.moveToOffset(offset); + return; + } + if (shellModeActive) { setShellModeActive(false); return; @@ -208,11 +246,61 @@ export const InputPrompt: React.FC = ({ } } + if (shellModeActive && key.ctrl && key.name === 'r') { + setReverseSearchActive(true); + setTextBeforeReverseSearch(buffer.text); + setCursorPosition(buffer.cursor); + return; + } + if (key.ctrl && key.name === 'l') { onClearScreen(); return; } + if (reverseSearchActive) { + const { + activeSuggestionIndex, + navigateUp, + navigateDown, + showSuggestions, + suggestions, + } = reverseSearchCompletion; + + if (showSuggestions) { + if (key.name === 'up') { + navigateUp(); + return; + } + if (key.name === 'down') { + navigateDown(); + return; + } + if (key.name === 'tab') { + reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex); + reverseSearchCompletion.resetCompletionState(); + setReverseSearchActive(false); + return; + } + } + + if (key.name === 'return' && !key.ctrl) { + const textToSubmit = + showSuggestions && activeSuggestionIndex > -1 + ? suggestions[activeSuggestionIndex].value + : buffer.text; + handleSubmitAndClear(textToSubmit); + reverseSearchCompletion.resetCompletionState(); + setReverseSearchActive(false); + return; + } + + // Prevent up/down from falling through to regular history navigation + if (key.name === 'up' || key.name === 'down') { + return; + } + } + // If the command is a perfect match, pressing enter should execute it. if (completion.isPerfectMatch && key.name === 'return') { handleSubmitAndClear(buffer.text); @@ -272,7 +360,6 @@ export const InputPrompt: React.FC = ({ return; } } else { - // Shell History Navigation if (key.name === 'up') { const prevCommand = shellHistory.getPreviousCommand(); if (prevCommand !== null) buffer.setText(prevCommand); @@ -284,7 +371,6 @@ export const InputPrompt: React.FC = ({ return; } } - if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) { if (buffer.text.trim()) { const [row, col] = buffer.cursor; @@ -362,9 +448,13 @@ export const InputPrompt: React.FC = ({ inputHistory, handleSubmitAndClear, shellHistory, + reverseSearchCompletion, handleClipboardImage, resetCompletionState, vimHandleInput, + reverseSearchActive, + textBeforeReverseSearch, + cursorPosition, ], ); @@ -385,7 +475,15 @@ export const InputPrompt: React.FC = ({ - {shellModeActive ? '! ' : '> '} + {shellModeActive ? ( + reverseSearchActive ? ( + (r:) + ) : ( + '! ' + ) + ) : ( + '> ' + )} {buffer.text.length === 0 && placeholder ? ( @@ -449,6 +547,18 @@ export const InputPrompt: React.FC = ({ /> )} + {reverseSearchActive && ( + + + + )} ); }; diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx new file mode 100644 index 00000000..652a77a6 --- /dev/null +++ b/packages/cli/src/ui/components/PrepareLabel.tsx @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { Colors } from '../colors.js'; + +interface PrepareLabelProps { + label: string; + matchedIndex?: number; + userInput: string; + textColor: string; + highlightColor?: string; +} + +export const PrepareLabel: React.FC = ({ + label, + matchedIndex, + userInput, + textColor, + highlightColor = Colors.AccentYellow, +}) => { + if ( + matchedIndex === undefined || + matchedIndex < 0 || + matchedIndex >= label.length || + userInput.length === 0 + ) { + return {label}; + } + + const start = label.slice(0, matchedIndex); + const match = label.slice(matchedIndex, matchedIndex + userInput.length); + const end = label.slice(matchedIndex + userInput.length); + + return ( + + {start} + + {match} + + {end} + + ); +}; diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 0620665f..9c4b5687 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -6,10 +6,12 @@ import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; +import { PrepareLabel } from './PrepareLabel.js'; export interface Suggestion { label: string; value: string; description?: string; + matchedIndex?: number; } interface SuggestionsDisplayProps { suggestions: Suggestion[]; @@ -58,18 +60,25 @@ export function SuggestionsDisplay({ const originalIndex = startIndex + index; const isActive = originalIndex === activeIndex; const textColor = isActive ? Colors.AccentPurple : Colors.Gray; + const labelElement = ( + + ); return ( - + {userInput.startsWith('/') ? ( // only use box model for (/) command mode - {suggestion.label} + {labelElement} ) : ( - // use regular text for other modes (@ context) - {suggestion.label} + labelElement )} {suggestion.description ? ( diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 7790f835..242b4528 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -4,30 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { glob } from 'glob'; -import { - isNodeError, - escapePath, - unescapePath, - getErrorMessage, - Config, - FileDiscoveryService, - DEFAULT_FILE_FILTERING_OPTIONS, -} from '@google/gemini-cli-core'; +import { useState, useCallback } from 'react'; + import { MAX_SUGGESTIONS_TO_SHOW, Suggestion, } from '../components/SuggestionsDisplay.js'; -import { CommandContext, SlashCommand } from '../commands/types.js'; -import { - logicalPosToOffset, - TextBuffer, -} from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; -import { toCodePoints } from '../utils/textUtils.js'; export interface UseCompletionReturn { suggestions: Suggestion[]; @@ -36,22 +18,18 @@ export interface UseCompletionReturn { showSuggestions: boolean; isLoadingSuggestions: boolean; isPerfectMatch: boolean; + setSuggestions: React.Dispatch>; setActiveSuggestionIndex: React.Dispatch>; + setVisibleStartIndex: React.Dispatch>; + setIsLoadingSuggestions: React.Dispatch>; + setIsPerfectMatch: React.Dispatch>; setShowSuggestions: React.Dispatch>; resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; - handleAutocomplete: (indexToUse: number) => void; } -export function useCompletion( - buffer: TextBuffer, - dirs: readonly string[], - cwd: string, - slashCommands: readonly SlashCommand[], - commandContext: CommandContext, - config?: Config, -): UseCompletionReturn { +export function useCompletion(): UseCompletionReturn { const [suggestions, setSuggestions] = useState([]); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); @@ -60,11 +38,6 @@ export function useCompletion( const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isPerfectMatch, setIsPerfectMatch] = useState(false); - const completionStart = useRef(-1); - const completionEnd = useRef(-1); - - const cursorRow = buffer.cursor[0]; - const cursorCol = buffer.cursor[1]; const resetCompletionState = useCallback(() => { setSuggestions([]); @@ -133,560 +106,6 @@ export function useCompletion( return newActiveIndex; }); }, [suggestions.length]); - - // Check if cursor is after @ or / without unescaped spaces - const commandIndex = useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { - return currentLine.indexOf('/'); - } - - // For other completions like '@', we search backwards from the cursor. - - const codePoints = toCodePoints(currentLine); - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; - - if (char === ' ') { - // Check for unescaped spaces. - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - return -1; // Inactive on unescaped space. - } - } else if (char === '@') { - // Active if we find an '@' before any unescaped space. - return i; - } - } - - return -1; - }, [cursorRow, cursorCol, buffer.lines]); - - useEffect(() => { - if (commandIndex === -1) { - resetCompletionState(); - return; - } - - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); - - if (codePoints[commandIndex] === '/') { - // Always reset perfect match at the beginning of processing. - setIsPerfectMatch(false); - - const fullPath = currentLine.substring(commandIndex + 1); - const hasTrailingSpace = currentLine.endsWith(' '); - - // Get all non-empty parts of the command. - const rawParts = fullPath.split(/\s+/).filter((p) => p); - - let commandPathParts = rawParts; - let partial = ''; - - // If there's no trailing space, the last part is potentially a partial segment. - // We tentatively separate it. - if (!hasTrailingSpace && rawParts.length > 0) { - partial = rawParts[rawParts.length - 1]; - commandPathParts = rawParts.slice(0, -1); - } - - // Traverse the Command Tree using the tentative completed path - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - let leafCommand: SlashCommand | null = null; - - for (const part of commandPathParts) { - if (!currentLevel) { - leafCommand = null; - currentLevel = []; - break; - } - const found: SlashCommand | undefined = currentLevel.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - if (found) { - leafCommand = found; - currentLevel = found.subCommands as - | readonly SlashCommand[] - | undefined; - } else { - leafCommand = null; - currentLevel = []; - break; - } - } - - let exactMatchAsParent: SlashCommand | undefined; - // Handle the Ambiguous Case - if (!hasTrailingSpace && currentLevel) { - exactMatchAsParent = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.subCommands, - ); - - if (exactMatchAsParent) { - // It's a perfect match for a parent command. Override our initial guess. - // Treat it as a completed command path. - leafCommand = exactMatchAsParent; - currentLevel = exactMatchAsParent.subCommands; - partial = ''; // We now want to suggest ALL of its sub-commands. - } - } - - // Check for perfect, executable match - if (!hasTrailingSpace) { - if (leafCommand && partial === '' && leafCommand.action) { - // Case: /command - command has action, no sub-commands were suggested - setIsPerfectMatch(true); - } else if (currentLevel) { - // Case: /command subcommand - const perfectMatch = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.action, - ); - if (perfectMatch) { - setIsPerfectMatch(true); - } - } - } - - const depth = commandPathParts.length; - const isArgumentCompletion = - leafCommand?.completion && - (hasTrailingSpace || - (rawParts.length > depth && depth > 0 && partial !== '')); - - // Set completion range - if (hasTrailingSpace || exactMatchAsParent) { - completionStart.current = currentLine.length; - completionEnd.current = currentLine.length; - } else if (partial) { - if (isArgumentCompletion) { - const commandSoFar = `/${commandPathParts.join(' ')}`; - const argStartIndex = - commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); - completionStart.current = argStartIndex; - } else { - completionStart.current = currentLine.length - partial.length; - } - completionEnd.current = currentLine.length; - } else { - // e.g. / - completionStart.current = commandIndex + 1; - completionEnd.current = currentLine.length; - } - - // Provide Suggestions based on the now-corrected context - if (isArgumentCompletion) { - const fetchAndSetSuggestions = async () => { - setIsLoadingSuggestions(true); - const argString = rawParts.slice(depth).join(' '); - const results = - (await leafCommand!.completion!(commandContext, argString)) || []; - const finalSuggestions = results.map((s) => ({ label: s, value: s })); - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - }; - fetchAndSetSuggestions(); - return; - } - - // Command/Sub-command Completion - const commandsToSearch = currentLevel || []; - if (commandsToSearch.length > 0) { - let potentialSuggestions = commandsToSearch.filter( - (cmd) => - cmd.description && - (cmd.name.startsWith(partial) || - cmd.altNames?.some((alt) => alt.startsWith(partial))), - ); - - // If a user's input is an exact match and it is a leaf command, - // enter should submit immediately. - if (potentialSuggestions.length > 0 && !hasTrailingSpace) { - const perfectMatch = potentialSuggestions.find( - (s) => s.name === partial || s.altNames?.includes(partial), - ); - if (perfectMatch && perfectMatch.action) { - potentialSuggestions = []; - } - } - - const finalSuggestions = potentialSuggestions.map((cmd) => ({ - label: cmd.name, - value: cmd.name, - description: cmd.description, - })); - - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - return; - } - - // If we fall through, no suggestions are available. - resetCompletionState(); - return; - } - - // Handle At Command Completion - completionEnd.current = codePoints.length; - for (let i = cursorCol; i < codePoints.length; i++) { - if (codePoints[i] === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - - if (backslashCount % 2 === 0) { - completionEnd.current = i; - break; - } - } - } - - const pathStart = commandIndex + 1; - const partialPath = currentLine.substring(pathStart, completionEnd.current); - const lastSlashIndex = partialPath.lastIndexOf('/'); - completionStart.current = - lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; - const baseDirRelative = - lastSlashIndex === -1 - ? '.' - : partialPath.substring(0, lastSlashIndex + 1); - const prefix = unescapePath( - lastSlashIndex === -1 - ? partialPath - : partialPath.substring(lastSlashIndex + 1), - ); - - let isMounted = true; - - const findFilesRecursively = async ( - startDir: string, - searchPrefix: string, - fileDiscovery: FileDiscoveryService | null, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - currentRelativePath = '', - depth = 0, - maxDepth = 10, // Limit recursion depth - maxResults = 50, // Limit number of results - ): Promise => { - if (depth > maxDepth) { - return []; - } - - const lowerSearchPrefix = searchPrefix.toLowerCase(); - let foundSuggestions: Suggestion[] = []; - try { - const entries = await fs.readdir(startDir, { withFileTypes: true }); - for (const entry of entries) { - if (foundSuggestions.length >= maxResults) break; - - const entryPathRelative = path.join(currentRelativePath, entry.name); - const entryPathFromRoot = path.relative( - startDir, - path.join(startDir, entry.name), - ); - - // Conditionally ignore dotfiles - if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - - // Check if this entry should be ignored by filtering options - if ( - fileDiscovery && - fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions) - ) { - continue; - } - - if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { - foundSuggestions.push({ - label: entryPathRelative + (entry.isDirectory() ? '/' : ''), - value: escapePath( - entryPathRelative + (entry.isDirectory() ? '/' : ''), - ), - }); - } - if ( - entry.isDirectory() && - entry.name !== 'node_modules' && - !entry.name.startsWith('.') - ) { - if (foundSuggestions.length < maxResults) { - foundSuggestions = foundSuggestions.concat( - await findFilesRecursively( - path.join(startDir, entry.name), - searchPrefix, // Pass original searchPrefix for recursive calls - fileDiscovery, - filterOptions, - entryPathRelative, - depth + 1, - maxDepth, - maxResults - foundSuggestions.length, - ), - ); - } - } - } - } catch (_err) { - // Ignore errors like permission denied or ENOENT during recursive search - } - return foundSuggestions.slice(0, maxResults); - }; - - const findFilesWithGlob = async ( - searchPrefix: string, - fileDiscoveryService: FileDiscoveryService, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - searchDir: string, - maxResults = 50, - ): Promise => { - const globPattern = `**/${searchPrefix}*`; - const files = await glob(globPattern, { - cwd: searchDir, - dot: searchPrefix.startsWith('.'), - nocase: true, - }); - - const suggestions: Suggestion[] = files - .filter((file) => { - if (fileDiscoveryService) { - return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); - } - return true; - }) - .map((file: string) => { - const absolutePath = path.resolve(searchDir, file); - const label = path.relative(cwd, absolutePath); - return { - label, - value: escapePath(label), - }; - }) - .slice(0, maxResults); - - return suggestions; - }; - - const fetchSuggestions = async () => { - setIsLoadingSuggestions(true); - let fetchedSuggestions: Suggestion[] = []; - - const fileDiscoveryService = config ? config.getFileService() : null; - const enableRecursiveSearch = - config?.getEnableRecursiveFileSearch() ?? true; - const filterOptions = - config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; - - try { - // If there's no slash, or it's the root, do a recursive search from workspace directories - for (const dir of dirs) { - let fetchedSuggestionsPerDir: Suggestion[] = []; - if ( - partialPath.indexOf('/') === -1 && - prefix && - enableRecursiveSearch - ) { - if (fileDiscoveryService) { - fetchedSuggestionsPerDir = await findFilesWithGlob( - prefix, - fileDiscoveryService, - filterOptions, - dir, - ); - } else { - fetchedSuggestionsPerDir = await findFilesRecursively( - dir, - prefix, - null, - filterOptions, - ); - } - } else { - // Original behavior: list files in the specific directory - const lowerPrefix = prefix.toLowerCase(); - const baseDirAbsolute = path.resolve(dir, baseDirRelative); - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, - }); - - // Filter entries using git-aware filtering - const filteredEntries = []; - for (const entry of entries) { - // Conditionally ignore dotfiles - if (!prefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - - const relativePath = path.relative( - dir, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile( - relativePath, - filterOptions, - ) - ) { - continue; - } - - filteredEntries.push(entry); - } - - fetchedSuggestionsPerDir = filteredEntries.map((entry) => { - const absolutePath = path.resolve(baseDirAbsolute, entry.name); - const label = - cwd === dir ? entry.name : path.relative(cwd, absolutePath); - const suggestionLabel = entry.isDirectory() ? label + '/' : label; - return { - label: suggestionLabel, - value: escapePath(suggestionLabel), - }; - }); - } - fetchedSuggestions = [ - ...fetchedSuggestions, - ...fetchedSuggestionsPerDir, - ]; - } - - // Like glob, we always return forwardslashes, even in windows. - fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ - ...suggestion, - label: suggestion.label.replace(/\\/g, '/'), - value: suggestion.value.replace(/\\/g, '/'), - })); - - // Sort by depth, then directories first, then alphabetically - fetchedSuggestions.sort((a, b) => { - const depthA = (a.label.match(/\//g) || []).length; - const depthB = (b.label.match(/\//g) || []).length; - - if (depthA !== depthB) { - return depthA - depthB; - } - - const aIsDir = a.label.endsWith('/'); - const bIsDir = b.label.endsWith('/'); - if (aIsDir && !bIsDir) return -1; - if (!aIsDir && bIsDir) return 1; - - // exclude extension when comparing - const filenameA = a.label.substring( - 0, - a.label.length - path.extname(a.label).length, - ); - const filenameB = b.label.substring( - 0, - b.label.length - path.extname(b.label).length, - ); - - return ( - filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label) - ); - }); - - if (isMounted) { - setSuggestions(fetchedSuggestions); - setShowSuggestions(fetchedSuggestions.length > 0); - setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); - setVisibleStartIndex(0); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (isMounted) { - setSuggestions([]); - setShowSuggestions(false); - } - } else { - console.error( - `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, - ); - if (isMounted) { - resetCompletionState(); - } - } - } - if (isMounted) { - setIsLoadingSuggestions(false); - } - }; - - const debounceTimeout = setTimeout(fetchSuggestions, 100); - - return () => { - isMounted = false; - clearTimeout(debounceTimeout); - }; - }, [ - buffer.text, - cursorRow, - cursorCol, - buffer.lines, - dirs, - cwd, - commandIndex, - resetCompletionState, - slashCommands, - commandContext, - config, - ]); - - const handleAutocomplete = useCallback( - (indexToUse: number) => { - if (indexToUse < 0 || indexToUse >= suggestions.length) { - return; - } - const suggestion = suggestions[indexToUse].value; - - if (completionStart.current === -1 || completionEnd.current === -1) { - return; - } - - const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; - let suggestionText = suggestion; - if (isSlash) { - // If we are inserting (not replacing), and the preceding character is not a space, add one. - if ( - completionStart.current === completionEnd.current && - completionStart.current > commandIndex + 1 && - (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' - ) { - suggestionText = ' ' + suggestionText; - } - suggestionText += ' '; - } - - buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), - suggestionText, - ); - resetCompletionState(); - }, - [cursorRow, resetCompletionState, buffer, suggestions, commandIndex], - ); - return { suggestions, activeSuggestionIndex, @@ -694,11 +113,16 @@ export function useCompletion( showSuggestions, isLoadingSuggestions, isPerfectMatch, - setActiveSuggestionIndex, + + setSuggestions, setShowSuggestions, + setActiveSuggestionIndex, + setVisibleStartIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + resetCompletionState, navigateUp, navigateDown, - handleAutocomplete, }; } diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx new file mode 100644 index 00000000..373696ce --- /dev/null +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useReverseSearchCompletion } from './useReverseSearchCompletion.js'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; + +describe('useReverseSearchCompletion', () => { + function useTextBufferForTest(text: string) { + return useTextBuffer({ + initialText: text, + initialCursorOffset: text.length, + viewport: { width: 80, height: 20 }, + isValidPath: () => false, + onChange: () => {}, + }); + } + + describe('Core Hook Behavior', () => { + describe('State Management', () => { + it('should initialize with default state', () => { + const mockShellHistory = ['echo hello']; + + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest(''), + mockShellHistory, + false, + ), + ); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should reset state when reverseSearchActive becomes false', () => { + const mockShellHistory = ['echo hello']; + const { result, rerender } = renderHook( + ({ text, active }) => { + const textBuffer = useTextBufferForTest(text); + return useReverseSearchCompletion( + textBuffer, + mockShellHistory, + active, + ); + }, + { initialProps: { text: 'echo', active: true } }, + ); + + // Simulate reverseSearchActive becoming false + rerender({ text: 'echo', active: false }); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.activeSuggestionIndex).toBe(-1); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.showSuggestions).toBe(false); + }); + + describe('Navigation', () => { + it('should handle navigateUp with no suggestions', () => { + const mockShellHistory = ['echo hello']; + + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('grep'), + mockShellHistory, + true, + ), + ); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(-1); + }); + + it('should handle navigateDown with no suggestions', () => { + const mockShellHistory = ['echo hello']; + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('grep'), + mockShellHistory, + true, + ), + ); + + act(() => { + result.current.navigateDown(); + }); + + expect(result.current.activeSuggestionIndex).toBe(-1); + }); + + it('should navigate up through suggestions with wrap-around', () => { + const mockShellHistory = [ + 'ls -l', + 'ls -la', + 'cd /some/path', + 'git status', + 'echo "Hello, World!"', + 'echo Hi', + ]; + + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('echo'), + mockShellHistory, + true, + ), + ); + + expect(result.current.suggestions.length).toBe(2); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(1); + }); + + it('should navigate down through suggestions with wrap-around', () => { + const mockShellHistory = [ + 'ls -l', + 'ls -la', + 'cd /some/path', + 'git status', + 'echo "Hello, World!"', + 'echo Hi', + ]; + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('ls'), + mockShellHistory, + true, + ), + ); + + expect(result.current.suggestions.length).toBe(2); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateDown(); + }); + + expect(result.current.activeSuggestionIndex).toBe(1); + }); + + it('should handle navigation with multiple suggestions', () => { + const mockShellHistory = [ + 'ls -l', + 'ls -la', + 'cd /some/path/l', + 'git status', + 'echo "Hello, World!"', + 'echo "Hi all"', + ]; + + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('l'), + mockShellHistory, + true, + ), + ); + + expect(result.current.suggestions.length).toBe(5); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateDown(); + }); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => { + result.current.navigateDown(); + }); + expect(result.current.activeSuggestionIndex).toBe(2); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + expect(result.current.activeSuggestionIndex).toBe(4); + }); + + it('should handle navigation with large suggestion lists and scrolling', () => { + const largeMockCommands = Array.from( + { length: 15 }, + (_, i) => `echo ${i}`, + ); + + const { result } = renderHook(() => + useReverseSearchCompletion( + useTextBufferForTest('echo'), + largeMockCommands, + true, + ), + ); + + expect(result.current.suggestions.length).toBe(15); + expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.visibleStartIndex).toBe(0); + + act(() => { + result.current.navigateUp(); + }); + + expect(result.current.activeSuggestionIndex).toBe(14); + expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8)); + }); + }); + }); + }); + + describe('Filtering', () => { + it('filters history by buffer.text and sets showSuggestions', () => { + const history = ['foo', 'barfoo', 'baz']; + const { result } = renderHook(() => + useReverseSearchCompletion(useTextBufferForTest('foo'), history, true), + ); + + // should only return the two entries containing "foo" + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'foo', + 'barfoo', + ]); + expect(result.current.showSuggestions).toBe(true); + }); + + it('hides suggestions when there are no matches', () => { + const history = ['alpha', 'beta']; + const { result } = renderHook(() => + useReverseSearchCompletion(useTextBufferForTest('γ'), history, true), + ); + + expect(result.current.suggestions).toEqual([]); + expect(result.current.showSuggestions).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx new file mode 100644 index 00000000..1cc7e602 --- /dev/null +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useCallback } from 'react'; +import { useCompletion } from './useCompletion.js'; +import { TextBuffer } from '../components/shared/text-buffer.js'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; + +export interface UseReverseSearchCompletionReturn { + suggestions: Suggestion[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + navigateUp: () => void; + navigateDown: () => void; + handleAutocomplete: (i: number) => void; + resetCompletionState: () => void; +} + +export function useReverseSearchCompletion( + buffer: TextBuffer, + shellHistory: readonly string[], + reverseSearchActive: boolean, +): UseReverseSearchCompletionReturn { + const { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + resetCompletionState, + navigateUp, + navigateDown, + } = useCompletion(); + + // whenever reverseSearchActive is on, filter history + useEffect(() => { + if (!reverseSearchActive) { + resetCompletionState(); + return; + } + const q = buffer.text.toLowerCase(); + const matches = shellHistory.reduce((acc, cmd) => { + const idx = cmd.toLowerCase().indexOf(q); + if (idx !== -1) { + acc.push({ label: cmd, value: cmd, matchedIndex: idx }); + } + return acc; + }, []); + setSuggestions(matches); + setShowSuggestions(matches.length > 0); + setActiveSuggestionIndex(matches.length > 0 ? 0 : -1); + }, [ + buffer.text, + shellHistory, + reverseSearchActive, + resetCompletionState, + setActiveSuggestionIndex, + setShowSuggestions, + setSuggestions, + ]); + + const handleAutocomplete = useCallback( + (i: number) => { + if (i < 0 || i >= suggestions.length) return; + buffer.setText(suggestions[i].value); + resetCompletionState(); + }, + [buffer, suggestions, resetCompletionState], + ); + + return { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + navigateUp, + navigateDown, + handleAutocomplete, + resetCompletionState, + }; +} diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts index 61c7207c..2e18dfbd 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.ts @@ -13,6 +13,7 @@ const HISTORY_FILE = 'shell_history'; const MAX_HISTORY_LENGTH = 100; export interface UseShellHistoryReturn { + history: string[]; addCommandToHistory: (command: string) => void; getPreviousCommand: () => string | null; getNextCommand: () => string | null; @@ -24,15 +25,32 @@ async function getHistoryFilePath(projectRoot: string): Promise { return path.join(historyDir, HISTORY_FILE); } +// Handle multiline commands async function readHistoryFile(filePath: string): Promise { try { - const content = await fs.readFile(filePath, 'utf-8'); - return content.split('\n').filter(Boolean); - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - return []; + const text = await fs.readFile(filePath, 'utf-8'); + const result: string[] = []; + let cur = ''; + + for (const raw of text.split(/\r?\n/)) { + if (!raw.trim()) continue; + const line = raw; + + const m = cur.match(/(\\+)$/); + if (m && m[1].length % 2) { + // odd number of trailing '\' + cur = cur.slice(0, -1) + ' ' + line; + } else { + if (cur) result.push(cur); + cur = line; + } } - console.error('Error reading shell history:', error); + + if (cur) result.push(cur); + return result; + } catch (err) { + if (isNodeError(err) && err.code === 'ENOENT') return []; + console.error('Error reading history:', err); return []; } } @@ -101,10 +119,15 @@ export function useShellHistory(projectRoot: string): UseShellHistoryReturn { return history[newIndex] ?? null; }, [history, historyIndex]); + const resetHistoryPosition = useCallback(() => { + setHistoryIndex(-1); + }, []); + return { + history, addCommandToHistory, getPreviousCommand, getNextCommand, - resetHistoryPosition: () => setHistoryIndex(-1), + resetHistoryPosition, }; } diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts similarity index 95% rename from packages/cli/src/ui/hooks/useCompletion.test.ts rename to packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 3a401194..13f8c240 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { useCompletion } from './useCompletion.js'; +import { useSlashCompletion } from './useSlashCompletion.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -16,7 +16,7 @@ import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; -describe('useCompletion', () => { +describe('useSlashCompletion', () => { let testRootDir: string; let mockConfig: Config; @@ -50,7 +50,7 @@ describe('useCompletion', () => { beforeEach(async () => { testRootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'completion-unit-test-'), + path.join(os.tmpdir(), 'slash-completion-unit-test-'), ); testDirs = [testRootDir]; mockConfig = { @@ -82,12 +82,13 @@ describe('useCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest(''), testDirs, testRootDir, slashCommands, mockCommandContext, + false, mockConfig, ), ); @@ -112,12 +113,13 @@ describe('useCompletion', () => { const { result, rerender } = renderHook( ({ text }) => { const textBuffer = useTextBufferForTest(text); - return useCompletion( + return useSlashCompletion( textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, + false, mockConfig, ); }, @@ -143,12 +145,13 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/help'), testDirs, testRootDir, slashCommands, mockCommandContext, + false, mockConfig, ), ); @@ -176,12 +179,13 @@ describe('useCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest(''), testDirs, testRootDir, slashCommands, mockCommandContext, + false, mockConfig, ), ); @@ -198,12 +202,14 @@ describe('useCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest(''), testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -223,12 +229,14 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/h'), testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -251,12 +259,14 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/h'), testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -280,12 +290,14 @@ describe('useCompletion', () => { { name: 'chat', description: 'Manage chat' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/'), testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -326,12 +338,14 @@ describe('useCompletion', () => { })) as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/command'), testDirs, testRootDir, largeMockCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -384,7 +398,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/'), testDirs, testRootDir, @@ -407,7 +421,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/mem'), testDirs, testRootDir, @@ -431,7 +445,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/usag'), // part of the word "usage" testDirs, testRootDir, @@ -458,7 +472,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/clear'), // No trailing space testDirs, testRootDir, @@ -490,7 +504,7 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest(query), testDirs, testRootDir, @@ -511,7 +525,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/clear '), testDirs, testRootDir, @@ -532,7 +546,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/unknown-command'), testDirs, testRootDir, @@ -566,7 +580,7 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/memory'), // Note: no trailing space testDirs, testRootDir, @@ -604,7 +618,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/memory'), testDirs, testRootDir, @@ -640,7 +654,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/memory a'), testDirs, testRootDir, @@ -672,7 +686,7 @@ describe('useCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/memory dothisnow'), testDirs, testRootDir, @@ -715,7 +729,7 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/chat resume my-ch'), testDirs, testRootDir, @@ -759,7 +773,7 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/chat resume '), testDirs, testRootDir, @@ -794,12 +808,14 @@ describe('useCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('/chat resume '), testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ), ); @@ -822,12 +838,14 @@ describe('useCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@s'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -856,12 +874,14 @@ describe('useCompletion', () => { await createTestFile('', 'src', 'index.ts'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@src/comp'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -882,12 +902,14 @@ describe('useCompletion', () => { await createTestFile('', 'src', 'index.ts'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@.'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -914,12 +936,14 @@ describe('useCompletion', () => { await createEmptyDir('dist'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@d'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfigNoRecursive, ), ); @@ -940,7 +964,7 @@ describe('useCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@'), testDirs, testRootDir, @@ -975,12 +999,14 @@ describe('useCompletion', () => { .mockImplementation(() => {}); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -1006,12 +1032,14 @@ describe('useCompletion', () => { await createEmptyDir('data'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@d'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -1040,12 +1068,14 @@ describe('useCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -1073,12 +1103,14 @@ describe('useCompletion', () => { await createTestFile('', 'temp', 'temp.log'); const { result } = renderHook(() => - useCompletion( + useSlashCompletion( useTextBufferForTest('@t'), testDirs, testRootDir, [], mockCommandContext, + false, + mockConfig, ), ); @@ -1116,12 +1148,14 @@ describe('useCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/mem'); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ); return { ...completion, textBuffer }; @@ -1158,12 +1192,14 @@ describe('useCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/memory'); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ); return { ...completion, textBuffer }; @@ -1202,12 +1238,14 @@ describe('useCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/?'); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, slashCommands, mockCommandContext, + false, + mockConfig, ); return { ...completion, textBuffer }; @@ -1229,12 +1267,13 @@ describe('useCompletion', () => { it('should complete a file path', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('@src/fi'); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, [], mockCommandContext, + false, mockConfig, ); return { ...completion, textBuffer }; @@ -1258,12 +1297,13 @@ describe('useCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest(text, cursorOffset); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, [], mockCommandContext, + false, mockConfig, ); return { ...completion, textBuffer }; @@ -1286,12 +1326,13 @@ describe('useCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest(text); - const completion = useCompletion( + const completion = useSlashCompletion( textBuffer, testDirs, testRootDir, [], mockCommandContext, + false, mockConfig, ); return { ...completion, textBuffer }; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx new file mode 100644 index 00000000..f68d52d8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx @@ -0,0 +1,654 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useCallback, useMemo, useRef } from 'react'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { glob } from 'glob'; +import { + isNodeError, + escapePath, + unescapePath, + getErrorMessage, + Config, + FileDiscoveryService, + DEFAULT_FILE_FILTERING_OPTIONS, +} from '@google/gemini-cli-core'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; +import { + logicalPosToOffset, + TextBuffer, +} from '../components/shared/text-buffer.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import { toCodePoints } from '../utils/textUtils.js'; +import { useCompletion } from './useCompletion.js'; + +export interface UseSlashCompletionReturn { + suggestions: Suggestion[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + isPerfectMatch: boolean; + setActiveSuggestionIndex: React.Dispatch>; + setShowSuggestions: React.Dispatch>; + resetCompletionState: () => void; + navigateUp: () => void; + navigateDown: () => void; + handleAutocomplete: (indexToUse: number) => void; +} + +export function useSlashCompletion( + buffer: TextBuffer, + dirs: readonly string[], + cwd: string, + slashCommands: readonly SlashCommand[], + commandContext: CommandContext, + reverseSearchActive: boolean = false, + config?: Config, +): UseSlashCompletionReturn { + const { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + + resetCompletionState, + navigateUp, + navigateDown, + } = useCompletion(); + + const completionStart = useRef(-1); + const completionEnd = useRef(-1); + + const cursorRow = buffer.cursor[0]; + const cursorCol = buffer.cursor[1]; + + // Check if cursor is after @ or / without unescaped spaces + const commandIndex = useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return currentLine.indexOf('/'); + } + + // For other completions like '@', we search backwards from the cursor. + + const codePoints = toCodePoints(currentLine); + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; + + if (char === ' ') { + // Check for unescaped spaces. + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + return -1; // Inactive on unescaped space. + } + } else if (char === '@') { + // Active if we find an '@' before any unescaped space. + return i; + } + } + + return -1; + }, [cursorRow, cursorCol, buffer.lines]); + + useEffect(() => { + if (commandIndex === -1 || reverseSearchActive) { + resetCompletionState(); + return; + } + + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); + + if (codePoints[commandIndex] === '/') { + // Always reset perfect match at the beginning of processing. + setIsPerfectMatch(false); + + const fullPath = currentLine.substring(commandIndex + 1); + const hasTrailingSpace = currentLine.endsWith(' '); + + // Get all non-empty parts of the command. + const rawParts = fullPath.split(/\s+/).filter((p) => p); + + let commandPathParts = rawParts; + let partial = ''; + + // If there's no trailing space, the last part is potentially a partial segment. + // We tentatively separate it. + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + // Traverse the Command Tree using the tentative completed path + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found: SlashCommand | undefined = currentLevel.find( + (cmd) => cmd.name === part || cmd.altNames?.includes(part), + ); + if (found) { + leafCommand = found; + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + let exactMatchAsParent: SlashCommand | undefined; + // Handle the Ambiguous Case + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.subCommands, + ); + + if (exactMatchAsParent) { + // It's a perfect match for a parent command. Override our initial guess. + // Treat it as a completed command path. + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; // We now want to suggest ALL of its sub-commands. + } + } + + // Check for perfect, executable match + if (!hasTrailingSpace) { + if (leafCommand && partial === '' && leafCommand.action) { + // Case: /command - command has action, no sub-commands were suggested + setIsPerfectMatch(true); + } else if (currentLevel) { + // Case: /command subcommand + const perfectMatch = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.action, + ); + if (perfectMatch) { + setIsPerfectMatch(true); + } + } + } + + const depth = commandPathParts.length; + const isArgumentCompletion = + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')); + + // Set completion range + if (hasTrailingSpace || exactMatchAsParent) { + completionStart.current = currentLine.length; + completionEnd.current = currentLine.length; + } else if (partial) { + if (isArgumentCompletion) { + const commandSoFar = `/${commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); + completionStart.current = argStartIndex; + } else { + completionStart.current = currentLine.length - partial.length; + } + completionEnd.current = currentLine.length; + } else { + // e.g. / + completionStart.current = commandIndex + 1; + completionEnd.current = currentLine.length; + } + + // Provide Suggestions based on the now-corrected context + if (isArgumentCompletion) { + const fetchAndSetSuggestions = async () => { + setIsLoadingSuggestions(true); + const argString = rawParts.slice(depth).join(' '); + const results = + (await leafCommand!.completion!(commandContext, argString)) || []; + const finalSuggestions = results.map((s) => ({ label: s, value: s })); + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); + setIsLoadingSuggestions(false); + }; + fetchAndSetSuggestions(); + return; + } + + // Command/Sub-command Completion + const commandsToSearch = currentLevel || []; + if (commandsToSearch.length > 0) { + let potentialSuggestions = commandsToSearch.filter( + (cmd) => + cmd.description && + (cmd.name.startsWith(partial) || + cmd.altNames?.some((alt) => alt.startsWith(partial))), + ); + + // If a user's input is an exact match and it is a leaf command, + // enter should submit immediately. + if (potentialSuggestions.length > 0 && !hasTrailingSpace) { + const perfectMatch = potentialSuggestions.find( + (s) => s.name === partial || s.altNames?.includes(partial), + ); + if (perfectMatch && perfectMatch.action) { + potentialSuggestions = []; + } + } + + const finalSuggestions = potentialSuggestions.map((cmd) => ({ + label: cmd.name, + value: cmd.name, + description: cmd.description, + })); + + setSuggestions(finalSuggestions); + setShowSuggestions(finalSuggestions.length > 0); + setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); + setIsLoadingSuggestions(false); + return; + } + + // If we fall through, no suggestions are available. + resetCompletionState(); + return; + } + + // Handle At Command Completion + completionEnd.current = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + completionEnd.current = i; + break; + } + } + } + + const pathStart = commandIndex + 1; + const partialPath = currentLine.substring(pathStart, completionEnd.current); + const lastSlashIndex = partialPath.lastIndexOf('/'); + completionStart.current = + lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; + const baseDirRelative = + lastSlashIndex === -1 + ? '.' + : partialPath.substring(0, lastSlashIndex + 1); + const prefix = unescapePath( + lastSlashIndex === -1 + ? partialPath + : partialPath.substring(lastSlashIndex + 1), + ); + + let isMounted = true; + + const findFilesRecursively = async ( + startDir: string, + searchPrefix: string, + fileDiscovery: FileDiscoveryService | null, + filterOptions: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + }, + currentRelativePath = '', + depth = 0, + maxDepth = 10, // Limit recursion depth + maxResults = 50, // Limit number of results + ): Promise => { + if (depth > maxDepth) { + return []; + } + + const lowerSearchPrefix = searchPrefix.toLowerCase(); + let foundSuggestions: Suggestion[] = []; + try { + const entries = await fs.readdir(startDir, { withFileTypes: true }); + for (const entry of entries) { + if (foundSuggestions.length >= maxResults) break; + + const entryPathRelative = path.join(currentRelativePath, entry.name); + const entryPathFromRoot = path.relative( + startDir, + path.join(startDir, entry.name), + ); + + // Conditionally ignore dotfiles + if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + + // Check if this entry should be ignored by filtering options + if ( + fileDiscovery && + fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions) + ) { + continue; + } + + if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { + foundSuggestions.push({ + label: entryPathRelative + (entry.isDirectory() ? '/' : ''), + value: escapePath( + entryPathRelative + (entry.isDirectory() ? '/' : ''), + ), + }); + } + if ( + entry.isDirectory() && + entry.name !== 'node_modules' && + !entry.name.startsWith('.') + ) { + if (foundSuggestions.length < maxResults) { + foundSuggestions = foundSuggestions.concat( + await findFilesRecursively( + path.join(startDir, entry.name), + searchPrefix, // Pass original searchPrefix for recursive calls + fileDiscovery, + filterOptions, + entryPathRelative, + depth + 1, + maxDepth, + maxResults - foundSuggestions.length, + ), + ); + } + } + } + } catch (_err) { + // Ignore errors like permission denied or ENOENT during recursive search + } + return foundSuggestions.slice(0, maxResults); + }; + + const findFilesWithGlob = async ( + searchPrefix: string, + fileDiscoveryService: FileDiscoveryService, + filterOptions: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + }, + searchDir: string, + maxResults = 50, + ): Promise => { + const globPattern = `**/${searchPrefix}*`; + const files = await glob(globPattern, { + cwd: searchDir, + dot: searchPrefix.startsWith('.'), + nocase: true, + }); + + const suggestions: Suggestion[] = files + .filter((file) => { + if (fileDiscoveryService) { + return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); + } + return true; + }) + .map((file: string) => { + const absolutePath = path.resolve(searchDir, file); + const label = path.relative(cwd, absolutePath); + return { + label, + value: escapePath(label), + }; + }) + .slice(0, maxResults); + + return suggestions; + }; + + const fetchSuggestions = async () => { + setIsLoadingSuggestions(true); + let fetchedSuggestions: Suggestion[] = []; + + const fileDiscoveryService = config ? config.getFileService() : null; + const enableRecursiveSearch = + config?.getEnableRecursiveFileSearch() ?? true; + const filterOptions = + config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; + + try { + // If there's no slash, or it's the root, do a recursive search from workspace directories + for (const dir of dirs) { + let fetchedSuggestionsPerDir: Suggestion[] = []; + if ( + partialPath.indexOf('/') === -1 && + prefix && + enableRecursiveSearch + ) { + if (fileDiscoveryService) { + fetchedSuggestionsPerDir = await findFilesWithGlob( + prefix, + fileDiscoveryService, + filterOptions, + dir, + ); + } else { + fetchedSuggestionsPerDir = await findFilesRecursively( + dir, + prefix, + null, + filterOptions, + ); + } + } else { + // Original behavior: list files in the specific directory + const lowerPrefix = prefix.toLowerCase(); + const baseDirAbsolute = path.resolve(dir, baseDirRelative); + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); + + // Filter entries using git-aware filtering + const filteredEntries = []; + for (const entry of entries) { + // Conditionally ignore dotfiles + if (!prefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; + + const relativePath = path.relative( + dir, + path.join(baseDirAbsolute, entry.name), + ); + if ( + fileDiscoveryService && + fileDiscoveryService.shouldIgnoreFile( + relativePath, + filterOptions, + ) + ) { + continue; + } + + filteredEntries.push(entry); + } + + fetchedSuggestionsPerDir = filteredEntries.map((entry) => { + const absolutePath = path.resolve(baseDirAbsolute, entry.name); + const label = + cwd === dir ? entry.name : path.relative(cwd, absolutePath); + const suggestionLabel = entry.isDirectory() ? label + '/' : label; + return { + label: suggestionLabel, + value: escapePath(suggestionLabel), + }; + }); + } + fetchedSuggestions = [ + ...fetchedSuggestions, + ...fetchedSuggestionsPerDir, + ]; + } + + // Like glob, we always return forwardslashes, even in windows. + fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ + ...suggestion, + label: suggestion.label.replace(/\\/g, '/'), + value: suggestion.value.replace(/\\/g, '/'), + })); + + // Sort by depth, then directories first, then alphabetically + fetchedSuggestions.sort((a, b) => { + const depthA = (a.label.match(/\//g) || []).length; + const depthB = (b.label.match(/\//g) || []).length; + + if (depthA !== depthB) { + return depthA - depthB; + } + + const aIsDir = a.label.endsWith('/'); + const bIsDir = b.label.endsWith('/'); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + // exclude extension when comparing + const filenameA = a.label.substring( + 0, + a.label.length - path.extname(a.label).length, + ); + const filenameB = b.label.substring( + 0, + b.label.length - path.extname(b.label).length, + ); + + return ( + filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label) + ); + }); + + if (isMounted) { + setSuggestions(fetchedSuggestions); + setShowSuggestions(fetchedSuggestions.length > 0); + setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); + setVisibleStartIndex(0); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (isMounted) { + setSuggestions([]); + setShowSuggestions(false); + } + } else { + console.error( + `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, + ); + if (isMounted) { + resetCompletionState(); + } + } + } + if (isMounted) { + setIsLoadingSuggestions(false); + } + }; + + const debounceTimeout = setTimeout(fetchSuggestions, 100); + + return () => { + isMounted = false; + clearTimeout(debounceTimeout); + }; + }, [ + buffer.text, + cursorRow, + cursorCol, + buffer.lines, + dirs, + cwd, + commandIndex, + resetCompletionState, + slashCommands, + commandContext, + config, + reverseSearchActive, + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + ]); + + const handleAutocomplete = useCallback( + (indexToUse: number) => { + if (indexToUse < 0 || indexToUse >= suggestions.length) { + return; + } + const suggestion = suggestions[indexToUse].value; + + if (completionStart.current === -1 || completionEnd.current === -1) { + return; + } + + const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; + let suggestionText = suggestion; + if (isSlash) { + // If we are inserting (not replacing), and the preceding character is not a space, add one. + if ( + completionStart.current === completionEnd.current && + completionStart.current > commandIndex + 1 && + (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + ) { + suggestionText = ' ' + suggestionText; + } + suggestionText += ' '; + } + + buffer.replaceRangeByOffset( + logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), + logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + suggestionText, + ); + resetCompletionState(); + }, + [cursorRow, resetCompletionState, buffer, suggestions, commandIndex], + ); + + return { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + setActiveSuggestionIndex, + setShowSuggestions, + resetCompletionState, + navigateUp, + navigateDown, + handleAutocomplete, + }; +} From 2cdaf912ba43a79e68baa74db6086b7f41bc3b82 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Sun, 3 Aug 2025 16:19:34 -0400 Subject: [PATCH 078/136] Generate NOTICES.TXT and surface via command (#5310) --- eslint.config.js | 16 +++ packages/vscode-ide-companion/.vscodeignore | 1 + packages/vscode-ide-companion/NOTICES.txt | 114 ++++++++++++++++++ packages/vscode-ide-companion/esbuild.js | 4 +- packages/vscode-ide-companion/package.json | 10 +- .../scripts/generate-notices.js | 105 ++++++++++++++++ .../vscode-ide-companion/src/extension.ts | 11 +- 7 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 packages/vscode-ide-companion/NOTICES.txt create mode 100644 packages/vscode-ide-companion/scripts/generate-notices.js diff --git a/eslint.config.js b/eslint.config.js index 169bbd17..a1194df7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,7 @@ export default tseslint.config( 'packages/server/dist/**', 'packages/vscode-ide-companion/dist/**', 'bundle/**', + 'package/bundle/**', ], }, eslint.configs.recommended, @@ -203,6 +204,21 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', }, }, + // extra settings for scripts that we run directly with node + { + files: ['packages/vscode-ide-companion/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, // Prettier config must be last prettierConfig, // extra settings for scripts that we run directly with node diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore index be532ef9..e74d0536 100644 --- a/packages/vscode-ide-companion/.vscodeignore +++ b/packages/vscode-ide-companion/.vscodeignore @@ -3,4 +3,5 @@ ../ ../../ !LICENSE +!NOTICES.txt !assets/ diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt new file mode 100644 index 00000000..56f3d4f3 --- /dev/null +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -0,0 +1,114 @@ +This file contains third-party software notices and license terms. + +============================================================ +@modelcontextprotocol/sdk@^1.15.1 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +cors@^2.8.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2013 Troy Goode + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +express@^5.1.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2009-2014 TJ Holowaychuk +Copyright (c) 2013-2014 Roman Shtylman +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +zod@^3.25.76 +(git+https://github.com/colinhacks/zod.git) + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 522542db..060be7c6 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -const esbuild = require('esbuild'); +import esbuild from 'esbuild'; const production = process.argv.includes('--production'); const watch = process.argv.includes('--watch'); @@ -40,7 +40,7 @@ async function main() { sourcemap: !production, sourcesContent: false, platform: 'node', - outfile: 'dist/extension.js', + outfile: 'dist/extension.cjs', external: ['vscode'], logLevel: 'silent', plugins: [ diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index c4ff2b68..254d8ac2 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -35,18 +35,24 @@ { "command": "gemini-cli.runGeminiCLI", "title": "Gemini CLI: Run" + }, + { + "command": "gemini-cli.showNotices", + "title": "Gemini CLI: View Third-Party Notices" } ] }, - "main": "./dist/extension.js", + "main": "./dist/extension.cjs", + "type": "module", "scripts": { - "vscode:prepublish": "npm run check-types && npm run lint && node esbuild.js --production", + "vscode:prepublish": "npm run generate:notices && npm run check-types && npm run lint && node esbuild.js --production", "build": "npm run compile", "compile": "npm run check-types && npm run lint && node esbuild.js", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "vsce package --no-dependencies", + "generate:notices": "node ./scripts/generate-notices.js", "check-types": "tsc --noEmit", "lint": "eslint src", "test": "vitest run", diff --git a/packages/vscode-ide-companion/scripts/generate-notices.js b/packages/vscode-ide-companion/scripts/generate-notices.js new file mode 100644 index 00000000..55dc3108 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/generate-notices.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const projectRoot = path.resolve( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'), +); +const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion'); +const noticeFilePath = path.join(packagePath, 'NOTICES.txt'); + +async function getDependencyLicense(depName, depVersion) { + let depPackageJsonPath; + let licenseContent = 'License text not found.'; + let repositoryUrl = 'No repository found'; + + try { + depPackageJsonPath = path.join( + projectRoot, + 'node_modules', + depName, + 'package.json', + ); + if (!(await fs.stat(depPackageJsonPath).catch(() => false))) { + depPackageJsonPath = path.join( + packagePath, + 'node_modules', + depName, + 'package.json', + ); + } + + const depPackageJsonContent = await fs.readFile( + depPackageJsonPath, + 'utf-8', + ); + const depPackageJson = JSON.parse(depPackageJsonContent); + + repositoryUrl = depPackageJson.repository?.url || repositoryUrl; + + const licenseFile = depPackageJson.licenseFile + ? path.join(path.dirname(depPackageJsonPath), depPackageJson.licenseFile) + : path.join(path.dirname(depPackageJsonPath), 'LICENSE'); + + try { + licenseContent = await fs.readFile(licenseFile, 'utf-8'); + } catch (e) { + console.warn( + `Warning: Failed to read license file for ${depName}: ${e.message}`, + ); + } + } catch (e) { + console.warn( + `Warning: Could not find package.json for ${depName}: ${e.message}`, + ); + } + + return { + name: depName, + version: depVersion, + repository: repositoryUrl, + license: licenseContent, + }; +} + +async function main() { + try { + const packageJsonPath = path.join(packagePath, 'package.json'); + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent); + + const dependencies = packageJson.dependencies || {}; + const dependencyEntries = Object.entries(dependencies); + + const licensePromises = dependencyEntries.map(([depName, depVersion]) => + getDependencyLicense(depName, depVersion), + ); + + const dependencyLicenses = await Promise.all(licensePromises); + + let noticeText = + 'This file contains third-party software notices and license terms.\n\n'; + + for (const dep of dependencyLicenses) { + noticeText += + '============================================================\n'; + noticeText += `${dep.name}@${dep.version}\n`; + noticeText += `(${dep.repository})\n\n`; + noticeText += `${dep.license}\n\n`; + } + + await fs.writeFile(noticeFilePath, noticeText); + console.log(`NOTICES.txt generated at ${noticeFilePath}`); + } catch (error) { + console.error('Error generating NOTICES.txt:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 637b69e3..73090175 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -5,8 +5,8 @@ */ import * as vscode from 'vscode'; -import { IDEServer } from './ide-server'; -import { createLogger } from './utils/logger'; +import { IDEServer } from './ide-server.js'; +import { createLogger } from './utils/logger.js'; const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; @@ -55,6 +55,13 @@ export async function activate(context: vscode.ExtensionContext) { terminal.show(); terminal.sendText(geminiCmd); }), + vscode.commands.registerCommand('gemini-cli.showNotices', async () => { + const noticePath = vscode.Uri.joinPath( + context.extensionUri, + 'NOTICES.txt', + ); + await vscode.window.showTextDocument(noticePath); + }), ); } From 70478b92a948b41a96963a4f3de057670bb4564f Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Sun, 3 Aug 2025 13:38:03 -0700 Subject: [PATCH 079/136] chore(release): v0.1.16 (#5478) --- package-lock.json | 8 ++++---- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3938c5e3..7067960d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.1.15", + "version": "0.1.16", "workspaces": [ "packages/*" ], @@ -11659,7 +11659,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.1.15", + "version": "0.1.16", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", @@ -11860,7 +11860,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.1.15", + "version": "0.1.16", "dependencies": { "@google/genai": "1.9.0", "@modelcontextprotocol/sdk": "^1.11.0", diff --git a/package.json b/package.json index 9d4f18c8..ec01287f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.15", + "version": "0.1.16", "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.1.15" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.16" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index e0518041..d65102e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.15", + "version": "0.1.16", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.15" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.16" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index c3517ce1..de2b7201 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.1.15", + "version": "0.1.16", "description": "Gemini CLI Core", "repository": { "type": "git", From acd48a125928cd721946713241e3236680d43507 Mon Sep 17 00:00:00 2001 From: Ali Al Jufairi Date: Mon, 4 Aug 2025 06:56:27 +0900 Subject: [PATCH 080/136] =?UTF-8?q?docs(fix):=20Update=20themes=20document?= =?UTF-8?q?ation=20to=20include=20new=20color=20keys=20for=E2=80=A6=20(#54?= =?UTF-8?q?67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cli/themes.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/cli/themes.md b/docs/cli/themes.md index df891868..25d6123c 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -58,7 +58,11 @@ Add a `customThemes` block to your user, project, or system `settings.json` file "AccentYellow": "#E5C07B", "AccentRed": "#E06C75", "Comment": "#5C6370", - "Gray": "#ABB2BF" + "Gray": "#ABB2BF", + "DiffAdded": "#A6E3A1", + "DiffRemoved": "#F38BA8", + "DiffModified": "#89B4FA", + "GradientColors": ["#4796E4", "#847ACE", "#C3677F"] } } } @@ -77,6 +81,9 @@ Add a `customThemes` block to your user, project, or system `settings.json` file - `AccentRed` - `Comment` - `Gray` +- `DiffAdded` (optional, for added lines in diffs) +- `DiffRemoved` (optional, for removed lines in diffs) +- `DiffModified` (optional, for modified lines in diffs) **Required Properties:** From a8984a9b30d153219cf517a7dc77d65ea3ef0965 Mon Sep 17 00:00:00 2001 From: Kumbham Ajay Goud Date: Mon, 4 Aug 2025 03:33:01 +0530 Subject: [PATCH 081/136] Fix: Preserve conversation history when changing auth methods via /auth (#5216) Co-authored-by: Jacob Richman --- packages/core/src/config/config.test.ts | 81 +++++++++++++++++++++++++ packages/core/src/config/config.ts | 24 +++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 165d2882..dd50fd41 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -184,6 +184,87 @@ describe('Server Config (config.ts)', () => { // Verify that fallback mode is reset expect(config.isInFallbackMode()).toBe(false); }); + + it('should preserve conversation history when refreshing auth', async () => { + const config = new Config(baseParams); + const authType = AuthType.USE_GEMINI; + const mockContentConfig = { + model: 'gemini-pro', + apiKey: 'test-key', + }; + + (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); + + // Mock the existing client with some history + const mockExistingHistory = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ]; + + const mockExistingClient = { + isInitialized: vi.fn().mockReturnValue(true), + getHistory: vi.fn().mockReturnValue(mockExistingHistory), + }; + + const mockNewClient = { + isInitialized: vi.fn().mockReturnValue(true), + getHistory: vi.fn().mockReturnValue([]), + setHistory: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }; + + // Set the existing client + ( + config as unknown as { geminiClient: typeof mockExistingClient } + ).geminiClient = mockExistingClient; + (GeminiClient as Mock).mockImplementation(() => mockNewClient); + + await config.refreshAuth(authType); + + // Verify that existing history was retrieved + expect(mockExistingClient.getHistory).toHaveBeenCalled(); + + // Verify that new client was created and initialized + expect(GeminiClient).toHaveBeenCalledWith(config); + expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig); + + // Verify that history was restored to the new client + expect(mockNewClient.setHistory).toHaveBeenCalledWith( + mockExistingHistory, + ); + }); + + it('should handle case when no existing client is initialized', async () => { + const config = new Config(baseParams); + const authType = AuthType.USE_GEMINI; + const mockContentConfig = { + model: 'gemini-pro', + apiKey: 'test-key', + }; + + (createContentGeneratorConfig as Mock).mockReturnValue(mockContentConfig); + + const mockNewClient = { + isInitialized: vi.fn().mockReturnValue(true), + getHistory: vi.fn().mockReturnValue([]), + setHistory: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }; + + // No existing client + (config as unknown as { geminiClient: null }).geminiClient = null; + (GeminiClient as Mock).mockImplementation(() => mockNewClient); + + await config.refreshAuth(authType); + + // Verify that new client was created and initialized + expect(GeminiClient).toHaveBeenCalledWith(config); + expect(mockNewClient.initialize).toHaveBeenCalledWith(mockContentConfig); + + // Verify that setHistory was not called since there was no existing history + expect(mockNewClient.setHistory).not.toHaveBeenCalled(); + }); }); it('Config constructor should store userMemory correctly', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b2d5f387..e94e8421 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -47,6 +47,7 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js' import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import { IdeClient } from '../ide/ide-client.js'; +import type { Content } from '@google/genai'; // Re-export OAuth config type export type { MCPOAuthConfig }; @@ -332,13 +333,30 @@ export class Config { } async refreshAuth(authMethod: AuthType) { - this.contentGeneratorConfig = createContentGeneratorConfig( + // Save the current conversation history before creating a new client + let existingHistory: Content[] = []; + if (this.geminiClient && this.geminiClient.isInitialized()) { + existingHistory = this.geminiClient.getHistory(); + } + + // Create new content generator config + const newContentGeneratorConfig = createContentGeneratorConfig( this, authMethod, ); - this.geminiClient = new GeminiClient(this); - await this.geminiClient.initialize(this.contentGeneratorConfig); + // Create and initialize new client in local variable first + const newGeminiClient = new GeminiClient(this); + await newGeminiClient.initialize(newContentGeneratorConfig); + + // Only assign to instance properties after successful initialization + this.contentGeneratorConfig = newContentGeneratorConfig; + this.geminiClient = newGeminiClient; + + // Restore the conversation history to the new client + if (existingHistory.length > 0) { + this.geminiClient.setHistory(existingHistory); + } // Reset the session flag since we're explicitly changing auth and using default model this.inFallbackMode = false; From 94b7b402c521f4c11866db848802ac38747e15fe Mon Sep 17 00:00:00 2001 From: matt korwel Date: Mon, 4 Aug 2025 08:49:14 -0700 Subject: [PATCH 082/136] feat(docs): create new documentation for automation and triage (#5363) --- docs/issue-and-pr-automation.md | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/issue-and-pr-automation.md diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md new file mode 100644 index 00000000..45a4bdfd --- /dev/null +++ b/docs/issue-and-pr-automation.md @@ -0,0 +1,84 @@ +# Automation and Triage Processes + +This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots. + +## Guiding Principle: Issues and Pull Requests + +First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. + +--- + +## Detailed Automation Workflows + +Here is a breakdown of the specific automation workflows that run in our repository. + +### 1. When you open an Issue: `Automated Issue Triage` + +This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels. + +- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml` +- **When it runs**: Immediately after an issue is created or reopened. +- **What it does**: + - It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines. + - **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`). + - **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`). + - **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact. + - **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information. + - **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version. +- **What you should do**: + - Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be. + - If the `status/need-information` label is added, please provide the requested details in a comment. + +### 2. When you open a Pull Request: `Continuous Integration (CI)` + +This workflow ensures that all changes meet our quality standards before they can be merged. + +- **Workflow File**: `.github/workflows/ci.yml` +- **When it runs**: On every push to a pull request. +- **What it does**: + - **Lint**: Checks that your code adheres to our project's formatting and style rules. + - **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process. + - **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests. +- **What you should do**: + - Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful. + - If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix. + +### 3. Ongoing Triage for Pull Requests: `PR Auditing and Label Sync` + +This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels. + +- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml` +- **When it runs**: Every 15 minutes on all open pull requests. +- **What it does**: + - **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`). + - **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked. + - **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present. +- **What you should do**: + - **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #` to your PR description. + - This will ensure your PR is correctly categorized and moves through the review process smoothly. + +### 4. Ongoing Triage for Issues: `Scheduled Issue Triage` + +This is a fallback workflow to ensure that no issue gets missed by the triage process. + +- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml` +- **When it runs**: Every hour on all open issues. +- **What it does**: + - It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label. + - It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels. +- **What you should do**: + - You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails. + +### 5. Release Automation + +This workflow handles the process of packaging and publishing new versions of the Gemini CLI. + +- **Workflow File**: `.github/workflows/release.yml` +- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases. +- **What it does**: + - Automatically builds the project, bumps the version numbers, and publishes the packages to npm. + - Creates a corresponding release on GitHub with generated release notes. +- **What you should do**: + - As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release. + +We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask! From 83a04c47552c1407662a5e3e567f4c5e50bba5de Mon Sep 17 00:00:00 2001 From: owenofbrien <86964623+owenofbrien@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:48:46 -0500 Subject: [PATCH 083/136] Cloud Shell surface logging fix (#5364) --- .../core/src/telemetry/clearcut-logger/clearcut-logger.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index cfbbdda6..6b85a664 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -235,7 +235,11 @@ export class ClearcutLogger { } logStartSessionEvent(event: StartSessionEvent): void { - const surface = process.env.SURFACE || 'SURFACE_NOT_SET'; + const surface = + process.env.CLOUD_SHELL === 'true' + ? 'CLOUD_SHELL' + : process.env.SURFACE || 'SURFACE_NOT_SET'; + const data = [ { gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL, From e506b40c271da0e05a361f5299c37976a9e1f1f3 Mon Sep 17 00:00:00 2001 From: Pyush Sinha <89475668+psinha40898@users.noreply.github.com> Date: Mon, 4 Aug 2025 09:53:50 -0700 Subject: [PATCH 084/136] fix: /help remove flickering and respect clear shortcut (ctr+l) (#3611) Co-authored-by: Jacob Richman Co-authored-by: Allen Hutchison --- packages/cli/src/ui/App.tsx | 7 +-- .../cli/src/ui/commands/helpCommand.test.ts | 46 ++++++++++++------- packages/cli/src/ui/commands/helpCommand.ts | 16 ++++--- packages/cli/src/ui/commands/types.ts | 2 +- .../src/ui/components/HistoryItemDisplay.tsx | 5 ++ .../ui/hooks/slashCommandProcessor.test.ts | 21 ++++----- .../cli/src/ui/hooks/slashCommandProcessor.ts | 11 ++--- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 19 -------- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 -- packages/cli/src/ui/types.ts | 12 +++++ 10 files changed, 72 insertions(+), 70 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f63fcb35..3b695111 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -38,7 +38,6 @@ import { AuthInProgress } from './components/AuthInProgress.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; -import { Help } from './components/Help.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import { LoadedSettings } from '../config/settings.js'; import { Tips } from './components/Tips.js'; @@ -146,7 +145,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const [geminiMdFileCount, setGeminiMdFileCount] = useState(0); const [debugMessage, setDebugMessage] = useState(''); - const [showHelp, setShowHelp] = useState(false); const [themeError, setThemeError] = useState(null); const [authError, setAuthError] = useState(null); const [editorError, setEditorError] = useState(null); @@ -473,7 +471,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { clearItems, loadHistory, refreshStatic, - setShowHelp, setDebugMessage, openThemeDialog, openAuthDialog, @@ -495,7 +492,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { config.getGeminiClient(), history, addItem, - setShowHelp, config, setDebugMessage, handleSlashCommand, @@ -802,6 +798,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { item={h} isPending={false} config={config} + commands={slashCommands} /> )), ]} @@ -829,8 +826,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { - {showHelp && } - {/* Move UpdateNotification to render update notification above input area */} {updateInfo && } diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index b0441106..566eead7 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -4,37 +4,49 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { helpCommand } from './helpCommand.js'; import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { CommandKind } from './types.js'; describe('helpCommand', () => { let mockContext: CommandContext; + const originalEnv = { ...process.env }; beforeEach(() => { - mockContext = {} as unknown as CommandContext; + mockContext = createMockCommandContext({ + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); }); - it("should return a dialog action and log a debug message for '/help'", () => { - const consoleDebugSpy = vi - .spyOn(console, 'debug') - .mockImplementation(() => {}); + afterEach(() => { + process.env = { ...originalEnv }; + vi.clearAllMocks(); + }); + + it('should add a help message to the UI history', async () => { if (!helpCommand.action) { throw new Error('Help command has no action'); } - const result = helpCommand.action(mockContext, ''); - expect(result).toEqual({ - type: 'dialog', - dialog: 'help', - }); - expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...'); + await helpCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.HELP, + timestamp: expect.any(Date), + }), + expect.any(Number), + ); }); - it("should also be triggered by its alternative name '?'", () => { - // This test is more conceptual. The routing of altNames to the command - // is handled by the slash command processor, but we can assert the - // altNames is correctly defined on the command object itself. - expect(helpCommand.altNames).toContain('?'); + it('should have the correct command properties', () => { + expect(helpCommand.name).toBe('help'); + expect(helpCommand.kind).toBe(CommandKind.BUILT_IN); + expect(helpCommand.description).toBe('for help on gemini-cli'); }); }); diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 03c64615..0b71ce8f 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -4,18 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js'; +import { CommandKind, SlashCommand } from './types.js'; +import { MessageType, type HistoryItemHelp } from '../types.js'; export const helpCommand: SlashCommand = { name: 'help', altNames: ['?'], - description: 'for help on gemini-cli', kind: CommandKind.BUILT_IN, - action: (_context, _args): OpenDialogActionReturn => { - console.debug('Opening help UI ...'); - return { - type: 'dialog', - dialog: 'help', + description: 'for help on gemini-cli', + action: async (context) => { + const helpItem: Omit = { + type: MessageType.HELP, + timestamp: new Date(), }; + + context.ui.addItem(helpItem, Date.now()); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 900be866..2de221f0 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -98,7 +98,7 @@ export interface MessageActionReturn { */ export interface OpenDialogActionReturn { type: 'dialog'; - dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy'; + dialog: 'auth' | 'theme' | 'editor' | 'privacy'; } /** diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index eba4ea47..74615b26 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -21,6 +21,8 @@ import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import { ToolStatsDisplay } from './ToolStatsDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { Config } from '@google/gemini-cli-core'; +import { Help } from './Help.js'; +import { SlashCommand } from '../commands/types.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -29,6 +31,7 @@ interface HistoryItemDisplayProps { isPending: boolean; config?: Config; isFocused?: boolean; + commands?: readonly SlashCommand[]; } export const HistoryItemDisplay: React.FC = ({ @@ -37,6 +40,7 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth, isPending, config, + commands, isFocused = true, }) => ( @@ -71,6 +75,7 @@ export const HistoryItemDisplay: React.FC = ({ gcpProject={item.gcpProject} /> )} + {item.type === 'help' && commands && } {item.type === 'stats' && } {item.type === 'model_stats' && } {item.type === 'tool_stats' && } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index d9fe8530..a37af262 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -90,7 +90,7 @@ describe('useSlashCommandProcessor', () => { const mockAddItem = vi.fn(); const mockClearItems = vi.fn(); const mockLoadHistory = vi.fn(); - const mockSetShowHelp = vi.fn(); + const mockOpenThemeDialog = vi.fn(); const mockOpenAuthDialog = vi.fn(); const mockSetQuittingMessages = vi.fn(); @@ -132,9 +132,8 @@ describe('useSlashCommandProcessor', () => { mockClearItems, mockLoadHistory, vi.fn(), // refreshStatic - mockSetShowHelp, vi.fn(), // onDebugMessage - vi.fn(), // openThemeDialog + mockOpenThemeDialog, // openThemeDialog mockOpenAuthDialog, vi.fn(), // openEditorDialog vi.fn(), // toggleCorgiMode @@ -334,19 +333,19 @@ describe('useSlashCommandProcessor', () => { }); describe('Action Result Handling', () => { - it('should handle "dialog: help" action', async () => { + it('should handle "dialog: theme" action', async () => { const command = createTestCommand({ - name: 'helpcmd', - action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }), + name: 'themecmd', + action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }), }); const result = setupProcessorHook([command]); await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { - await result.current.handleSlashCommand('/helpcmd'); + await result.current.handleSlashCommand('/themecmd'); }); - expect(mockSetShowHelp).toHaveBeenCalledWith(true); + expect(mockOpenThemeDialog).toHaveBeenCalled(); }); it('should handle "load_history" action', async () => { @@ -819,15 +818,15 @@ describe('useSlashCommandProcessor', () => { mockClearItems, mockLoadHistory, vi.fn(), // refreshStatic - mockSetShowHelp, vi.fn(), // onDebugMessage vi.fn(), // openThemeDialog mockOpenAuthDialog, - vi.fn(), // openEditorDialog, + vi.fn(), // openEditorDialog vi.fn(), // toggleCorgiMode mockSetQuittingMessages, vi.fn(), // openPrivacyNotice - vi.fn(), // toggleVimEnabled + vi.fn().mockResolvedValue(false), // toggleVimEnabled + vi.fn(), // setIsProcessing ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index a2a1837d..6d9f4643 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -42,7 +42,6 @@ export const useSlashCommandProcessor = ( clearItems: UseHistoryManagerReturn['clearItems'], loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, - setShowHelp: React.Dispatch>, onDebugMessage: (message: string) => void, openThemeDialog: () => void, openAuthDialog: () => void, @@ -105,6 +104,11 @@ export const useSlashCommandProcessor = ( selectedAuthType: message.selectedAuthType, gcpProject: message.gcpProject, }; + } else if (message.type === MessageType.HELP) { + historyItemContent = { + type: 'help', + timestamp: message.timestamp, + }; } else if (message.type === MessageType.STATS) { historyItemContent = { type: 'stats', @@ -138,7 +142,6 @@ export const useSlashCommandProcessor = ( }, [addItem], ); - const commandContext = useMemo( (): CommandContext => ({ services: { @@ -333,9 +336,6 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; case 'dialog': switch (result.dialog) { - case 'help': - setShowHelp(true); - return { type: 'handled' }; case 'auth': openAuthDialog(); return { type: 'handled' }; @@ -462,7 +462,6 @@ export const useSlashCommandProcessor = ( [ config, addItem, - setShowHelp, openAuthDialog, commands, commandContext, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 085e3e96..062c1687 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -30,7 +30,6 @@ import { SlashCommandProcessorResult, StreamingState, } from '../types.js'; -import { Dispatch, SetStateAction } from 'react'; import { LoadedSettings } from '../../config/settings.js'; // --- MOCKS --- @@ -257,7 +256,6 @@ describe('mergePartListUnions', () => { // --- Tests for useGeminiStream Hook --- describe('useGeminiStream', () => { let mockAddItem: Mock; - let mockSetShowHelp: Mock; let mockConfig: Config; let mockOnDebugMessage: Mock; let mockHandleSlashCommand: Mock; @@ -269,7 +267,6 @@ describe('useGeminiStream', () => { vi.clearAllMocks(); // Clear mocks before each test mockAddItem = vi.fn(); - mockSetShowHelp = vi.fn(); // Define the mock for getGeminiClient const mockGetGeminiClient = vi.fn().mockImplementation(() => { // MockedGeminiClientClass is defined in the module scope by the previous change. @@ -382,7 +379,6 @@ describe('useGeminiStream', () => { client: any; history: HistoryItem[]; addItem: UseHistoryManagerReturn['addItem']; - setShowHelp: Dispatch>; config: Config; onDebugMessage: (message: string) => void; handleSlashCommand: ( @@ -400,7 +396,6 @@ describe('useGeminiStream', () => { props.client, props.history, props.addItem, - props.setShowHelp, props.config, props.onDebugMessage, props.handleSlashCommand, @@ -417,7 +412,6 @@ describe('useGeminiStream', () => { client, history: [], addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'], - setShowHelp: mockSetShowHelp, config: mockConfig, onDebugMessage: mockOnDebugMessage, handleSlashCommand: mockHandleSlashCommand as unknown as ( @@ -542,7 +536,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -610,7 +603,6 @@ describe('useGeminiStream', () => { client, [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -707,7 +699,6 @@ describe('useGeminiStream', () => { client, [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -810,7 +801,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1161,7 +1151,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1213,7 +1202,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(testConfig), [], mockAddItem, - mockSetShowHelp, testConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1262,7 +1250,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1309,7 +1296,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1357,7 +1343,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1445,7 +1430,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1500,7 +1484,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1577,7 +1560,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, @@ -1630,7 +1612,6 @@ describe('useGeminiStream', () => { new MockedGeminiClientClass(mockConfig), [], mockAddItem, - mockSetShowHelp, mockConfig, mockOnDebugMessage, mockHandleSlashCommand, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c934a139..e53e77dc 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -82,7 +82,6 @@ export const useGeminiStream = ( geminiClient: GeminiClient, history: HistoryItem[], addItem: UseHistoryManagerReturn['addItem'], - setShowHelp: React.Dispatch>, config: Config, onDebugMessage: (message: string) => void, handleSlashCommand: ( @@ -610,7 +609,6 @@ export const useGeminiStream = ( return; const userMessageTimestamp = Date.now(); - setShowHelp(false); // Reset quota error flag when starting a new query (not a continuation) if (!options?.isContinuation) { @@ -693,7 +691,6 @@ export const useGeminiStream = ( }, [ streamingState, - setShowHelp, setModelSwitchedFromQuotaError, prepareQueryForGemini, processGeminiStreamEvents, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index da95d6ec..6d078b22 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -97,6 +97,11 @@ export type HistoryItemAbout = HistoryItemBase & { gcpProject: string; }; +export type HistoryItemHelp = HistoryItemBase & { + type: 'help'; + timestamp: Date; +}; + export type HistoryItemStats = HistoryItemBase & { type: 'stats'; duration: string; @@ -142,6 +147,7 @@ export type HistoryItemWithoutId = | HistoryItemInfo | HistoryItemError | HistoryItemAbout + | HistoryItemHelp | HistoryItemToolGroup | HistoryItemStats | HistoryItemModelStats @@ -157,6 +163,7 @@ export enum MessageType { ERROR = 'error', USER = 'user', ABOUT = 'about', + HELP = 'help', STATS = 'stats', MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', @@ -183,6 +190,11 @@ export type Message = gcpProject: string; content?: string; // Optional content, not really used for ABOUT } + | { + type: MessageType.HELP; + timestamp: Date; + content?: string; // Optional content, not really used for HELP + } | { type: MessageType.STATS; timestamp: Date; From b9fe4fc263340c7a614e3e36462284b865e641c7 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 4 Aug 2025 10:49:15 -0700 Subject: [PATCH 085/136] feat(cli): Handle Punctuation in @ Command Parsing (#5482) --- .../src/ui/hooks/atCommandProcessor.test.ts | 394 +++++++++++++++++- .../cli/src/ui/hooks/atCommandProcessor.ts | 15 +- .../src/ui/hooks/useSlashCompletion.test.ts | 258 ++++++++++++ .../cli/src/ui/hooks/useSlashCompletion.tsx | 13 +- packages/core/src/utils/paths.test.ts | 214 ++++++++++ packages/core/src/utils/paths.ts | 38 +- 6 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/utils/paths.test.ts diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 2b4c81a3..583c0b2e 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -689,5 +689,397 @@ describe('handleAtCommand', () => { `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, ); }); - // }); + + describe('punctuation termination in @ commands', () => { + const punctuationTestCases = [ + { + name: 'comma', + fileName: 'test.txt', + fileContent: 'File content here', + queryTemplate: (filePath: string) => + `Look at @${filePath}, then explain it.`, + messageId: 400, + }, + { + name: 'period', + fileName: 'readme.md', + fileContent: 'File content here', + queryTemplate: (filePath: string) => + `Check @${filePath}. What does it say?`, + messageId: 401, + }, + { + name: 'semicolon', + fileName: 'example.js', + fileContent: 'Code example', + queryTemplate: (filePath: string) => + `Review @${filePath}; check for bugs.`, + messageId: 402, + }, + { + name: 'exclamation mark', + fileName: 'important.txt', + fileContent: 'Important content', + queryTemplate: (filePath: string) => + `Look at @${filePath}! This is critical.`, + messageId: 403, + }, + { + name: 'question mark', + fileName: 'config.json', + fileContent: 'Config settings', + queryTemplate: (filePath: string) => + `What is in @${filePath}? Please explain.`, + messageId: 404, + }, + { + name: 'opening parenthesis', + fileName: 'func.ts', + fileContent: 'Function definition', + queryTemplate: (filePath: string) => + `Analyze @${filePath}(the main function).`, + messageId: 405, + }, + { + name: 'closing parenthesis', + fileName: 'data.json', + fileContent: 'Test data', + queryTemplate: (filePath: string) => + `Use data from @${filePath}) for testing.`, + messageId: 406, + }, + { + name: 'opening square bracket', + fileName: 'array.js', + fileContent: 'Array data', + queryTemplate: (filePath: string) => + `Check @${filePath}[0] for the first element.`, + messageId: 407, + }, + { + name: 'closing square bracket', + fileName: 'list.md', + fileContent: 'List content', + queryTemplate: (filePath: string) => + `Review item @${filePath}] from the list.`, + messageId: 408, + }, + { + name: 'opening curly brace', + fileName: 'object.ts', + fileContent: 'Object definition', + queryTemplate: (filePath: string) => + `Parse @${filePath}{prop1: value1}.`, + messageId: 409, + }, + { + name: 'closing curly brace', + fileName: 'config.yaml', + fileContent: 'Configuration', + queryTemplate: (filePath: string) => + `Use settings from @${filePath}} for deployment.`, + messageId: 410, + }, + ]; + + it.each(punctuationTestCases)( + 'should terminate @path at $name', + async ({ fileName, fileContent, queryTemplate, messageId }) => { + const filePath = await createTestFile( + path.join(testRootDir, fileName), + fileContent, + ); + const query = queryTemplate(filePath); + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }, + ); + + it('should handle multiple @paths terminated by different punctuation', async () => { + const content1 = 'First file'; + const file1Path = await createTestFile( + path.join(testRootDir, 'first.txt'), + content1, + ); + const content2 = 'Second file'; + const file2Path = await createTestFile( + path.join(testRootDir, 'second.txt'), + content2, + ); + const query = `Compare @${file1Path}, @${file2Path}; what's different?`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 411, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Compare @${file1Path}, @${file2Path}; what's different?` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should still handle escaped spaces in paths before punctuation', async () => { + const fileContent = 'Spaced file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'spaced file.txt'), + fileContent, + ); + const escapedPath = path.join(testRootDir, 'spaced\\ file.txt'); + const query = `Check @${escapedPath}, it has spaces.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 412, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath}, it has spaces.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should not break file paths with periods in extensions', async () => { + const fileContent = 'TypeScript content'; + const filePath = await createTestFile( + path.join(testRootDir, 'example.d.ts'), + fileContent, + ); + const query = `Analyze @${filePath} for type definitions.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 413, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Analyze @${filePath} for type definitions.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle file paths ending with period followed by space', async () => { + const fileContent = 'Config content'; + const filePath = await createTestFile( + path.join(testRootDir, 'config.json'), + fileContent, + ); + const query = `Check @${filePath}. This file contains settings.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 414, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath}. This file contains settings.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle comma termination with complex file paths', async () => { + const fileContent = 'Package info'; + const filePath = await createTestFile( + path.join(testRootDir, 'package.json'), + fileContent, + ); + const query = `Review @${filePath}, then check dependencies.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 415, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Review @${filePath}, then check dependencies.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should not terminate at period within file name', async () => { + const fileContent = 'Version info'; + const filePath = await createTestFile( + path.join(testRootDir, 'version.1.2.3.txt'), + fileContent, + ); + const query = `Check @${filePath} contains version information.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 416, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} contains version information.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle end of string termination for period and comma', async () => { + const fileContent = 'End file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'end.txt'), + fileContent, + ); + const query = `Show me @${filePath}.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 417, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Show me @${filePath}.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle files with special characters in names', async () => { + const fileContent = 'File with special chars content'; + const filePath = await createTestFile( + path.join(testRootDir, 'file$with&special#chars.txt'), + fileContent, + ); + const query = `Check @${filePath} for content.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 418, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} for content.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + + it('should handle basic file names without special characters', async () => { + const fileContent = 'Basic file content'; + const filePath = await createTestFile( + path.join(testRootDir, 'basicfile.txt'), + fileContent, + ); + const query = `Check @${filePath} please.`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 421, + signal: abortController.signal, + }); + + expect(result).toEqual({ + processedQuery: [ + { text: `Check @${filePath} please.` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 7b9005fa..165b7b30 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -87,9 +87,17 @@ function parseAllAtCommands(query: string): AtCommandPart[] { inEscape = false; } else if (char === '\\') { inEscape = true; - } else if (/\s/.test(char)) { - // Path ends at first whitespace not escaped + } else if (/[,\s;!?()[\]{}]/.test(char)) { + // Path ends at first whitespace or punctuation not escaped break; + } else if (char === '.') { + // For . we need to be more careful - only terminate if followed by whitespace or end of string + // This allows file extensions like .txt, .js but terminates at sentence endings like "file.txt. Next sentence" + const nextChar = + pathEndIndex + 1 < query.length ? query[pathEndIndex + 1] : ''; + if (nextChar === '' || /\s/.test(nextChar)) { + break; + } } pathEndIndex++; } @@ -320,8 +328,7 @@ export async function handleAtCommand({ if ( i > 0 && initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - resolvedSpec + !initialQueryText.endsWith(' ') ) { // Add space if previous part was text and didn't end with space, or if previous was @path const prevPart = commandParts[i - 1]; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 13f8c240..206c4dc9 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1350,4 +1350,262 @@ describe('useSlashCompletion', () => { expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt'); }); }); + + describe('File Path Escaping', () => { + it('should escape special characters in file names', async () => { + await createTestFile('', 'my file.txt'); + await createTestFile('', 'file(1).txt'); + await createTestFile('', 'backup[old].txt'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file.txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('my\\ file.txt'); + }); + + it('should escape parentheses in file names', async () => { + await createTestFile('', 'document(final).docx'); + await createTestFile('', 'script(v2).sh'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@doc'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'document(final).docx', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('document\\(final\\).docx'); + }); + + it('should escape square brackets in file names', async () => { + await createTestFile('', 'backup[2024-01-01].zip'); + await createTestFile('', 'config[dev].json'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@backup'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'backup[2024-01-01].zip', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); + }); + + it('should escape multiple special characters in file names', async () => { + await createTestFile('', 'my file (backup) [v1.2].txt'); + await createTestFile('', 'data & config {prod}.json'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find( + (s) => s.label === 'my file (backup) [v1.2].txt', + ); + expect(suggestion).toBeDefined(); + expect(suggestion!.value).toBe( + 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', + ); + }); + + it('should preserve path separators while escaping special characters', async () => { + await createTestFile( + '', + 'projects', + 'my project (2024)', + 'file with spaces.txt', + ); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@projects/my'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('my project'), + ); + expect(suggestion).toBeDefined(); + // Should escape spaces and parentheses but preserve forward slashes + expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); + expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator + }); + + it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { + // Create test with complex nested structure + await createTestFile( + '', + 'deep', + 'nested', + 'special folder', + 'file with (parentheses).txt', + ); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@deep/nested/special'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestion = result.current.suggestions.find((s) => + s.label.includes('special folder'), + ); + expect(suggestion).toBeDefined(); + // Should use forward slashes for path separators and escape spaces + expect(suggestion!.value).toContain('special\\ folder/'); + expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators + }); + + it('should handle directory names with special characters', async () => { + await createEmptyDir('my documents (personal)'); + await createEmptyDir('config [production]'); + await createEmptyDir('data & logs'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const docSuggestion = suggestions.find( + (s) => s.label === 'my documents (personal)/', + ); + expect(docSuggestion).toBeDefined(); + expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); + + const configSuggestion = suggestions.find( + (s) => s.label === 'config [production]/', + ); + expect(configSuggestion).toBeDefined(); + expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); + + const dataSuggestion = suggestions.find( + (s) => s.label === 'data & logs/', + ); + expect(dataSuggestion).toBeDefined(); + expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); + }); + + it('should handle files with various shell metacharacters', async () => { + await createTestFile('', 'file$var.txt'); + await createTestFile('', 'important!.md'); + + const { result } = renderHook(() => + useSlashCompletion( + useTextBufferForTest('@'), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + const suggestions = result.current.suggestions; + + const dollarSuggestion = suggestions.find( + (s) => s.label === 'file$var.txt', + ); + expect(dollarSuggestion).toBeDefined(); + expect(dollarSuggestion!.value).toBe('file\\$var.txt'); + + const importantSuggestion = suggestions.find( + (s) => s.label === 'important!.md', + ); + expect(importantSuggestion).toBeDefined(); + expect(importantSuggestion!.value).toBe('important\\!.md'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx index f68d52d8..c6821358 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.tsx +++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx @@ -16,6 +16,7 @@ import { Config, FileDiscoveryService, DEFAULT_FILE_FILTERING_OPTIONS, + SHELL_SPECIAL_CHARS, } from '@google/gemini-cli-core'; import { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; @@ -513,11 +514,17 @@ export function useSlashCompletion( ]; } - // Like glob, we always return forwardslashes, even in windows. + // Like glob, we always return forward slashes for path separators, even on Windows. + // But preserve backslash escaping for special characters. + const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; + const pathSeparatorRegex = new RegExp( + `\\\\${specialCharsLookahead}`, + 'g', + ); fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ ...suggestion, - label: suggestion.label.replace(/\\/g, '/'), - value: suggestion.value.replace(/\\/g, '/'), + label: suggestion.label.replace(pathSeparatorRegex, '/'), + value: suggestion.value.replace(pathSeparatorRegex, '/'), })); // Sort by depth, then directories first, then alphabetically diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts new file mode 100644 index 00000000..d688c072 --- /dev/null +++ b/packages/core/src/utils/paths.test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { escapePath, unescapePath } from './paths.js'; + +describe('escapePath', () => { + it('should escape spaces', () => { + expect(escapePath('my file.txt')).toBe('my\\ file.txt'); + }); + + it('should escape tabs', () => { + expect(escapePath('file\twith\ttabs.txt')).toBe('file\\\twith\\\ttabs.txt'); + }); + + it('should escape parentheses', () => { + expect(escapePath('file(1).txt')).toBe('file\\(1\\).txt'); + }); + + it('should escape square brackets', () => { + expect(escapePath('file[backup].txt')).toBe('file\\[backup\\].txt'); + }); + + it('should escape curly braces', () => { + expect(escapePath('file{temp}.txt')).toBe('file\\{temp\\}.txt'); + }); + + it('should escape semicolons', () => { + expect(escapePath('file;name.txt')).toBe('file\\;name.txt'); + }); + + it('should escape ampersands', () => { + expect(escapePath('file&name.txt')).toBe('file\\&name.txt'); + }); + + it('should escape pipes', () => { + expect(escapePath('file|name.txt')).toBe('file\\|name.txt'); + }); + + it('should escape asterisks', () => { + expect(escapePath('file*.txt')).toBe('file\\*.txt'); + }); + + it('should escape question marks', () => { + expect(escapePath('file?.txt')).toBe('file\\?.txt'); + }); + + it('should escape dollar signs', () => { + expect(escapePath('file$name.txt')).toBe('file\\$name.txt'); + }); + + it('should escape backticks', () => { + expect(escapePath('file`name.txt')).toBe('file\\`name.txt'); + }); + + it('should escape single quotes', () => { + expect(escapePath("file'name.txt")).toBe("file\\'name.txt"); + }); + + it('should escape double quotes', () => { + expect(escapePath('file"name.txt')).toBe('file\\"name.txt'); + }); + + it('should escape hash symbols', () => { + expect(escapePath('file#name.txt')).toBe('file\\#name.txt'); + }); + + it('should escape exclamation marks', () => { + expect(escapePath('file!name.txt')).toBe('file\\!name.txt'); + }); + + it('should escape tildes', () => { + expect(escapePath('file~name.txt')).toBe('file\\~name.txt'); + }); + + it('should escape less than and greater than signs', () => { + expect(escapePath('file.txt')).toBe('file\\.txt'); + }); + + it('should handle multiple special characters', () => { + expect(escapePath('my file (backup) [v1.2].txt')).toBe( + 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', + ); + }); + + it('should not double-escape already escaped characters', () => { + expect(escapePath('my\\ file.txt')).toBe('my\\ file.txt'); + expect(escapePath('file\\(name\\).txt')).toBe('file\\(name\\).txt'); + }); + + it('should handle escaped backslashes correctly', () => { + // Double backslash (escaped backslash) followed by space should escape the space + expect(escapePath('path\\\\ file.txt')).toBe('path\\\\\\ file.txt'); + // Triple backslash (escaped backslash + escaping backslash) followed by space should not double-escape + expect(escapePath('path\\\\\\ file.txt')).toBe('path\\\\\\ file.txt'); + // Quadruple backslash (two escaped backslashes) followed by space should escape the space + expect(escapePath('path\\\\\\\\ file.txt')).toBe('path\\\\\\\\\\ file.txt'); + }); + + it('should handle complex escaped backslash scenarios', () => { + // Escaped backslash before special character that needs escaping + expect(escapePath('file\\\\(test).txt')).toBe('file\\\\\\(test\\).txt'); + // Multiple escaped backslashes + expect(escapePath('path\\\\\\\\with space.txt')).toBe( + 'path\\\\\\\\with\\ space.txt', + ); + }); + + it('should handle paths without special characters', () => { + expect(escapePath('normalfile.txt')).toBe('normalfile.txt'); + expect(escapePath('path/to/normalfile.txt')).toBe('path/to/normalfile.txt'); + }); + + it('should handle complex real-world examples', () => { + expect(escapePath('My Documents/Project (2024)/file [backup].txt')).toBe( + 'My\\ Documents/Project\\ \\(2024\\)/file\\ \\[backup\\].txt', + ); + expect(escapePath('file with $special &chars!.txt')).toBe( + 'file\\ with\\ \\$special\\ \\&chars\\!.txt', + ); + }); + + it('should handle empty strings', () => { + expect(escapePath('')).toBe(''); + }); + + it('should handle paths with only special characters', () => { + expect(escapePath(' ()[]{};&|*?$`\'"#!~<>')).toBe( + '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>', + ); + }); +}); + +describe('unescapePath', () => { + it('should unescape spaces', () => { + expect(unescapePath('my\\ file.txt')).toBe('my file.txt'); + }); + + it('should unescape tabs', () => { + expect(unescapePath('file\\\twith\\\ttabs.txt')).toBe( + 'file\twith\ttabs.txt', + ); + }); + + it('should unescape parentheses', () => { + expect(unescapePath('file\\(1\\).txt')).toBe('file(1).txt'); + }); + + it('should unescape square brackets', () => { + expect(unescapePath('file\\[backup\\].txt')).toBe('file[backup].txt'); + }); + + it('should unescape curly braces', () => { + expect(unescapePath('file\\{temp\\}.txt')).toBe('file{temp}.txt'); + }); + + it('should unescape multiple special characters', () => { + expect(unescapePath('my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt')).toBe( + 'my file (backup) [v1.2].txt', + ); + }); + + it('should handle paths without escaped characters', () => { + expect(unescapePath('normalfile.txt')).toBe('normalfile.txt'); + expect(unescapePath('path/to/normalfile.txt')).toBe( + 'path/to/normalfile.txt', + ); + }); + + it('should handle all special characters', () => { + expect( + unescapePath( + '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>', + ), + ).toBe(' ()[]{};&|*?$`\'"#!~<>'); + }); + + it('should be the inverse of escapePath', () => { + const testCases = [ + 'my file.txt', + 'file(1).txt', + 'file[backup].txt', + 'My Documents/Project (2024)/file [backup].txt', + 'file with $special &chars!.txt', + ' ()[]{};&|*?$`\'"#!~<>', + 'file\twith\ttabs.txt', + ]; + + testCases.forEach((testCase) => { + expect(unescapePath(escapePath(testCase))).toBe(testCase); + }); + }); + + it('should handle empty strings', () => { + expect(unescapePath('')).toBe(''); + }); + + it('should not affect backslashes not followed by special characters', () => { + expect(unescapePath('file\\name.txt')).toBe('file\\name.txt'); + expect(unescapePath('path\\to\\file.txt')).toBe('path\\to\\file.txt'); + }); + + it('should handle escaped backslashes in unescaping', () => { + // Should correctly unescape when there are escaped backslashes + expect(unescapePath('path\\\\\\ file.txt')).toBe('path\\\\ file.txt'); + expect(unescapePath('path\\\\\\\\\\ file.txt')).toBe( + 'path\\\\\\\\ file.txt', + ); + expect(unescapePath('file\\\\\\(test\\).txt')).toBe('file\\\\(test).txt'); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 5370a7cb..7bab888e 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -13,6 +13,13 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; const TMP_DIR_NAME = 'tmp'; const COMMANDS_DIR_NAME = 'commands'; +/** + * Special characters that need to be escaped in file paths for shell compatibility. + * Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes, + * asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters. + */ +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; + /** * Replaces the home directory with a tilde. * @param path - The path to tildeify. @@ -119,26 +126,43 @@ export function makeRelative( } /** - * Escapes spaces in a file path. + * Escapes special characters in a file path like macOS terminal does. + * Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes, + * asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters. */ export function escapePath(filePath: string): string { let result = ''; for (let i = 0; i < filePath.length; i++) { - // Only escape spaces that are not already escaped. - if (filePath[i] === ' ' && (i === 0 || filePath[i - 1] !== '\\')) { - result += '\\ '; + const char = filePath[i]; + + // Count consecutive backslashes before this character + let backslashCount = 0; + for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) { + backslashCount++; + } + + // Character is already escaped if there's an odd number of backslashes before it + const isAlreadyEscaped = backslashCount % 2 === 1; + + // Only escape if not already escaped + if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) { + result += '\\' + char; } else { - result += filePath[i]; + result += char; } } return result; } /** - * Unescapes spaces in a file path. + * Unescapes special characters in a file path. + * Removes backslash escaping from shell metacharacters. */ export function unescapePath(filePath: string): string { - return filePath.replace(/\\ /g, ' '); + return filePath.replace( + new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'), + '$1', + ); } /** From d1bfba1abbd09e4c70220bc9d82f10b6f61c5b28 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 4 Aug 2025 11:30:59 -0700 Subject: [PATCH 086/136] feat(core): Add trailing space when completing an at completion suggestion (#5475) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../cli/src/ui/hooks/useSlashCompletion.test.ts | 15 ++++++++++----- packages/cli/src/ui/hooks/useSlashCompletion.tsx | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 206c4dc9..da4dc87b 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -7,7 +7,7 @@ /** @vitest-environment jsdom */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { useSlashCompletion } from './useSlashCompletion.js'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -136,7 +136,7 @@ describe('useSlashCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should reset all state to default values', () => { + it('should reset all state to default values', async () => { const slashCommands = [ { name: 'help', @@ -165,6 +165,11 @@ describe('useSlashCompletion', () => { result.current.resetCompletionState(); }); + // Wait for async suggestions clearing + await waitFor(() => { + expect(result.current.suggestions).toEqual([]); + }); + expect(result.current.suggestions).toEqual([]); expect(result.current.activeSuggestionIndex).toBe(-1); expect(result.current.visibleStartIndex).toBe(0); @@ -1288,7 +1293,7 @@ describe('useSlashCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('@src/file1.txt'); + expect(result.current.textBuffer.text).toBe('@src/file1.txt '); }); it('should complete a file path when cursor is not at the end of the line', () => { @@ -1318,7 +1323,7 @@ describe('useSlashCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); + expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); }); it('should complete the correct file path with multiple @-commands', () => { @@ -1347,7 +1352,7 @@ describe('useSlashCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt'); + expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt '); }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx index c6821358..3b59bd45 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.tsx +++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx @@ -111,7 +111,7 @@ export function useSlashCompletion( useEffect(() => { if (commandIndex === -1 || reverseSearchActive) { - resetCompletionState(); + setTimeout(resetCompletionState, 0); return; } @@ -631,17 +631,17 @@ export function useSlashCompletion( ) { suggestionText = ' ' + suggestionText; } - suggestionText += ' '; } + suggestionText += ' '; + buffer.replaceRangeByOffset( logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), suggestionText, ); - resetCompletionState(); }, - [cursorRow, resetCompletionState, buffer, suggestions, commandIndex], + [cursorRow, buffer, suggestions, commandIndex], ); return { From 5caf23d627829a28adb78f038170689155b17150 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Mon, 4 Aug 2025 12:12:33 -0700 Subject: [PATCH 087/136] remove unnecessary checks in WriteFileChecks.getDescription (#5526) --- packages/core/src/tools/write-file.test.ts | 28 +++++++++++++++++++++- packages/core/src/tools/write-file.ts | 4 ++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 965e9476..fe662a02 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -13,7 +13,7 @@ import { vi, type Mocked, } from 'vitest'; -import { WriteFileTool } from './write-file.js'; +import { WriteFileTool, WriteFileToolParams } from './write-file.js'; import { FileDiff, ToolConfirmationOutcome, @@ -202,6 +202,32 @@ describe('WriteFileTool', () => { `Path is a directory, not a file: ${dirAsFilePath}`, ); }); + + it('should return error if the content is null', () => { + const dirAsFilePath = path.join(rootDir, 'a_directory'); + fs.mkdirSync(dirAsFilePath); + const params = { + file_path: dirAsFilePath, + content: null, + } as unknown as WriteFileToolParams; // Intentionally non-conforming + expect(tool.validateToolParams(params)).toMatch( + `params/content must be string`, + ); + }); + }); + + describe('getDescription', () => { + it('should return error if the file_path is empty', () => { + const dirAsFilePath = path.join(rootDir, 'a_directory'); + fs.mkdirSync(dirAsFilePath); + const params = { + file_path: '', + content: '', + }; + expect(tool.getDescription(params)).toMatch( + `Model did not provide valid parameters for write file tool, missing or empty "file_path"`, + ); + }); }); describe('_getCorrectedFileContent', () => { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 470adf31..1cb1a917 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -131,8 +131,8 @@ export class WriteFileTool } getDescription(params: WriteFileToolParams): string { - if (!params.file_path || !params.content) { - return `Model did not provide valid parameters for write file tool`; + if (!params.file_path) { + return `Model did not provide valid parameters for write file tool, missing or empty "file_path"`; } const relativePath = makeRelative( params.file_path, From 37b83e05a71690d4d8f72f6bddb63435c31a5a01 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Mon, 4 Aug 2025 16:10:36 -0400 Subject: [PATCH 088/136] Use new URLs for downloading workflows (#5524) --- packages/cli/src/ui/commands/setupGithubCommand.test.ts | 2 +- packages/cli/src/ui/commands/setupGithubCommand.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index fe68be0c..7c654149 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -49,7 +49,7 @@ describe('setupGithubCommand', () => { `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`, `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`, `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`, - 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/main/workflows/', + 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/v0/examples/workflows/', ]; for (const substring of expectedSubstrings) { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 14314423..9dd12292 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -27,9 +27,8 @@ export const setupGithubCommand: SlashCommand = { throw new Error('Unable to determine the Git root directory.'); } - // TODO(#5198): pin workflow versions for release controls - const version = 'main'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/workflows/`; + const version = 'v0'; + const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`; const workflows = [ 'gemini-cli/gemini-cli.yml', From 8da6d23688646dde2011fc3577faea1093077a3e Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 4 Aug 2025 13:35:26 -0700 Subject: [PATCH 089/136] refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) --- .../src/ui/components/InputPrompt.test.tsx | 176 +++++++++--------- .../cli/src/ui/components/InputPrompt.tsx | 4 +- ...n.test.ts => useCommandCompletion.test.ts} | 96 +++++----- ...ompletion.tsx => useCommandCompletion.tsx} | 6 +- 4 files changed, 141 insertions(+), 141 deletions(-) rename packages/cli/src/ui/hooks/{useSlashCompletion.test.ts => useCommandCompletion.test.ts} (96%) rename packages/cli/src/ui/hooks/{useSlashCompletion.tsx => useCommandCompletion.tsx} (99%) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 6b7bc7ce..2291b5a1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -20,9 +20,9 @@ import { UseShellHistoryReturn, } from '../hooks/useShellHistory.js'; import { - useSlashCompletion, - UseSlashCompletionReturn, -} from '../hooks/useSlashCompletion.js'; + useCommandCompletion, + UseCommandCompletionReturn, +} from '../hooks/useCommandCompletion.js'; import { useInputHistory, UseInputHistoryReturn, @@ -31,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; vi.mock('../hooks/useShellHistory.js'); -vi.mock('../hooks/useSlashCompletion.js'); +vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useInputHistory.js'); vi.mock('../utils/clipboardUtils.js'); @@ -86,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [ describe('InputPrompt', () => { let props: InputPromptProps; let mockShellHistory: UseShellHistoryReturn; - let mockSlashCompletion: UseSlashCompletionReturn; + let mockCommandCompletion: UseCommandCompletionReturn; let mockInputHistory: UseInputHistoryReturn; let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; const mockedUseShellHistory = vi.mocked(useShellHistory); - const mockedUseSlashCompletion = vi.mocked(useSlashCompletion); + const mockedUseCommandCompletion = vi.mocked(useCommandCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); beforeEach(() => { @@ -146,7 +146,7 @@ describe('InputPrompt', () => { }; mockedUseShellHistory.mockReturnValue(mockShellHistory); - mockSlashCompletion = { + mockCommandCompletion = { suggestions: [], activeSuggestionIndex: -1, isLoadingSuggestions: false, @@ -160,7 +160,7 @@ describe('InputPrompt', () => { setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), }; - mockedUseSlashCompletion.mockReturnValue(mockSlashCompletion); + mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); mockInputHistory = { navigateUp: vi.fn(), @@ -271,8 +271,8 @@ describe('InputPrompt', () => { }); it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -291,15 +291,15 @@ describe('InputPrompt', () => { stdin.write('\u0010'); // Ctrl+P await wait(); - expect(mockSlashCompletion.navigateUp).toHaveBeenCalledTimes(2); - expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2); + expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'memory', value: 'memory' }, @@ -317,15 +317,15 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockSlashCompletion.navigateDown).toHaveBeenCalledTimes(2); - expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2); + expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); unmount(); }); it('should NOT call completion navigation when suggestions are not showing', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, }); props.buffer.setText('some text'); @@ -342,8 +342,8 @@ describe('InputPrompt', () => { stdin.write('\u000E'); // Ctrl+N await wait(); - expect(mockSlashCompletion.navigateUp).not.toHaveBeenCalled(); - expect(mockSlashCompletion.navigateDown).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); unmount(); }); @@ -472,8 +472,8 @@ describe('InputPrompt', () => { it('should complete a partial parent command', async () => { // SCENARIO: /mem -> Tab - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory', description: '...' }], activeSuggestionIndex: 0, @@ -486,14 +486,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should append a sub-command when the parent command is already complete', async () => { // SCENARIO: /memory -> Tab (to accept 'add') - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -509,14 +509,14 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); unmount(); }); it('should handle the "backspace" edge case correctly', async () => { // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show') - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'show', value: 'show' }, @@ -534,14 +534,14 @@ describe('InputPrompt', () => { await wait(); // It should NOT become '/show'. It should correctly become '/memory show'. - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should complete a partial argument for a command', async () => { // SCENARIO: /chat resume fi- -> Tab - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'fix-foo', value: 'fix-foo' }], activeSuggestionIndex: 0, @@ -554,13 +554,13 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); it('should autocomplete on Enter when suggestions are active, without submitting', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'memory', value: 'memory' }], activeSuggestionIndex: 0, @@ -574,7 +574,7 @@ describe('InputPrompt', () => { await wait(); // The app should autocomplete the text, NOT submit. - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); @@ -590,8 +590,8 @@ describe('InputPrompt', () => { }, ]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'help', value: 'help' }], activeSuggestionIndex: 0, @@ -604,7 +604,7 @@ describe('InputPrompt', () => { stdin.write('\t'); // Press Tab for autocomplete await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); unmount(); }); @@ -622,8 +622,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when isPerfectMatch is true', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, isPerfectMatch: true, }); @@ -640,8 +640,8 @@ describe('InputPrompt', () => { }); it('should submit directly on Enter when a complete leaf command is typed', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, isPerfectMatch: false, // Added explicit isPerfectMatch false }); @@ -658,8 +658,8 @@ describe('InputPrompt', () => { }); it('should autocomplete an @-path on Enter without submitting', async () => { - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'index.ts', value: 'index.ts' }], activeSuggestionIndex: 0, @@ -672,7 +672,7 @@ describe('InputPrompt', () => { stdin.write('\r'); await wait(); - expect(mockSlashCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -704,7 +704,7 @@ describe('InputPrompt', () => { await wait(); expect(props.buffer.setText).toHaveBeenCalledWith(''); - expect(mockSlashCompletion.resetCompletionState).toHaveBeenCalled(); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); expect(props.onSubmit).not.toHaveBeenCalled(); unmount(); }); @@ -728,8 +728,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/components']; mockBuffer.cursor = [0, 15]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }], }); @@ -738,7 +738,7 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with correct signature - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -756,8 +756,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -765,7 +765,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -783,8 +783,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file.ts hello']; mockBuffer.cursor = [0, 18]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -792,7 +792,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -810,8 +810,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory add']; mockBuffer.cursor = [0, 11]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -819,7 +819,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -837,8 +837,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['hello world']; mockBuffer.cursor = [0, 5]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -846,7 +846,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -864,8 +864,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['first line', '/memory']; mockBuffer.cursor = [1, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -874,7 +874,7 @@ describe('InputPrompt', () => { await wait(); // Verify useCompletion was called with the buffer - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -892,8 +892,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory']; mockBuffer.cursor = [0, 7]; - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'show', value: 'show' }], }); @@ -901,7 +901,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -920,8 +920,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file👍.txt']; mockBuffer.cursor = [0, 14]; // After the emoji character - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }], }); @@ -929,7 +929,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -948,8 +948,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/file👍.txt hello']; mockBuffer.cursor = [0, 20]; // After the space - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -957,7 +957,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -976,8 +976,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@src/my\\ file.txt']; mockBuffer.cursor = [0, 16]; // After the escaped space and filename - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'my file.txt', value: 'my file.txt' }], }); @@ -985,7 +985,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1004,8 +1004,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@path/my\\ file.txt hello']; mockBuffer.cursor = [0, 24]; // After "hello" - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: false, suggestions: [], }); @@ -1013,7 +1013,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1032,8 +1032,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md']; mockBuffer.cursor = [0, 29]; // At the end - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'my long file name.md', value: 'my long file name.md' }, @@ -1043,7 +1043,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1062,8 +1062,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['/memory\\ test']; mockBuffer.cursor = [0, 13]; // At the end - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [{ label: 'test-command', value: 'test-command' }], }); @@ -1071,7 +1071,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), @@ -1090,8 +1090,8 @@ describe('InputPrompt', () => { mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')]; mockBuffer.cursor = [0, 25]; // After the escaped space and emoji - mockedUseSlashCompletion.mockReturnValue({ - ...mockSlashCompletion, + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, showSuggestions: true, suggestions: [ { label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' }, @@ -1101,7 +1101,7 @@ describe('InputPrompt', () => { const { unmount } = render(); await wait(); - expect(mockedUseSlashCompletion).toHaveBeenCalledWith( + expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], path.join('test', 'project', 'src'), diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index db4eec1b..b405b684 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -15,7 +15,7 @@ import chalk from 'chalk'; import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; -import { useSlashCompletion } from '../hooks/useSlashCompletion.js'; +import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import { useKeypress, Key } from '../hooks/useKeypress.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config } from '@google/gemini-cli-core'; @@ -78,7 +78,7 @@ export const InputPrompt: React.FC = ({ const shellHistory = useShellHistory(config.getProjectRoot()); const historyData = shellHistory.history; - const completion = useSlashCompletion( + const completion = useCommandCompletion( buffer, dirs, config.getTargetDir(), diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts similarity index 96% rename from packages/cli/src/ui/hooks/useSlashCompletion.test.ts rename to packages/cli/src/ui/hooks/useCommandCompletion.test.ts index da4dc87b..005b4e7d 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; -import { useSlashCompletion } from './useSlashCompletion.js'; +import { useCommandCompletion } from './useCommandCompletion.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -16,7 +16,7 @@ import { CommandContext, SlashCommand } from '../commands/types.js'; import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; -describe('useSlashCompletion', () => { +describe('useCommandCompletion', () => { let testRootDir: string; let mockConfig: Config; @@ -82,7 +82,7 @@ describe('useSlashCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest(''), testDirs, testRootDir, @@ -113,7 +113,7 @@ describe('useSlashCompletion', () => { const { result, rerender } = renderHook( ({ text }) => { const textBuffer = useTextBufferForTest(text); - return useSlashCompletion( + return useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -145,7 +145,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/help'), testDirs, testRootDir, @@ -184,7 +184,7 @@ describe('useSlashCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest(''), testDirs, testRootDir, @@ -207,7 +207,7 @@ describe('useSlashCompletion', () => { { name: 'dummy', description: 'dummy' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest(''), testDirs, testRootDir, @@ -234,7 +234,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/h'), testDirs, testRootDir, @@ -264,7 +264,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/h'), testDirs, testRootDir, @@ -295,7 +295,7 @@ describe('useSlashCompletion', () => { { name: 'chat', description: 'Manage chat' }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, @@ -343,7 +343,7 @@ describe('useSlashCompletion', () => { })) as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/command'), testDirs, testRootDir, @@ -403,7 +403,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, @@ -426,7 +426,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/mem'), testDirs, testRootDir, @@ -450,7 +450,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/usag'), // part of the word "usage" testDirs, testRootDir, @@ -477,7 +477,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/clear'), // No trailing space testDirs, testRootDir, @@ -509,7 +509,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest(query), testDirs, testRootDir, @@ -530,7 +530,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/clear '), testDirs, testRootDir, @@ -551,7 +551,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/unknown-command'), testDirs, testRootDir, @@ -585,7 +585,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/memory'), // Note: no trailing space testDirs, testRootDir, @@ -623,7 +623,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/memory'), testDirs, testRootDir, @@ -659,7 +659,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/memory a'), testDirs, testRootDir, @@ -691,7 +691,7 @@ describe('useSlashCompletion', () => { }, ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/memory dothisnow'), testDirs, testRootDir, @@ -734,7 +734,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/chat resume my-ch'), testDirs, testRootDir, @@ -778,7 +778,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/chat resume '), testDirs, testRootDir, @@ -813,7 +813,7 @@ describe('useSlashCompletion', () => { ] as unknown as SlashCommand[]; const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('/chat resume '), testDirs, testRootDir, @@ -843,7 +843,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@s'), testDirs, testRootDir, @@ -879,7 +879,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'src', 'index.ts'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@src/comp'), testDirs, testRootDir, @@ -907,7 +907,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'src', 'index.ts'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@.'), testDirs, testRootDir, @@ -941,7 +941,7 @@ describe('useSlashCompletion', () => { await createEmptyDir('dist'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@d'), testDirs, testRootDir, @@ -969,7 +969,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@'), testDirs, testRootDir, @@ -1004,7 +1004,7 @@ describe('useSlashCompletion', () => { .mockImplementation(() => {}); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@'), testDirs, testRootDir, @@ -1037,7 +1037,7 @@ describe('useSlashCompletion', () => { await createEmptyDir('data'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@d'), testDirs, testRootDir, @@ -1073,7 +1073,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'README.md'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@'), testDirs, testRootDir, @@ -1108,7 +1108,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'temp', 'temp.log'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@t'), testDirs, testRootDir, @@ -1153,7 +1153,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/mem'); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1197,7 +1197,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/memory'); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1243,7 +1243,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/?'); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1272,7 +1272,7 @@ describe('useSlashCompletion', () => { it('should complete a file path', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('@src/fi'); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1302,7 +1302,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest(text, cursorOffset); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1331,7 +1331,7 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest(text); - const completion = useSlashCompletion( + const completion = useCommandCompletion( textBuffer, testDirs, testRootDir, @@ -1363,7 +1363,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'backup[old].txt'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@my'), testDirs, testRootDir, @@ -1390,7 +1390,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'script(v2).sh'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@doc'), testDirs, testRootDir, @@ -1417,7 +1417,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'config[dev].json'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@backup'), testDirs, testRootDir, @@ -1444,7 +1444,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'data & config {prod}.json'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@my'), testDirs, testRootDir, @@ -1477,7 +1477,7 @@ describe('useSlashCompletion', () => { ); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@projects/my'), testDirs, testRootDir, @@ -1512,7 +1512,7 @@ describe('useSlashCompletion', () => { ); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@deep/nested/special'), testDirs, testRootDir, @@ -1542,7 +1542,7 @@ describe('useSlashCompletion', () => { await createEmptyDir('data & logs'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@'), testDirs, testRootDir, @@ -1583,7 +1583,7 @@ describe('useSlashCompletion', () => { await createTestFile('', 'important!.md'); const { result } = renderHook(() => - useSlashCompletion( + useCommandCompletion( useTextBufferForTest('@'), testDirs, testRootDir, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx similarity index 99% rename from packages/cli/src/ui/hooks/useSlashCompletion.tsx rename to packages/cli/src/ui/hooks/useCommandCompletion.tsx index 3b59bd45..9227be39 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -28,7 +28,7 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useCompletion } from './useCompletion.js'; -export interface UseSlashCompletionReturn { +export interface UseCommandCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; visibleStartIndex: number; @@ -43,7 +43,7 @@ export interface UseSlashCompletionReturn { handleAutocomplete: (indexToUse: number) => void; } -export function useSlashCompletion( +export function useCommandCompletion( buffer: TextBuffer, dirs: readonly string[], cwd: string, @@ -51,7 +51,7 @@ export function useSlashCompletion( commandContext: CommandContext, reverseSearchActive: boolean = false, config?: Config, -): UseSlashCompletionReturn { +): UseCommandCompletionReturn { const { suggestions, activeSuggestionIndex, From 11808ef7ed3735b848ed23ef8b3eb0f8cdb95775 Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Mon, 4 Aug 2025 16:41:58 -0400 Subject: [PATCH 090/136] fix(core): Allow model to be set from `settings.json` (#5527) --- packages/cli/src/config/config.test.ts | 62 ++++++++++++++++++++++++++ packages/cli/src/config/config.ts | 4 +- packages/cli/src/config/settings.ts | 2 + 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index d8d463c2..431b1375 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -917,6 +917,68 @@ describe('loadCliConfig extensions', () => { }); }); +describe('loadCliConfig model selection', () => { + it('selects a model from settings.json if provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { + model: 'gemini-9001-ultra', + }, + [], + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-9001-ultra'); + }); + + it('uses the default gemini model if nothing is set', async () => { + process.argv = ['node', 'script.js']; // No model set. + const argv = await parseArguments(); + const config = await loadCliConfig( + { + // No model set. + }, + [], + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-2.5-pro'); + }); + + it('always prefers model from argvs', async () => { + process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { + model: 'gemini-9001-ultra', + }, + [], + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-8675309-ultra'); + }); + + it('selects the model from argvs if provided', async () => { + process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { + // No model provided via settings. + }, + [], + 'test-session', + argv, + ); + + expect(config.getModel()).toBe('gemini-8675309-ultra'); + }); +}); + describe('loadCliConfig ideModeFeature', () => { const originalArgv = process.argv; const originalEnv = { ...process.env }; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9274b65e..38d59a4f 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -79,7 +79,7 @@ export async function parseArguments(): Promise { alias: 'm', type: 'string', description: `Model`, - default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL, + default: process.env.GEMINI_MODEL, }) .option('prompt', { alias: 'p', @@ -444,7 +444,7 @@ export async function loadCliConfig( cwd: process.cwd(), fileDiscoveryService: fileService, bugCommand: settings.bugCommand, - model: argv.model!, + model: argv.model || settings.model || DEFAULT_GEMINI_MODEL, extensionContextFilePaths, maxSessionTurns: settings.maxSessionTurns ?? -1, experimentalAcp: argv.experimentalAcp || false, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 05d4313f..20a7b14a 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -86,6 +86,8 @@ export interface Settings { bugCommand?: BugCommandSettings; checkpointing?: CheckpointingSettings; autoConfigureMaxOldSpaceSize?: boolean; + /** The model name to use (e.g 'gemini-9.0-pro') */ + model?: string; // Git-aware file filtering settings fileFiltering?: { From 2180dd13dc580db4cef77b39aa69eaa8017530ea Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 4 Aug 2025 17:06:17 -0400 Subject: [PATCH 091/136] Improve user-facing error messages for IDE mode (#5522) --- .../cli/src/ui/commands/ideCommand.test.ts | 9 +++-- packages/cli/src/ui/commands/ideCommand.ts | 36 +++++++++++++------ packages/core/src/ide/detect-ide.ts | 9 +++-- packages/core/src/ide/ide-installer.test.ts | 30 +--------------- packages/core/src/ide/ide-installer.ts | 13 +++---- 5 files changed, 43 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 3c73549c..4f2b7af2 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -65,6 +65,7 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, + getDetectedIdeDisplayName: () => 'VS Code', } as ReturnType); const command = ideCommand(mockConfig); expect(command).not.toBeNull(); @@ -82,6 +83,7 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getConnectionStatus: mockGetConnectionStatus, getCurrentIde: () => DetectedIde.VSCode, + getDetectedIdeDisplayName: () => 'VS Code', } as unknown as ReturnType); }); @@ -96,7 +98,7 @@ describe('ideCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: '🟢 Connected', + content: '🟢 Connected to VS Code', }); }); @@ -155,6 +157,7 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getCurrentIde: () => DetectedIde.VSCode, getConnectionStatus: vi.fn(), + getDetectedIdeDisplayName: () => 'VS Code', } as unknown as ReturnType); vi.mocked(core.getIdeInstaller).mockReturnValue({ install: mockInstall, @@ -180,7 +183,7 @@ describe('ideCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: `Installing IDE companion extension...`, + text: `Installing IDE companion...`, }), expect.any(Number), ); @@ -210,7 +213,7 @@ describe('ideCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: `Installing IDE companion extension...`, + text: `Installing IDE companion...`, }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 1da7d6b0..c6d65264 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -6,6 +6,7 @@ import { Config, + DetectedIde, IDEConnectionStatus, getIdeDisplayName, getIdeInstaller, @@ -19,12 +20,27 @@ import { import { SettingScope } from '../../config/settings.js'; export const ideCommand = (config: Config | null): SlashCommand | null => { - if (!config?.getIdeModeFeature()) { + if (!config || !config.getIdeModeFeature()) { return null; } - const currentIDE = config.getIdeClient().getCurrentIde(); - if (!currentIDE) { - return null; + const ideClient = config.getIdeClient(); + const currentIDE = ideClient.getCurrentIde(); + if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) { + return { + name: 'ide', + description: 'manage IDE integration', + kind: CommandKind.BUILT_IN, + action: (): SlashCommandActionReturn => + ({ + type: 'message', + messageType: 'error', + content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values( + DetectedIde, + ) + .map((ide) => getIdeDisplayName(ide)) + .join(', ')}`, + }) as const, + }; } const ideSlashCommand: SlashCommand = { @@ -39,13 +55,13 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = config.getIdeClient().getConnectionStatus(); - switch (connection?.status) { + const connection = ideClient.getConnectionStatus(); + switch (connection.status) { case IDEConnectionStatus.Connected: return { type: 'message', messageType: 'info', - content: `🟢 Connected`, + content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, } as const; case IDEConnectionStatus.Connecting: return { @@ -70,7 +86,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { const installCommand: SlashCommand = { name: 'install', - description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `, + description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`, kind: CommandKind.BUILT_IN, action: async (context) => { const installer = getIdeInstaller(currentIDE); @@ -78,7 +94,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { context.ui.addItem( { type: 'error', - text: 'No installer available for your configured IDE.', + text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`, }, Date.now(), ); @@ -88,7 +104,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { context.ui.addItem( { type: 'info', - text: `Installing IDE companion extension...`, + text: `Installing IDE companion...`, }, Date.now(), ); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index ae46789e..f3d8cc63 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -11,9 +11,12 @@ export enum DetectedIde { export function getIdeDisplayName(ide: DetectedIde): string { switch (ide) { case DetectedIde.VSCode: - return 'VSCode'; - default: - throw new Error(`Unsupported IDE: ${ide}`); + return 'VS Code'; + default: { + // This ensures that if a new IDE is added to the enum, we get a compile-time error. + const exhaustiveCheck: never = ide; + return exhaustiveCheck; + } } } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index 83459d6b..698c3173 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -45,32 +45,6 @@ describe('ide-installer', () => { vi.restoreAllMocks(); }); - describe('isInstalled', () => { - it('should return true if command is in PATH', async () => { - expect(await installer.isInstalled()).toBe(true); - }); - - it('should return true if command is in a known location', async () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error('Command not found'); - }); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - // Re-create the installer so it re-runs findVsCodeCommand - installer = getIdeInstaller(DetectedIde.VSCode)!; - expect(await installer.isInstalled()).toBe(true); - }); - - it('should return false if command is not found', async () => { - vi.spyOn(child_process, 'execSync').mockImplementation(() => { - throw new Error('Command not found'); - }); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - // Re-create the installer so it re-runs findVsCodeCommand - installer = getIdeInstaller(DetectedIde.VSCode)!; - expect(await installer.isInstalled()).toBe(false); - }); - }); - describe('install', () => { it('should return a failure message if VS Code is not installed', async () => { vi.spyOn(child_process, 'execSync').mockImplementation(() => { @@ -81,9 +55,7 @@ describe('ide-installer', () => { installer = getIdeInstaller(DetectedIde.VSCode)!; const result = await installer.install(); expect(result.success).toBe(false); - expect(result.message).toContain( - 'not found in your PATH or common installation locations', - ); + expect(result.message).toContain('VS Code CLI not found'); }); }); }); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index 725f4f7c..7db8e2d2 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -18,7 +18,6 @@ const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion'; export interface IdeInstaller { install(): Promise; - isInstalled(): Promise; } export interface InstallResult { @@ -95,16 +94,12 @@ class VsCodeInstaller implements IdeInstaller { this.vsCodeCommand = findVsCodeCommand(); } - async isInstalled(): Promise { - return (await this.vsCodeCommand) !== null; - } - async install(): Promise { const commandPath = await this.vsCodeCommand; if (!commandPath) { return { success: false, - message: `VS Code command-line tool not found in your PATH or common installation locations.`, + message: `VS Code CLI not found. Please ensure 'code' is in your system's PATH. For help, see https://code.visualstudio.com/docs/configure/command-line#_code-is-not-recognized-as-an-internal-or-external-command. You can also install the companion extension manually from the VS Code marketplace.`, }; } @@ -141,12 +136,12 @@ class VsCodeInstaller implements IdeInstaller { return { success: true, message: - 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', + 'VS Code companion extension was installed successfully. Please restart your terminal to complete the setup.', }; } catch (_error) { return { success: false, - message: 'Failed to install VS Code companion extension.', + message: `Failed to install VS Code companion extension. Please try installing it manually from the VS Code marketplace.`, }; } } @@ -154,7 +149,7 @@ class VsCodeInstaller implements IdeInstaller { export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null { switch (ide) { - case 'vscode': + case DetectedIde.VSCode: return new VsCodeInstaller(); default: return null; From dca040908affc6277884514b4c726365359fd10b Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 4 Aug 2025 17:06:50 -0400 Subject: [PATCH 092/136] ide-mode flag cleanup (#5531) --- packages/cli/src/config/config.ts | 5 +---- packages/core/src/config/config.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 38d59a4f..0395ac0f 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -62,7 +62,6 @@ export interface CliArgs { experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; - ideMode?: boolean | undefined; ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; @@ -287,9 +286,7 @@ export async function loadCliConfig( ) || false; const memoryImportFormat = settings.memoryImportFormat || 'tree'; - const ideMode = - (argv.ideMode ?? settings.ideMode ?? false) && - process.env.TERM_PROGRAM === 'vscode'; + const ideMode = settings.ideMode ?? false; const ideModeFeature = (argv.ideModeFeature ?? settings.ideModeFeature ?? false) && diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e94e8421..3f5c11a0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -302,7 +302,7 @@ export class Config { this.noBrowser = params.noBrowser ?? false; this.summarizeToolOutput = params.summarizeToolOutput; this.ideModeFeature = params.ideModeFeature ?? false; - this.ideMode = params.ideMode ?? true; + this.ideMode = params.ideMode ?? false; this.ideClient = params.ideClient; if (params.contextFileName) { From e7b468e122a29341a6e2e2ca67366e6d62014a6d Mon Sep 17 00:00:00 2001 From: Mo Moadeli Date: Mon, 4 Aug 2025 17:20:49 -0400 Subject: [PATCH 093/136] feat(cli): Prevent redundant opening of browser tabs when zero MCP servers are configured (#5367) Co-authored-by: Allen Hutchison --- .../cli/src/ui/commands/mcpCommand.test.ts | 26 +++---------------- packages/cli/src/ui/commands/mcpCommand.ts | 21 ++++----------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index afa71ba5..2a6401b3 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -14,15 +14,10 @@ import { getMCPDiscoveryState, DiscoveredMCPTool, } from '@google/gemini-cli-core'; -import open from 'open'; + import { MessageActionReturn } from './types.js'; import { Type, CallableTool } from '@google/genai'; -// Mock external dependencies -vi.mock('open', () => ({ - default: vi.fn(), -})); - vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -144,30 +139,15 @@ describe('mcpCommand', () => { mockConfig.getMcpServers = vi.fn().mockReturnValue({}); }); - it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => { - process.env.SANDBOX = 'sandbox'; - + it('should display a message with a URL when no MCP servers are configured', async () => { const result = await mcpCommand.action!(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'info', content: - 'No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp', + 'No MCP servers configured. Please view MCP documentation in your browser: https://goo.gle/gemini-cli-docs-mcp or use the cli /docs command', }); - expect(open).not.toHaveBeenCalled(); - }); - - it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => { - const result = await mcpCommand.action!(mockContext, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp', - }); - expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp'); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 709053b6..dc5442cc 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -21,7 +21,6 @@ import { mcpServerRequiresOAuth, getErrorMessage, } from '@google/gemini-cli-core'; -import open from 'open'; const COLOR_GREEN = '\u001b[32m'; const COLOR_YELLOW = '\u001b[33m'; @@ -60,21 +59,11 @@ const getMcpStatus = async ( if (serverNames.length === 0 && blockedMcpServers.length === 0) { const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; - if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { - return { - type: 'message', - messageType: 'info', - content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`, - }; - } else { - // Open the URL in the browser - await open(docsUrl); - return { - type: 'message', - messageType: 'info', - content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`, - }; - } + return { + type: 'message', + messageType: 'info', + content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`, + }; } // Check if any servers are still connecting From 93f8fe3671babbd3065d7a80b9e5ac50c42042da Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 4 Aug 2025 21:36:23 +0000 Subject: [PATCH 094/136] [ide-mode] Add openDiff tool to IDE MCP server (#4519) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/vscode-ide-companion/package.json | 51 ++++ .../vscode-ide-companion/src/diff-manager.ts | 228 ++++++++++++++++++ .../vscode-ide-companion/src/extension.ts | 38 ++- .../vscode-ide-companion/src/ide-server.ts | 70 +++++- 4 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 packages/vscode-ide-companion/src/diff-manager.ts diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 254d8ac2..263f1b18 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -31,7 +31,22 @@ "onStartupFinished" ], "contributes": { + "languages": [ + { + "id": "gemini-diff-editable" + } + ], "commands": [ + { + "command": "gemini.diff.accept", + "title": "Gemini CLI: Accept Current Diff", + "icon": "$(check)" + }, + { + "command": "gemini.diff.cancel", + "title": "Cancel", + "icon": "$(close)" + }, { "command": "gemini-cli.runGeminiCLI", "title": "Gemini CLI: Run" @@ -40,6 +55,42 @@ "command": "gemini-cli.showNotices", "title": "Gemini CLI: View Third-Party Notices" } + ], + "menus": { + "commandPalette": [ + { + "command": "gemini.diff.accept", + "when": "gemini.diff.isVisible" + }, + { + "command": "gemini.diff.cancel", + "when": "gemini.diff.isVisible" + } + ], + "editor/title": [ + { + "command": "gemini.diff.accept", + "when": "gemini.diff.isVisible", + "group": "navigation" + }, + { + "command": "gemini.diff.cancel", + "when": "gemini.diff.isVisible", + "group": "navigation" + } + ] + }, + "keybindings": [ + { + "command": "gemini.diff.accept", + "key": "ctrl+s", + "when": "gemini.diff.isVisible" + }, + { + "command": "gemini.diff.accept", + "key": "cmd+s", + "when": "gemini.diff.isVisible" + } ] }, "main": "./dist/extension.cjs", diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts new file mode 100644 index 00000000..159a6101 --- /dev/null +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import * as path from 'node:path'; +import { DIFF_SCHEME } from './extension.js'; +import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; + +export class DiffContentProvider implements vscode.TextDocumentContentProvider { + private content = new Map(); + private onDidChangeEmitter = new vscode.EventEmitter(); + + get onDidChange(): vscode.Event { + return this.onDidChangeEmitter.event; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + return this.content.get(uri.toString()) ?? ''; + } + + setContent(uri: vscode.Uri, content: string): void { + this.content.set(uri.toString(), content); + this.onDidChangeEmitter.fire(uri); + } + + deleteContent(uri: vscode.Uri): void { + this.content.delete(uri.toString()); + } + + getContent(uri: vscode.Uri): string | undefined { + return this.content.get(uri.toString()); + } +} + +// Information about a diff view that is currently open. +interface DiffInfo { + originalFilePath: string; + newContent: string; + rightDocUri: vscode.Uri; +} + +/** + * Manages the state and lifecycle of diff views within the IDE. + */ +export class DiffManager { + private readonly onDidChangeEmitter = + new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + private diffDocuments = new Map(); + + constructor( + private readonly logger: vscode.OutputChannel, + private readonly diffContentProvider: DiffContentProvider, + ) {} + + /** + * Creates and shows a new diff view. + */ + async showDiff(filePath: string, newContent: string) { + const fileUri = vscode.Uri.file(filePath); + + const rightDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: filePath, + // cache busting + query: `rand=${Math.random()}`, + }); + this.diffContentProvider.setContent(rightDocUri, newContent); + + this.addDiffDocument(rightDocUri, { + originalFilePath: filePath, + newContent, + rightDocUri, + }); + + const diffTitle = `${path.basename(filePath)} ↔ Modified`; + await vscode.commands.executeCommand( + 'setContext', + 'gemini.diff.isVisible', + true, + ); + + let leftDocUri; + try { + await vscode.workspace.fs.stat(fileUri); + leftDocUri = fileUri; + } catch { + // We need to provide an empty document to diff against. + // Using the 'untitled' scheme is one way to do this. + leftDocUri = vscode.Uri.from({ + scheme: 'untitled', + path: filePath, + }); + } + + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { + preview: false, + }, + ); + await vscode.commands.executeCommand( + 'workbench.action.files.setActiveEditorWriteableInSession', + ); + } + + /** + * Closes an open diff view for a specific file. + */ + async closeDiff(filePath: string) { + let uriToClose: vscode.Uri | undefined; + for (const [uriString, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === filePath) { + uriToClose = vscode.Uri.parse(uriString); + break; + } + } + + if (uriToClose) { + const rightDoc = await vscode.workspace.openTextDocument(uriToClose); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(uriToClose); + this.onDidChangeEmitter.fire({ + jsonrpc: '2.0', + method: 'ide/diffClosed', + params: { + filePath, + content: modifiedContent, + }, + }); + vscode.window.showInformationMessage(`Diff for ${filePath} closed.`); + } else { + vscode.window.showWarningMessage(`No open diff found for ${filePath}.`); + } + } + + /** + * User accepts the changes in a diff view. Does not apply changes. + */ + async acceptDiff(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + this.logger.appendLine( + `No diff info found for ${rightDocUri.toString()}`, + ); + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + jsonrpc: '2.0', + method: 'ide/diffAccepted', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }); + } + + /** + * Called when a user cancels a diff view. + */ + async cancelDiff(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + this.logger.appendLine( + `No diff info found for ${rightDocUri.toString()}`, + ); + // Even if we don't have diff info, we should still close the editor. + await this.closeDiffEditor(rightDocUri); + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + jsonrpc: '2.0', + method: 'ide/diffClosed', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }); + } + + private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) { + this.diffDocuments.set(uri.toString(), diffInfo); + } + + private async closeDiffEditor(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + await vscode.commands.executeCommand( + 'setContext', + 'gemini.diff.isVisible', + false, + ); + + if (diffInfo) { + this.diffDocuments.delete(rightDocUri.toString()); + this.diffContentProvider.deleteContent(rightDocUri); + } + + // Find and close the tab corresponding to the diff view + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { + modified?: vscode.Uri; + original?: vscode.Uri; + }; + if (input && input.modified?.toString() === rightDocUri.toString()) { + await vscode.window.tabGroups.close(tab); + return; + } + } + } + } +} diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 73090175..b31e15b8 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -6,12 +6,15 @@ import * as vscode from 'vscode'; import { IDEServer } from './ide-server.js'; +import { DiffContentProvider, DiffManager } from './diff-manager.js'; import { createLogger } from './utils/logger.js'; const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; +export const DIFF_SCHEME = 'gemini-diff'; let ideServer: IDEServer; let logger: vscode.OutputChannel; + let log: (message: string) => void = () => {}; function updateWorkspacePath(context: vscode.ExtensionContext) { @@ -37,7 +40,40 @@ export async function activate(context: vscode.ExtensionContext) { updateWorkspacePath(context); - ideServer = new IDEServer(log); + const diffContentProvider = new DiffContentProvider(); + const diffManager = new DiffManager(logger, diffContentProvider); + + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((doc) => { + if (doc.uri.scheme === DIFF_SCHEME) { + diffManager.cancelDiff(doc.uri); + } + }), + vscode.workspace.registerTextDocumentContentProvider( + DIFF_SCHEME, + diffContentProvider, + ), + vscode.commands.registerCommand( + 'gemini.diff.accept', + (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + diffManager.acceptDiff(docUri); + } + }, + ), + vscode.commands.registerCommand( + 'gemini.diff.cancel', + (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + diffManager.cancelDiff(docUri); + } + }, + ), + ); + + ideServer = new IDEServer(log, diffManager); try { await ideServer.start(context); } catch (err) { diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 8296c64c..30215ccc 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -14,6 +14,8 @@ import { type JSONRPCNotification, } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; +import { z } from 'zod'; +import { DiffManager } from './diff-manager.js'; import { OpenFilesManager } from './open-files-manager.js'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; @@ -45,20 +47,22 @@ export class IDEServer { private server: HTTPServer | undefined; private context: vscode.ExtensionContext | undefined; private log: (message: string) => void; + diffManager: DiffManager; - constructor(log: (message: string) => void) { + constructor(log: (message: string) => void, diffManager: DiffManager) { this.log = log; + this.diffManager = diffManager; } async start(context: vscode.ExtensionContext) { this.context = context; + const sessionsWithInitialNotification = new Set(); const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - const sessionsWithInitialNotification = new Set(); const app = express(); app.use(express.json()); - const mcpServer = createMcpServer(); + const mcpServer = createMcpServer(this.diffManager); const openFilesManager = new OpenFilesManager(context); const onDidChangeSubscription = openFilesManager.onDidChange(() => { @@ -71,6 +75,14 @@ export class IDEServer { } }); context.subscriptions.push(onDidChangeSubscription); + const onDidChangeDiffSubscription = this.diffManager.onDidChange( + (notification: JSONRPCNotification) => { + for (const transport of Object.values(transports)) { + transport.send(notification); + } + }, + ); + context.subscriptions.push(onDidChangeDiffSubscription); app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers[MCP_SESSION_ID_HEADER] as @@ -88,7 +100,6 @@ export class IDEServer { transports[newSessionId] = transport; }, }); - const keepAlive = setInterval(() => { try { transport.send({ jsonrpc: '2.0', method: 'ping' }); @@ -212,7 +223,7 @@ export class IDEServer { } } -const createMcpServer = () => { +const createMcpServer = (diffManager: DiffManager) => { const server = new McpServer( { name: 'gemini-cli-companion-mcp-server', @@ -220,5 +231,54 @@ const createMcpServer = () => { }, { capabilities: { logging: {} } }, ); + server.registerTool( + 'openDiff', + { + description: + '(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejcted.', + inputSchema: z.object({ + filePath: z.string(), + // TODO(chrstn): determine if this should be required or not. + newContent: z.string().optional(), + }).shape, + }, + async ({ + filePath, + newContent, + }: { + filePath: string; + newContent?: string; + }) => { + await diffManager.showDiff(filePath, newContent ?? ''); + return { + content: [ + { + type: 'text', + text: `Showing diff for ${filePath}`, + }, + ], + }; + }, + ); + server.registerTool( + 'closeDiff', + { + description: '(IDE Tool) Close an open diff view for a specific file.', + inputSchema: z.object({ + filePath: z.string(), + }).shape, + }, + async ({ filePath }: { filePath: string }) => { + await diffManager.closeDiff(filePath); + return { + content: [ + { + type: 'text', + text: `Closed diff for ${filePath}`, + }, + ], + }; + }, + ); return server; }; From 99ba2f6424b8873df3857f03b729e236710bbc32 Mon Sep 17 00:00:00 2001 From: Harold Mciver Date: Mon, 4 Aug 2025 17:38:23 -0400 Subject: [PATCH 095/136] Update MCP client to connect to servers with only prompts (#5290) --- .../cli/src/ui/commands/mcpCommand.test.ts | 4 +- packages/cli/src/ui/commands/mcpCommand.ts | 17 ++++- packages/core/src/tools/mcp-client.ts | 71 +++++++++++++------ 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 2a6401b3..ad04cb69 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -212,9 +212,9 @@ describe('mcpCommand', () => { ); expect(message).toContain('server2_tool1'); - // Server 3 - Disconnected + // Server 3 - Disconnected but with cached tools, so shows as Ready expect(message).toContain( - '🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)', + '🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)', ); expect(message).toContain('server3_tool1'); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index dc5442cc..11c71f1a 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -94,7 +94,15 @@ const getMcpStatus = async ( const promptRegistry = await config.getPromptRegistry(); const serverPrompts = promptRegistry.getPromptsByServer(serverName) || []; - const status = getMCPServerStatus(serverName); + const originalStatus = getMCPServerStatus(serverName); + const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0; + + // If the server is "disconnected" but has prompts or cached tools, display it as Ready + // by using CONNECTED as the display status. + const status = + originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems + ? MCPServerStatus.CONNECTED + : originalStatus; // Add status indicator with descriptive text let statusIndicator = ''; @@ -260,11 +268,14 @@ const getMcpStatus = async ( message += ' No tools or prompts available\n'; } else if (serverTools.length === 0) { message += ' No tools available'; - if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) { + if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) { message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`; } message += '\n'; - } else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) { + } else if ( + originalStatus === MCPServerStatus.DISCONNECTED && + needsAuthHint + ) { // This case is for when serverTools.length > 0 message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`; } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index f9ccc380..00f2197a 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -366,33 +366,47 @@ export async function connectAndDiscover( ): Promise { updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING); + let mcpClient: Client | undefined; try { - const mcpClient = await connectToMcpServer( + mcpClient = await connectToMcpServer( mcpServerName, mcpServerConfig, debugMode, ); - try { - updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED); - mcpClient.onerror = (error) => { - console.error(`MCP ERROR (${mcpServerName}):`, error.toString()); - updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); - }; - await discoverPrompts(mcpServerName, mcpClient, promptRegistry); - const tools = await discoverTools( - mcpServerName, - mcpServerConfig, - mcpClient, - ); - for (const tool of tools) { - toolRegistry.registerTool(tool); - } - } catch (error) { - mcpClient.close(); - throw error; + mcpClient.onerror = (error) => { + console.error(`MCP ERROR (${mcpServerName}):`, error.toString()); + updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); + }; + + // Attempt to discover both prompts and tools + const prompts = await discoverPrompts( + mcpServerName, + mcpClient, + promptRegistry, + ); + const tools = await discoverTools( + mcpServerName, + mcpServerConfig, + mcpClient, + ); + + // If we have neither prompts nor tools, it's a failed discovery + if (prompts.length === 0 && tools.length === 0) { + throw new Error('No prompts or tools found on the server.'); + } + + // If we found anything, the server is connected + updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED); + + // Register any discovered tools + for (const tool of tools) { + toolRegistry.registerTool(tool); } } catch (error) { + if (mcpClient) { + mcpClient.close(); + } console.error( `Error connecting to MCP server '${mcpServerName}': ${getErrorMessage( error, @@ -423,7 +437,8 @@ export async function discoverTools( const tool = await mcpCallableTool.tool(); if (!Array.isArray(tool.functionDeclarations)) { - throw new Error(`Server did not return valid function declarations.`); + // This is a valid case for a prompt-only server + return []; } const discoveredTools: DiscoveredMCPTool[] = []; @@ -454,7 +469,17 @@ export async function discoverTools( } return discoveredTools; } catch (error) { - throw new Error(`Error discovering tools: ${error}`); + if ( + error instanceof Error && + !error.message?.includes('Method not found') + ) { + console.error( + `Error discovering tools from ${mcpServerName}: ${getErrorMessage( + error, + )}`, + ); + } + return []; } } @@ -469,7 +494,7 @@ export async function discoverPrompts( mcpServerName: string, mcpClient: Client, promptRegistry: PromptRegistry, -): Promise { +): Promise { try { const response = await mcpClient.request( { method: 'prompts/list', params: {} }, @@ -484,6 +509,7 @@ export async function discoverPrompts( invokeMcpPrompt(mcpServerName, mcpClient, prompt.name, params), }); } + return response.prompts; } catch (error) { // It's okay if this fails, not all servers will have prompts. // Don't log an error if the method is not found, which is a common case. @@ -497,6 +523,7 @@ export async function discoverPrompts( )}`, ); } + return []; } } From 11ecf6fc86ef7b1e1f546df5616270521a001423 Mon Sep 17 00:00:00 2001 From: Olcan Date: Mon, 4 Aug 2025 18:12:21 -0700 Subject: [PATCH 096/136] fix self-reference in build script (#5548) --- scripts/generate-git-commit-info.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js index 237ec09b..c5865d73 100644 --- a/scripts/generate-git-commit-info.js +++ b/scripts/generate-git-commit-info.js @@ -19,11 +19,12 @@ import { execSync } from 'child_process'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; -import { dirname, join } from 'path'; +import { dirname, join, relative } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); +const scriptPath = relative(root, fileURLToPath(import.meta.url)); const generatedDir = join(root, 'packages/cli/src/generated'); const gitCommitFile = join(generatedDir, 'git-commit.ts'); let gitCommitInfo = 'N/A'; @@ -55,7 +56,7 @@ const fileContent = `/** * SPDX-License-Identifier: Apache-2.0 */ -// This file is auto-generated by the build script (scripts/build.js) +// This file is auto-generated by the build script (${scriptPath}) // Do not edit this file manually. export const GIT_COMMIT_INFO = '${gitCommitInfo}'; `; From 49001a0f831ac4c3b00f2e249a9155cc9bfda7e5 Mon Sep 17 00:00:00 2001 From: DeWitt Clinton Date: Mon, 4 Aug 2025 21:01:19 -0700 Subject: [PATCH 097/136] Remove the "local modifications" string from bug and about reports. (#5552) --- scripts/generate-git-commit-info.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js index c5865d73..7c4871ec 100644 --- a/scripts/generate-git-commit-info.js +++ b/scripts/generate-git-commit-info.js @@ -39,12 +39,6 @@ try { }).trim(); if (gitHash) { gitCommitInfo = gitHash; - const gitStatus = execSync('git status --porcelain', { - encoding: 'utf-8', - }).trim(); - if (gitStatus) { - gitCommitInfo = `${gitHash} (local modifications)`; - } } } catch { // ignore From c7a1de49832b7a5a6f62115085620dea97eb92cc Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Mon, 4 Aug 2025 21:37:32 -0700 Subject: [PATCH 098/136] chore(release): v0.1.17 (#5561) --- package-lock.json | 8 ++++---- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7067960d..7f6cfc4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.1.16", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.1.16", + "version": "0.1.17", "workspaces": [ "packages/*" ], @@ -11659,7 +11659,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", @@ -11860,7 +11860,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "@google/genai": "1.9.0", "@modelcontextprotocol/sdk": "^1.11.0", diff --git a/package.json b/package.json index ec01287f..bb7896c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.16", + "version": "0.1.17", "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.1.16" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.17" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index d65102e1..3d9bd400 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.16", + "version": "0.1.17", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.16" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.17" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index de2b7201..cc5e9c2a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.1.16", + "version": "0.1.17", "description": "Gemini CLI Core", "repository": { "type": "git", From d0cda58f1fc23daa1d69f782c5ab9593b30217cb Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 5 Aug 2025 10:03:58 -0400 Subject: [PATCH 099/136] docs: update typo in commands.md (#5584) --- docs/cli/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 58717635..96f47a2a 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -46,7 +46,7 @@ Slash commands provide meta-level control over the CLI itself. - **Usage:** `/directory add ,` - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead. - **`show`**: - - **Description:** Display all directories added by `/direcotry add` and `--include-directories`. + - **Description:** Display all directories added by `/directory add` and `--include-directories`. - **Usage:** `/directory show` - **`/editor`** From 5c8268b6f44e96ef1975999baac71c022875c321 Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Wed, 6 Aug 2025 02:01:01 +0900 Subject: [PATCH 100/136] feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (#5354) Co-authored-by: Allen Hutchison --- docs/cli/configuration.md | 24 ++++- packages/cli/src/config/config.test.ts | 87 ++++++++++++++- packages/cli/src/config/config.ts | 21 +++- packages/cli/src/config/settings.test.ts | 42 ++++++++ packages/cli/src/config/settings.ts | 9 ++ packages/cli/src/ui/App.tsx | 6 +- .../src/ui/commands/directoryCommand.test.tsx | 13 +++ .../cli/src/ui/commands/directoryCommand.tsx | 34 +++++- .../cli/src/ui/commands/memoryCommand.test.ts | 4 + packages/cli/src/ui/commands/memoryCommand.ts | 3 + packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 3 + packages/cli/src/utils/resolvePath.ts | 21 ++++ packages/core/src/config/config.ts | 8 ++ .../core/src/utils/memoryDiscovery.test.ts | 102 ++++++++---------- packages/core/src/utils/memoryDiscovery.ts | 39 ++++++- packages/core/src/utils/workspaceContext.ts | 43 ++++++++ 17 files changed, 393 insertions(+), 67 deletions(-) create mode 100644 packages/cli/src/utils/resolvePath.ts diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index ce9b55bc..5c917a3f 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -248,6 +248,26 @@ In addition to a project settings file, a project's `.gemini` directory can cont "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] ``` +- **`includeDirectories`** (array of strings): + - **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. + - **Default:** `[]` + - **Example:** + ```json + "includeDirectories": [ + "/path/to/another/project", + "../shared-library", + "~/common-utils" + ] + ``` + +- **`loadMemoryFromIncludeDirectories`** (boolean): + - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `GEMINI.md` files should be loaded from all directories that are added. If set to `false`, `GEMINI.md` should only be loaded from the current directory. + - **Default:** `false` + - **Example:** + ```json + "loadMemoryFromIncludeDirectories": true + ``` + ### Example `settings.json`: ```json @@ -280,7 +300,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont "tokenBudget": 100 } }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadMemoryFromIncludeDirectories": true } ``` diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 431b1375..f5d0ddf8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; import { loadCliConfig, parseArguments } from './config.js'; import { Settings } from './settings.js'; import { Extension } from './extension.js'; @@ -44,7 +46,7 @@ vi.mock('@google/gemini-cli-core', async () => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, debug, fileService, extensionPaths, _maxDirs) => + (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => Promise.resolve({ memoryContent: extensionPaths?.join(',') || '', fileCount: extensionPaths?.length || 0, @@ -487,6 +489,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { await loadCliConfig(settings, extensions, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), + [], false, expect.any(Object), [ @@ -1015,3 +1018,85 @@ describe('loadCliConfig ideModeFeature', () => { expect(config.getIdeModeFeature()).toBe(false); }); }); + +vi.mock('fs', async () => { + const actualFs = await vi.importActual('fs'); + const MOCK_CWD1 = process.cwd(); + const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project'); + + const mockPaths = new Set([ + MOCK_CWD1, + MOCK_CWD2, + path.resolve(path.sep, 'cli', 'path1'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(MOCK_CWD2, 'cli', 'path2'), + path.join(MOCK_CWD2, 'settings', 'path3'), + ]); + + return { + ...actualFs, + existsSync: vi.fn((p) => mockPaths.has(p.toString())), + statSync: vi.fn((p) => { + if (mockPaths.has(p.toString())) { + return { isDirectory: () => true }; + } + // Fallback for other paths if needed, though the test should be specific. + return actualFs.statSync(p); + }), + realpathSync: vi.fn((p) => p), + }; +}); + +describe('loadCliConfig with includeDirectories', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve(path.sep, 'home', 'user', 'project'), + ); + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should combine and resolve paths from settings and CLI arguments', async () => { + const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); + process.argv = [ + 'node', + 'script.js', + '--include-directories', + `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, + ]; + const argv = await parseArguments(); + const settings: Settings = { + includeDirectories: [ + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ], + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + const expected = [ + mockCwd, + path.resolve(path.sep, 'cli', 'path1'), + path.join(mockCwd, 'cli', 'path2'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ]; + expect(config.getWorkspaceContext().getDirectories()).toEqual( + expect.arrayContaining(expected), + ); + expect(config.getWorkspaceContext().getDirectories()).toHaveLength( + expected.length, + ); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0395ac0f..d3d37c6a 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -29,6 +29,7 @@ import { Settings } from './settings.js'; import { Extension, annotateActiveExtensions } from './extension.js'; import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; +import { resolvePath } from '../utils/resolvePath.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -65,6 +66,7 @@ export interface CliArgs { ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; + loadMemoryFromIncludeDirectories: boolean | undefined; } export async function parseArguments(): Promise { @@ -212,6 +214,12 @@ export async function parseArguments(): Promise { // Handle comma-separated values dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), }) + .option('load-memory-from-include-directories', { + type: 'boolean', + description: + 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', + default: false, + }) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() @@ -239,6 +247,7 @@ export async function parseArguments(): Promise { // TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[] = [], debugMode: boolean, fileService: FileDiscoveryService, settings: Settings, @@ -264,6 +273,7 @@ export async function loadHierarchicalGeminiMemory( // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, + includeDirectoriesToReadGemini, debugMode, fileService, extensionContextFilePaths, @@ -325,9 +335,14 @@ export async function loadCliConfig( ...settings.fileFiltering, }; + const includeDirectories = (settings.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.loadMemoryFromIncludeDirectories ? includeDirectories : [], debugMode, fileService, settings, @@ -393,7 +408,11 @@ export async function loadCliConfig( embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: process.cwd(), - includeDirectories: argv.includeDirectories, + includeDirectories, + loadMemoryFromIncludeDirectories: + argv.loadMemoryFromIncludeDirectories || + settings.loadMemoryFromIncludeDirectories || + false, debugMode, question: argv.promptInteractive || argv.prompt || '', fullContext: argv.allFiles || argv.all_files || false, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 4099e778..d0266720 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); expect(settings.errors.length).toBe(0); }); @@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => { ...userSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => { ...workspaceSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => { contextFileName: 'WORKSPACE_CONTEXT.md', customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => { allowMCPServers: ['server1', 'server2'], customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.mcpServers).toEqual({}); }); + it('should merge includeDirectories from all scopes', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + includeDirectories: ['/system/dir'], + }; + const userSettingsContent = { + includeDirectories: ['/user/dir1', '/user/dir2'], + }; + const workspaceSettingsContent = { + includeDirectories: ['/workspace/dir'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.includeDirectories).toEqual([ + '/system/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + ]); + }); + it('should handle JSON parsing errors gracefully', () => { (mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist" const invalidJsonContent = 'invalid json'; @@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); // Check that error objects are populated in settings.errors @@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 20a7b14a..722af628 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -126,6 +126,10 @@ export interface Settings { // Environment variables to exclude from project .env files excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; + + includeDirectories?: string[]; + + loadMemoryFromIncludeDirectories?: boolean; } export interface SettingsError { @@ -181,6 +185,11 @@ export class LoadedSettings { ...(workspace.mcpServers || {}), ...(system.mcpServers || {}), }, + includeDirectories: [ + ...(system.includeDirectories || []), + ...(user.includeDirectories || []), + ...(workspace.includeDirectories || []), + ], }; } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 3b695111..f07a5386 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -276,6 +276,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { try { const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.merged.loadMemoryFromIncludeDirectories + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), settings.merged, @@ -480,6 +483,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, + setGeminiMdFileCount, ); const { @@ -599,7 +603,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (config) { setGeminiMdFileCount(config.getGeminiMdFileCount()); } - }, [config]); + }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); const [userMessages, setUserMessages] = useState([]); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 081083d3..fee8ae40 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -40,11 +40,24 @@ describe('directoryCommand', () => { getGeminiClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), }), + getWorkingDir: () => '/test/dir', + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileService: () => ({}), + getExtensionContextFilePaths: () => [], + getFileFilteringOptions: () => ({ ignore: [], include: [] }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), } as unknown as Config; mockContext = { services: { config: mockConfig, + settings: { + merged: { + memoryDiscoveryMaxDirs: 1000, + }, + }, }, ui: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 18f7e78f..6c667f44 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -8,6 +8,7 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js'; import { MessageType } from '../types.js'; import * as os from 'os'; import * as path from 'path'; +import { loadServerHierarchicalMemory } from '@google/gemini-cli-core'; export function expandHomeDir(p: string): string { if (!p) { @@ -16,7 +17,7 @@ export function expandHomeDir(p: string): string { let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p.startsWith('~')) { + } else if (p === '~' || p.startsWith('~/')) { expandedPath = os.homedir() + p.substring(1); } return path.normalize(expandedPath); @@ -90,6 +91,37 @@ export const directoryCommand: SlashCommand = { } } + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + const { memoryContent, fileCount } = + await loadServerHierarchicalMemory( + config.getWorkingDir(), + [ + ...config.getWorkspaceContext().getDirectories(), + ...pathsToAdd, + ], + config.getDebugMode(), + config.getFileService(), + config.getExtensionContextFilePaths(), + context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' + config.getFileFilteringOptions(), + context.services.settings.merged.memoryDiscoveryMaxDirs, + ); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + context.ui.setGeminiMdFileCount(fileCount); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } catch (error) { + errors.push(`Error refreshing memory: ${(error as Error).message}`); + } + if (added.length > 0) { const gemini = config.getGeminiClient(); if (gemini) { diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 74614fa7..670ca796 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -161,6 +161,10 @@ describe('memoryCommand', () => { getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, getExtensionContextFilePaths: () => [], + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), getFileFilteringOptions: () => ({ ignore: [], include: [], diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 370bb1fb..b046e7f8 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = { const { memoryContent, fileCount } = await loadServerHierarchicalMemory( config.getWorkingDir(), + config.shouldLoadMemoryFromIncludeDirectories() + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2de221f0..09d79e9d 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,6 +59,7 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; + setGeminiMdFileCount: (count: number) => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6d9f4643..cfe4b385 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -51,6 +51,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise, setIsProcessing: (isProcessing: boolean) => void, + setGeminiMdFileCount: (count: number) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -163,6 +164,7 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, + setGeminiMdFileCount, }, session: { stats: session.stats, @@ -185,6 +187,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, + setGeminiMdFileCount, ], ); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts new file mode 100644 index 00000000..b26ed8fc --- /dev/null +++ b/packages/cli/src/utils/resolvePath.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'os'; +import * as path from 'path'; + +export function resolvePath(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3f5c11a0..22996f3e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -188,6 +188,7 @@ export interface ConfigParameters { ideModeFeature?: boolean; ideMode?: boolean; ideClient: IdeClient; + loadMemoryFromIncludeDirectories?: boolean; } export class Config { @@ -247,6 +248,7 @@ export class Config { | Record | undefined; private readonly experimentalAcp: boolean = false; + private readonly loadMemoryFromIncludeDirectories: boolean = false; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -304,6 +306,8 @@ export class Config { this.ideModeFeature = params.ideModeFeature ?? false; this.ideMode = params.ideMode ?? false; this.ideClient = params.ideClient; + this.loadMemoryFromIncludeDirectories = + params.loadMemoryFromIncludeDirectories ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -366,6 +370,10 @@ export class Config { return this.sessionId; } + shouldLoadMemoryFromIncludeDirectories(): boolean { + return this.loadMemoryFromIncludeDirectories; + } + getContentGeneratorConfig(): ContentGeneratorConfig { return this.contentGeneratorConfig; } diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 8c7a294d..6c229dbb 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -67,6 +67,7 @@ describe('loadServerHierarchicalMemory', () => { it('should return empty memory and count if no context files are found', async () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -85,14 +86,13 @@ describe('loadServerHierarchicalMemory', () => { const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, fileCount: 1, }); }); @@ -108,14 +108,13 @@ default context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} --- -custom context content ---- End of Context from: ${path.relative(cwd, customContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} ---\ncustom context content\n--- End of Context from: ${path.relative(cwd, customContextFile)} ---`, fileCount: 1, }); }); @@ -135,18 +134,13 @@ custom context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} --- -project context content ---- End of Context from: ${path.relative(cwd, projectContextFile)} --- - ---- Context from: ${path.relative(cwd, cwdContextFile)} --- -cwd context content ---- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} ---\nproject context content\n--- End of Context from: ${path.relative(cwd, projectContextFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdContextFile)} ---\ncwd context content\n--- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, fileCount: 2, }); }); @@ -163,18 +157,13 @@ cwd context content const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${customFilename} --- -CWD custom memory ---- End of Context from: ${customFilename} --- - ---- Context from: ${path.join('subdir', customFilename)} --- -Subdir custom memory ---- End of Context from: ${path.join('subdir', customFilename)} ---`, + memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---\n\n--- Context from: ${path.join('subdir', customFilename)} ---\nSubdir custom memory\n--- End of Context from: ${path.join('subdir', customFilename)} ---`, fileCount: 2, }); }); @@ -191,18 +180,13 @@ Subdir custom memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, srcGeminiFile)} --- -Src directory memory ---- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, srcGeminiFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, fileCount: 2, }); }); @@ -219,18 +203,13 @@ Src directory memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} --- -CWD memory ---- End of Context from: ${DEFAULT_CONTEXT_FILENAME} --- - ---- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} --- -Subdir memory ---- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, + memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---\n\n--- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---\nSubdir memory\n--- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`, fileCount: 2, }); }); @@ -259,30 +238,13 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} --- -default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} --- - ---- Context from: ${path.relative(cwd, rootGeminiFile)} --- -Project parent memory ---- End of Context from: ${path.relative(cwd, rootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, projectRootGeminiFile)} --- -Project root memory ---- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} --- - ---- Context from: ${path.relative(cwd, cwdGeminiFile)} --- -CWD memory ---- End of Context from: ${path.relative(cwd, cwdGeminiFile)} --- - ---- Context from: ${path.relative(cwd, subDirGeminiFile)} --- -Subdir memory ---- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, subDirGeminiFile)} ---\nSubdir memory\n--- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`, fileCount: 5, }); }); @@ -302,6 +264,7 @@ Subdir memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [], @@ -314,9 +277,7 @@ Subdir memory ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} --- -My code memory ---- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---\nMy code memory\n--- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`, fileCount: 1, }); }); @@ -333,6 +294,7 @@ My code memory // Pass the custom limit directly to the function await loadServerHierarchicalMemory( cwd, + [], true, new FileDiscoveryService(projectRoot), [], @@ -353,6 +315,7 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), ); @@ -371,15 +334,36 @@ My code memory const result = await loadServerHierarchicalMemory( cwd, + [], false, new FileDiscoveryService(projectRoot), [extensionFilePath], ); expect(result).toEqual({ - memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} --- -Extension memory content ---- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} ---\nExtension memory content\n--- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, + fileCount: 1, + }); + }); + + it('should load memory from included directories', async () => { + const includedDir = await createEmptyDir( + path.join(testRootDir, 'included'), + ); + const includedFile = await createTestFile( + path.join(includedDir, DEFAULT_CONTEXT_FILENAME), + 'included directory memory', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [includedDir], + false, + new FileDiscoveryService(projectRoot), + ); + + expect(result).toEqual({ + memoryContent: `--- Context from: ${path.relative(cwd, includedFile)} ---\nincluded directory memory\n--- End of Context from: ${path.relative(cwd, includedFile)} ---`, fileCount: 1, }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 323b13c5..f53d27a9 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -83,6 +83,36 @@ async function findProjectRoot(startDir: string): Promise { async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], + userHomePath: string, + debugMode: boolean, + fileService: FileDiscoveryService, + extensionContextFilePaths: string[] = [], + fileFilteringOptions: FileFilteringOptions, + maxDirs: number, +): Promise { + const dirs = new Set([ + ...includeDirectoriesToReadGemini, + currentWorkingDirectory, + ]); + const paths = []; + for (const dir of dirs) { + const pathsByDir = await getGeminiMdFilePathsInternalForEachDir( + dir, + userHomePath, + debugMode, + fileService, + extensionContextFilePaths, + fileFilteringOptions, + maxDirs, + ); + paths.push(...pathsByDir); + } + return Array.from(new Set(paths)); +} + +async function getGeminiMdFilePathsInternalForEachDir( + dir: string, userHomePath: string, debugMode: boolean, fileService: FileDiscoveryService, @@ -115,8 +145,8 @@ async function getGeminiMdFilePathsInternal( // FIX: Only perform the workspace search (upward and downward scans) // if a valid currentWorkingDirectory is provided. - if (currentWorkingDirectory) { - const resolvedCwd = path.resolve(currentWorkingDirectory); + if (dir) { + const resolvedCwd = path.resolve(dir); if (debugMode) logger.debug( `Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`, @@ -257,6 +287,7 @@ function concatenateInstructions( */ export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[], debugMode: boolean, fileService: FileDiscoveryService, extensionContextFilePaths: string[] = [], @@ -274,6 +305,7 @@ export async function loadServerHierarchicalMemory( const userHomePath = homedir(); const filePaths = await getGeminiMdFilePathsInternal( currentWorkingDirectory, + includeDirectoriesToReadGemini, userHomePath, debugMode, fileService, @@ -282,7 +314,8 @@ export async function loadServerHierarchicalMemory( maxDirs, ); if (filePaths.length === 0) { - if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.'); + if (debugMode) + logger.debug('No GEMINI.md files found in hierarchy of the workspace.'); return { memoryContent: '', fileCount: 0 }; } const contentsWithPaths = await readGeminiMdFiles( diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 16d1b4c9..efbc8a4c 100644 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -15,6 +15,8 @@ import * as path from 'path'; export class WorkspaceContext { private directories: Set; + private initialDirectories: Set; + /** * Creates a new WorkspaceContext with the given initial directory and optional additional directories. * @param initialDirectory The initial working directory (usually cwd) @@ -22,11 +24,14 @@ export class WorkspaceContext { */ constructor(initialDirectory: string, additionalDirectories: string[] = []) { this.directories = new Set(); + this.initialDirectories = new Set(); this.addDirectoryInternal(initialDirectory); + this.addInitialDirectoryInternal(initialDirectory); for (const dir of additionalDirectories) { this.addDirectoryInternal(dir); + this.addInitialDirectoryInternal(dir); } } @@ -69,6 +74,33 @@ export class WorkspaceContext { this.directories.add(realPath); } + private addInitialDirectoryInternal( + directory: string, + basePath: string = process.cwd(), + ): void { + const absolutePath = path.isAbsolute(directory) + ? directory + : path.resolve(basePath, directory); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Directory does not exist: ${absolutePath}`); + } + + const stats = fs.statSync(absolutePath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${absolutePath}`); + } + + let realPath: string; + try { + realPath = fs.realpathSync(absolutePath); + } catch (_error) { + throw new Error(`Failed to resolve path: ${absolutePath}`); + } + + this.initialDirectories.add(realPath); + } + /** * Gets a copy of all workspace directories. * @returns Array of absolute directory paths @@ -77,6 +109,17 @@ export class WorkspaceContext { return Array.from(this.directories); } + getInitialDirectories(): readonly string[] { + return Array.from(this.initialDirectories); + } + + setDirectories(directories: readonly string[]): void { + this.directories.clear(); + for (const dir of directories) { + this.addDirectoryInternal(dir); + } + } + /** * Checks if a given path is within any of the workspace directories. * @param pathToCheck The path to validate From 43d5aaa7980aaa51714175bc9c11d13f39c5d1be Mon Sep 17 00:00:00 2001 From: David East Date: Tue, 5 Aug 2025 14:44:30 -0400 Subject: [PATCH 101/136] fix(mcp): ensure authorization url is valid when containing query params (#5545) Co-authored-by: Jacob Richman --- packages/core/src/mcp/oauth-provider.test.ts | 118 +++++++++++++++++-- packages/core/src/mcp/oauth-provider.ts | 6 +- 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5bfd637b..74eb42c0 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -4,7 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { vi } from 'vitest'; + +// Mock dependencies AT THE TOP +const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); +vi.mock('../utils/secure-browser-launcher.js', () => ({ + openBrowserSecurely: mockOpenBrowserSecurely, +})); +vi.mock('node:crypto'); +vi.mock('./oauth-token-storage.js'); + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { @@ -15,14 +25,6 @@ import { } from './oauth-provider.js'; import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js'; -// Mock dependencies -const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); -vi.mock('../utils/secure-browser-launcher.js', () => ({ - openBrowserSecurely: mockOpenBrowserSecurely, -})); -vi.mock('node:crypto'); -vi.mock('./oauth-token-storage.js'); - // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -721,5 +723,103 @@ describe('MCPOAuthProvider', () => { expect(capturedUrl!).toContain('scope=read+write'); expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com'); }); + + it('should correctly append parameters to an authorization URL that already has query params', async () => { + // Mock to capture the URL that would be opened + let capturedUrl: string; + mockOpenBrowserSecurely.mockImplementation((url: string) => { + capturedUrl = url; + return Promise.resolve(); + }); + + let callbackHandler: unknown; + vi.mocked(http.createServer).mockImplementation((handler) => { + callbackHandler = handler; + return mockHttpServer as unknown as http.Server; + }); + + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + setTimeout(() => { + const mockReq = { + url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw', + }; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + (callbackHandler as (req: unknown, res: unknown) => void)( + mockReq, + mockRes, + ); + }, 10); + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse), + }); + + const configWithParamsInUrl = { + ...mockConfig, + authorizationUrl: 'https://auth.example.com/authorize?audience=1234', + }; + + await MCPOAuthProvider.authenticate('test-server', configWithParamsInUrl); + + const url = new URL(capturedUrl!); + expect(url.searchParams.get('audience')).toBe('1234'); + expect(url.searchParams.get('client_id')).toBe('test-client-id'); + expect(url.search.startsWith('?audience=1234&')).toBe(true); + }); + + it('should correctly append parameters to a URL with a fragment', async () => { + // Mock to capture the URL that would be opened + let capturedUrl: string; + mockOpenBrowserSecurely.mockImplementation((url: string) => { + capturedUrl = url; + return Promise.resolve(); + }); + + let callbackHandler: unknown; + vi.mocked(http.createServer).mockImplementation((handler) => { + callbackHandler = handler; + return mockHttpServer as unknown as http.Server; + }); + + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + setTimeout(() => { + const mockReq = { + url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw', + }; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + (callbackHandler as (req: unknown, res: unknown) => void)( + mockReq, + mockRes, + ); + }, 10); + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse), + }); + + const configWithFragment = { + ...mockConfig, + authorizationUrl: 'https://auth.example.com/authorize#login', + }; + + await MCPOAuthProvider.authenticate('test-server', configWithFragment); + + const url = new URL(capturedUrl!); + expect(url.searchParams.get('client_id')).toBe('test-client-id'); + expect(url.hash).toBe('#login'); + expect(url.pathname).toBe('/authorize'); + }); }); }); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 491ec477..5052e8af 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -308,7 +308,11 @@ export class MCPOAuthProvider { ); } - return `${config.authorizationUrl}?${params.toString()}`; + const url = new URL(config.authorizationUrl!); + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + return url.toString(); } /** From 08f14319467cb88ba897a630e1a4c3182e5ae434 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Tue, 5 Aug 2025 11:52:39 -0700 Subject: [PATCH 102/136] bug(core): fix `contentRangeTruncated` calculation. (#5329) Co-authored-by: Jacob Richman --- packages/core/src/utils/fileUtils.test.ts | 23 +++++++++++++++++++++++ packages/core/src/utils/fileUtils.ts | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index ca121bca..bcdf3fe7 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -426,6 +426,29 @@ describe('fileUtils', () => { expect(result.linesShown).toEqual([6, 10]); }); + it('should identify truncation when reading the end of a file', async () => { + const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); + actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); + + // Read from line 11 to 20. The start is not 0, so it's truncated. + const result = await processSingleFileContent( + testTextFilePath, + tempRootDir, + 10, + 10, + ); + const expectedContent = lines.slice(10, 20).join('\n'); + + expect(result.llmContent).toContain(expectedContent); + expect(result.llmContent).toContain( + '[File content truncated: showing lines 11-20 of 20 total lines. Use offset/limit parameters to view more.]', + ); + expect(result.returnDisplay).toBe('Read lines 11-20 of 20 from test.txt'); + expect(result.isTruncated).toBe(true); // This is the key check for the bug + expect(result.originalLineCount).toBe(20); + expect(result.linesShown).toEqual([11, 20]); + }); + it('should handle limit exceeding file length', async () => { const lines = ['Line 1', 'Line 2']; actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index c016cd4a..96f4b36c 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -299,7 +299,8 @@ export async function processSingleFileContent( return line; }); - const contentRangeTruncated = endLine < originalLineCount; + const contentRangeTruncated = + startLine > 0 || endLine < originalLineCount; const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; let llmTextContent = ''; From f2d6748432221d3956024b3f2f5aec9fc927aa34 Mon Sep 17 00:00:00 2001 From: Alexander J <741037+jaegeral@users.noreply.github.com> Date: Tue, 5 Aug 2025 21:04:10 +0200 Subject: [PATCH 103/136] fix: small typo in ROADMAP.md (#5593) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9c47a4dd..5e182a5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ find initiatives that interest you. Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved: - **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with. -- **Report Bugs:** If you find an issue, please create a bug(https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priorty/p0`. +- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`. - **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml). - **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors. - **Write Documentation:** Help us improve our documentation, tutorials, and examples. From b4651452293295020874dfb6ba6707a47c555175 Mon Sep 17 00:00:00 2001 From: Oleksandr Gotgelf Date: Tue, 5 Aug 2025 21:10:16 +0200 Subject: [PATCH 104/136] chore(settings): clean up comments in settings.ts (#5576) --- packages/cli/src/config/settings.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 722af628..bb8c87b8 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -380,7 +380,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings { const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); - // FIX: Resolve paths to their canonical representation to handle symlinks + // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); const resolvedHomeDir = path.resolve(homedir()); @@ -435,7 +435,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings { }); } - // This comparison is now much more reliable. if (realWorkspaceDir !== realHomeDir) { // Load workspace settings try { From 2778c7d851740631b4dbacf907b63db26a6e1816 Mon Sep 17 00:00:00 2001 From: Luccas Paroni Date: Tue, 5 Aug 2025 16:19:47 -0300 Subject: [PATCH 105/136] feat(core): Parse Multimodal MCP Tool responses (#5529) Co-authored-by: Luccas Paroni --- docs/core/tools-api.md | 4 +- docs/tools/mcp-server.md | 50 +++ packages/core/src/tools/mcp-tool.test.ts | 369 ++++++++++++++++++++++- packages/core/src/tools/mcp-tool.ts | 213 +++++++++---- 4 files changed, 580 insertions(+), 56 deletions(-) diff --git a/docs/core/tools-api.md b/docs/core/tools-api.md index e10333d2..0b52191c 100644 --- a/docs/core/tools-api.md +++ b/docs/core/tools-api.md @@ -15,9 +15,11 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg - `execute()`: The core method that performs the tool's action and returns a `ToolResult`. - **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome: - - `llmContent`: The factual string content to be included in the history sent back to the LLM for context. + - `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content. - `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI. +- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content. + - **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for: - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`). - **Discovering Tools:** It can also discover tools dynamically: diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 050e10e8..1222c693 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -571,6 +571,56 @@ The MCP integration tracks several states: This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use. +## Returning Rich Content from Tools + +MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn. + +All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information. + +### How It Works + +To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model. + +You can mix and match different content block types in the `content` array. The supported block types include: + +- `text` +- `image` +- `audio` +- `resource` (embedded content) +- `resource_link` + +### Example: Returning Text and an Image + +Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image: + +```json +{ + "content": [ + { + "type": "text", + "text": "Here is the logo you requested." + }, + { + "type": "image", + "data": "BASE64_ENCODED_IMAGE_DATA_HERE", + "mimeType": "image/png" + }, + { + "type": "text", + "text": "The logo was created in 2025." + } + ] +} +``` + +When the Gemini CLI receives this response, it will: + +1. Extract all the text and combine it into a single `functionResponse` part for the model. +2. Present the image data as a separate `inlineData` part. +3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received. + +This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model. + ## MCP Prompts as Slash Commands In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name. diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index b5843b95..f8a9a8ba 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -131,8 +131,11 @@ describe('DiscoveredMCPTool', () => { success: true, details: 'executed', }; - const mockFunctionResponseContent: Part[] = [ - { text: JSON.stringify(mockToolSuccessResultObject) }, + const mockFunctionResponseContent = [ + { + type: 'text', + text: JSON.stringify(mockToolSuccessResultObject), + }, ]; const mockMcpToolResponseParts: Part[] = [ { @@ -149,11 +152,13 @@ describe('DiscoveredMCPTool', () => { expect(mockCallTool).toHaveBeenCalledWith([ { name: serverToolName, args: params }, ]); - expect(toolResult.llmContent).toEqual(mockMcpToolResponseParts); const stringifiedResponseContent = JSON.stringify( mockToolSuccessResultObject, ); + expect(toolResult.llmContent).toEqual([ + { text: stringifiedResponseContent }, + ]); expect(toolResult.returnDisplay).toBe(stringifiedResponseContent); }); @@ -170,6 +175,9 @@ describe('DiscoveredMCPTool', () => { mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty); const toolResult: ToolResult = await tool.execute(params); expect(toolResult.returnDisplay).toBe('```json\n[]\n```'); + expect(toolResult.llmContent).toEqual([ + { text: '[Error: Could not parse tool response]' }, + ]); }); it('should propagate rejection if mcpTool.callTool rejects', async () => { @@ -186,6 +194,361 @@ describe('DiscoveredMCPTool', () => { await expect(tool.execute(params)).rejects.toThrow(expectedError); }); + + it('should handle a simple text response correctly', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { query: 'test' }; + const successMessage = 'This is a success message.'; + + // Simulate the response from the GenAI SDK, which wraps the MCP + // response in a functionResponse Part. + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + // The `content` array contains MCP ContentBlocks. + content: [{ type: 'text', text: successMessage }], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + // 1. Assert that the llmContent sent to the scheduler is a clean Part array. + expect(toolResult.llmContent).toEqual([{ text: successMessage }]); + + // 2. Assert that the display output is the simple text message. + expect(toolResult.returnDisplay).toBe(successMessage); + + // 3. Verify that the underlying callTool was made correctly. + expect(mockCallTool).toHaveBeenCalledWith([ + { name: serverToolName, args: params }, + ]); + }); + + it('should handle an AudioBlock response', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { action: 'play' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { + type: 'audio', + data: 'BASE64_AUDIO_DATA', + mimeType: 'audio/mp3', + }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { + text: `[Tool '${serverToolName}' provided the following audio data with mime-type: audio/mp3]`, + }, + { + inlineData: { + mimeType: 'audio/mp3', + data: 'BASE64_AUDIO_DATA', + }, + }, + ]); + expect(toolResult.returnDisplay).toBe('[Audio: audio/mp3]'); + }); + + it('should handle a ResourceLinkBlock response', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { resource: 'get' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { + type: 'resource_link', + uri: 'file:///path/to/thing', + name: 'resource-name', + title: 'My Resource', + }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { + text: 'Resource Link: My Resource at file:///path/to/thing', + }, + ]); + expect(toolResult.returnDisplay).toBe( + '[Link to My Resource: file:///path/to/thing]', + ); + }); + + it('should handle an embedded text ResourceBlock response', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { resource: 'get' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { + type: 'resource', + resource: { + uri: 'file:///path/to/text.txt', + text: 'This is the text content.', + mimeType: 'text/plain', + }, + }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { text: 'This is the text content.' }, + ]); + expect(toolResult.returnDisplay).toBe('This is the text content.'); + }); + + it('should handle an embedded binary ResourceBlock response', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { resource: 'get' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { + type: 'resource', + resource: { + uri: 'file:///path/to/data.bin', + blob: 'BASE64_BINARY_DATA', + mimeType: 'application/octet-stream', + }, + }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { + text: `[Tool '${serverToolName}' provided the following embedded resource with mime-type: application/octet-stream]`, + }, + { + inlineData: { + mimeType: 'application/octet-stream', + data: 'BASE64_BINARY_DATA', + }, + }, + ]); + expect(toolResult.returnDisplay).toBe( + '[Embedded Resource: application/octet-stream]', + ); + }); + + it('should handle a mix of content block types', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { action: 'complex' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { type: 'text', text: 'First part.' }, + { + type: 'image', + data: 'BASE64_IMAGE_DATA', + mimeType: 'image/jpeg', + }, + { type: 'text', text: 'Second part.' }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { text: 'First part.' }, + { + text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`, + }, + { + inlineData: { + mimeType: 'image/jpeg', + data: 'BASE64_IMAGE_DATA', + }, + }, + { text: 'Second part.' }, + ]); + expect(toolResult.returnDisplay).toBe( + 'First part.\n[Image: image/jpeg]\nSecond part.', + ); + }); + + it('should ignore unknown content block types', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { action: 'test' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { type: 'text', text: 'Valid part.' }, + { type: 'future_block', data: 'some-data' }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]); + expect(toolResult.returnDisplay).toBe( + 'Valid part.\n[Unknown content type: future_block]', + ); + }); + + it('should handle a complex mix of content block types', async () => { + const tool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + ); + const params = { action: 'super-complex' }; + const sdkResponse: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [ + { type: 'text', text: 'Here is a resource.' }, + { + type: 'resource_link', + uri: 'file:///path/to/resource', + name: 'resource-name', + title: 'My Resource', + }, + { + type: 'resource', + resource: { + uri: 'file:///path/to/text.txt', + text: 'Embedded text content.', + mimeType: 'text/plain', + }, + }, + { + type: 'image', + data: 'BASE64_IMAGE_DATA', + mimeType: 'image/jpeg', + }, + ], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(sdkResponse); + + const toolResult = await tool.execute(params); + + expect(toolResult.llmContent).toEqual([ + { text: 'Here is a resource.' }, + { + text: 'Resource Link: My Resource at file:///path/to/resource', + }, + { text: 'Embedded text content.' }, + { + text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`, + }, + { + inlineData: { + mimeType: 'image/jpeg', + data: 'BASE64_IMAGE_DATA', + }, + }, + ]); + expect(toolResult.returnDisplay).toBe( + 'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]', + ); + }); }); describe('shouldConfirmExecute', () => { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 9e814bba..3dd62e2b 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -22,6 +22,40 @@ import { type ToolParams = Record; +// Discriminated union for MCP Content Blocks to ensure type safety. +type McpTextBlock = { + type: 'text'; + text: string; +}; + +type McpMediaBlock = { + type: 'image' | 'audio'; + mimeType: string; + data: string; +}; + +type McpResourceBlock = { + type: 'resource'; + resource: { + text?: string; + blob?: string; + mimeType?: string; + }; +}; + +type McpResourceLinkBlock = { + type: 'resource_link'; + uri: string; + title?: string; + name?: string; +}; + +type McpContentBlock = + | McpTextBlock + | McpMediaBlock + | McpResourceBlock + | McpResourceLinkBlock; + export class DiscoveredMCPTool extends BaseTool { private static readonly allowlist: Set = new Set(); @@ -114,70 +148,145 @@ export class DiscoveredMCPTool extends BaseTool { }, ]; - const responseParts: Part[] = await this.mcpTool.callTool(functionCalls); + const rawResponseParts = await this.mcpTool.callTool(functionCalls); + const transformedParts = transformMcpContentToParts(rawResponseParts); return { - llmContent: responseParts, - returnDisplay: getStringifiedResultForDisplay(responseParts), + llmContent: transformedParts, + returnDisplay: getStringifiedResultForDisplay(rawResponseParts), }; } } -/** - * Processes an array of `Part` objects, primarily from a tool's execution result, - * to generate a user-friendly string representation, typically for display in a CLI. - * - * The `result` array can contain various types of `Part` objects: - * 1. `FunctionResponse` parts: - * - If the `response.content` of a `FunctionResponse` is an array consisting solely - * of `TextPart` objects, their text content is concatenated into a single string. - * This is to present simple textual outputs directly. - * - If `response.content` is an array but contains other types of `Part` objects (or a mix), - * the `content` array itself is preserved. This handles structured data like JSON objects or arrays - * returned by a tool. - * - If `response.content` is not an array or is missing, the entire `functionResponse` - * object is preserved. - * 2. Other `Part` types (e.g., `TextPart` directly in the `result` array): - * - These are preserved as is. - * - * All processed parts are then collected into an array, which is JSON.stringify-ed - * with indentation and wrapped in a markdown JSON code block. - */ -function getStringifiedResultForDisplay(result: Part[]) { - if (!result || result.length === 0) { - return '```json\n[]\n```'; +function transformTextBlock(block: McpTextBlock): Part { + return { text: block.text }; +} + +function transformImageAudioBlock( + block: McpMediaBlock, + toolName: string, +): Part[] { + return [ + { + text: `[Tool '${toolName}' provided the following ${ + block.type + } data with mime-type: ${block.mimeType}]`, + }, + { + inlineData: { + mimeType: block.mimeType, + data: block.data, + }, + }, + ]; +} + +function transformResourceBlock( + block: McpResourceBlock, + toolName: string, +): Part | Part[] | null { + const resource = block.resource; + if (resource?.text) { + return { text: resource.text }; } + if (resource?.blob) { + const mimeType = resource.mimeType || 'application/octet-stream'; + return [ + { + text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`, + }, + { + inlineData: { + mimeType, + data: resource.blob, + }, + }, + ]; + } + return null; +} - const processFunctionResponse = (part: Part) => { - if (part.functionResponse) { - const responseContent = part.functionResponse.response?.content; - if (responseContent && Array.isArray(responseContent)) { - // Check if all parts in responseContent are simple TextParts - const allTextParts = responseContent.every( - (p: Part) => p.text !== undefined, - ); - if (allTextParts) { - return responseContent.map((p: Part) => p.text).join(''); - } - // If not all simple text parts, return the array of these content parts for JSON stringification - return responseContent; - } - - // If no content, or not an array, or not a functionResponse, stringify the whole functionResponse part for inspection - return part.functionResponse; - } - return part; // Fallback for unexpected structure or non-FunctionResponsePart +function transformResourceLinkBlock(block: McpResourceLinkBlock): Part { + return { + text: `Resource Link: ${block.title || block.name} at ${block.uri}`, }; +} - const processedResults = - result.length === 1 - ? processFunctionResponse(result[0]) - : result.map(processFunctionResponse); - if (typeof processedResults === 'string') { - return processedResults; +/** + * Transforms the raw MCP content blocks from the SDK response into a + * standard GenAI Part array. + * @param sdkResponse The raw Part[] array from `mcpTool.callTool()`. + * @returns A clean Part[] array ready for the scheduler. + */ +function transformMcpContentToParts(sdkResponse: Part[]): Part[] { + const funcResponse = sdkResponse?.[0]?.functionResponse; + const mcpContent = funcResponse?.response?.content as McpContentBlock[]; + const toolName = funcResponse?.name || 'unknown tool'; + + if (!Array.isArray(mcpContent)) { + return [{ text: '[Error: Could not parse tool response]' }]; } - return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```'; + const transformed = mcpContent.flatMap( + (block: McpContentBlock): Part | Part[] | null => { + switch (block.type) { + case 'text': + return transformTextBlock(block); + case 'image': + case 'audio': + return transformImageAudioBlock(block, toolName); + case 'resource': + return transformResourceBlock(block, toolName); + case 'resource_link': + return transformResourceLinkBlock(block); + default: + return null; + } + }, + ); + + return transformed.filter((part): part is Part => part !== null); +} + +/** + * Processes the raw response from the MCP tool to generate a clean, + * human-readable string for display in the CLI. It summarizes non-text + * content and presents text directly. + * + * @param rawResponse The raw Part[] array from the GenAI SDK. + * @returns A formatted string representing the tool's output. + */ +function getStringifiedResultForDisplay(rawResponse: Part[]): string { + const mcpContent = rawResponse?.[0]?.functionResponse?.response + ?.content as McpContentBlock[]; + + if (!Array.isArray(mcpContent)) { + return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```'; + } + + const displayParts = mcpContent.map((block: McpContentBlock): string => { + switch (block.type) { + case 'text': + return block.text; + case 'image': + return `[Image: ${block.mimeType}]`; + case 'audio': + return `[Audio: ${block.mimeType}]`; + case 'resource_link': + return `[Link to ${block.title || block.name}: ${block.uri}]`; + case 'resource': + if (block.resource?.text) { + return block.resource.text; + } + return `[Embedded Resource: ${ + block.resource?.mimeType || 'unknown type' + }]`; + default: + return `[Unknown content type: ${(block as { type: string }).type}]`; + } + }); + + return displayParts.join('\n'); } /** Visible for testing */ From d421fa9e64d44981bfaabf98deb3ff5e5fc5d0eb Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 15:55:50 -0400 Subject: [PATCH 106/136] Testing basic velocity report action --- .github/workflows/scripts/generate-report.sh | 23 ++++++++++++ .github/workflows/weekly-velocity-report.yml | 39 ++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 .github/workflows/scripts/generate-report.sh create mode 100644 .github/workflows/weekly-velocity-report.yml diff --git a/.github/workflows/scripts/generate-report.sh b/.github/workflows/scripts/generate-report.sh new file mode 100644 index 00000000..04879117 --- /dev/null +++ b/.github/workflows/scripts/generate-report.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# --- Configuration --- +USERNAMES=("davideast" "hugomurillomtz" "jakemac53" "richieforeman" "shishu314" "shrutip90" "i14h" "hritan") + +# --- Date Calculation --- +START_DATE=$(date -d "last Friday" +%Y-%m-%d) +END_DATE=$(date -d "last Thursday" +%Y-%m-%d) +DATE_RANGE="${START_DATE}..${END_DATE}" + +# --- Report Generation --- +# Print a header row for the CSV +echo "Date Range,Username,PRs Submitted,Issues Closed" + +# Loop through each user and generate a data row +for USER in "${USERNAMES[@]}"; do + # Get metrics using the GitHub CLI + PRS_SUBMITTED=$(gh pr list --author "${USER}" --search "created:${DATE_RANGE}" --repo "${GITHUB_REPO}" --json number --jq 'length') + ISSUES_CLOSED=$(gh issue list --search 'closer:"${USER}" closed:${DATE_RANGE}' --repo "${GITHUB_REPO}" --json number --jq 'length') + + # Print the data as a CSV row + echo "${START_DATE} to ${END_DATE},${USER},${PRS_SUBMITTED},${ISSUES_CLOSED}" +done diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml new file mode 100644 index 00000000..858c1128 --- /dev/null +++ b/.github/workflows/weekly-velocity-report.yml @@ -0,0 +1,39 @@ +# .github/workflows/weekly-velocity-report.yml + +name: Weekly Velocity Report + +on: + schedule: + #- cron: "0 13 * * 1" # Runs every Monday at 9:00 AM UTC + - cron: "*/5 * * * *" # Test by running every 5 minutes + workflow_dispatch: + +jobs: + generate_report: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Generate Weekly Report as CSV + id: report + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + GITHUB_REPO: ${{ github.repository }} + run: | + chmod +x ./.github/workflows/scripts/generate-report.sh + REPORT_CSV=$(./.github/workflows/scripts/generate-report.sh) + echo "csv_data<> $GITHUB_OUTPUT + echo "$REPORT_CSV" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Append data to Google Sheet + if: success() + uses: jroehl/gcp-google-sheets-action@v2.0.0 + with: + gcp_sa_key: ${{ secrets.GCP_SA_KEY }} + spreadsheet_id: ${{ secrets.SPREADSHEET_ID }} + sheet_name: "Weekly Reports" # The name of the tab in your sheet + data: ${{ steps.report.outputs.csv_data }} + major_dimension: "ROWS" + value_input_option: "USER_ENTERED" From c194a6ac3b91ccc37a157c7056c6a942897d700a Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 16:33:59 -0400 Subject: [PATCH 107/136] GitHub Action for velocity reporting purposes (#5607) --- .github/workflows/scripts/generate-report.sh | 8 ++++---- .github/workflows/weekly-velocity-report.yml | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/scripts/generate-report.sh b/.github/workflows/scripts/generate-report.sh index 04879117..62dfb18c 100644 --- a/.github/workflows/scripts/generate-report.sh +++ b/.github/workflows/scripts/generate-report.sh @@ -1,8 +1,5 @@ #!/bin/bash -# --- Configuration --- -USERNAMES=("davideast" "hugomurillomtz" "jakemac53" "richieforeman" "shishu314" "shrutip90" "i14h" "hritan") - # --- Date Calculation --- START_DATE=$(date -d "last Friday" +%Y-%m-%d) END_DATE=$(date -d "last Thursday" +%Y-%m-%d) @@ -12,8 +9,11 @@ DATE_RANGE="${START_DATE}..${END_DATE}" # Print a header row for the CSV echo "Date Range,Username,PRs Submitted,Issues Closed" +# Get a list of all repository contributors, filter out bots, and extract the login name +USERNAMES=$(gh repo contributors --repo "${GITHUB_REPO}" --json "login" --jq '.[] | select(.login | contains("[bot]") | not) | .login') + # Loop through each user and generate a data row -for USER in "${USERNAMES[@]}"; do +for USER in $USERNAMES; do # Get metrics using the GitHub CLI PRS_SUBMITTED=$(gh pr list --author "${USER}" --search "created:${DATE_RANGE}" --repo "${GITHUB_REPO}" --json number --jq 'length') ISSUES_CLOSED=$(gh issue list --search 'closer:"${USER}" closed:${DATE_RANGE}' --repo "${GITHUB_REPO}" --json number --jq 'length') diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 858c1128..6dfb7d77 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -5,7 +5,7 @@ name: Weekly Velocity Report on: schedule: #- cron: "0 13 * * 1" # Runs every Monday at 9:00 AM UTC - - cron: "*/5 * * * *" # Test by running every 5 minutes + - cron: '*/5 * * * *' # Test by running every 5 minutes workflow_dispatch: jobs: @@ -29,11 +29,11 @@ jobs: - name: Append data to Google Sheet if: success() - uses: jroehl/gcp-google-sheets-action@v2.0.0 + uses: jroehl/gsheet.action@v2.0.0 with: gcp_sa_key: ${{ secrets.GCP_SA_KEY }} spreadsheet_id: ${{ secrets.SPREADSHEET_ID }} - sheet_name: "Weekly Reports" # The name of the tab in your sheet + sheet_name: 'Weekly Reports' # The name of the tab in your sheet data: ${{ steps.report.outputs.csv_data }} - major_dimension: "ROWS" - value_input_option: "USER_ENTERED" + major_dimension: 'ROWS' + value_input_option: 'USER_ENTERED' From 3dcca317961ee41c9d31ca1729449697ccb89c53 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 17:00:44 -0400 Subject: [PATCH 108/136] Update weekly-velocity-report.yml --- .github/workflows/weekly-velocity-report.yml | 22 ++++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 6dfb7d77..7d11af5f 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -4,8 +4,7 @@ name: Weekly Velocity Report on: schedule: - #- cron: "0 13 * * 1" # Runs every Monday at 9:00 AM UTC - - cron: '*/5 * * * *' # Test by running every 5 minutes + - cron: "0 9 * * 1" # Runs every Monday at 9:00 AM UTC workflow_dispatch: jobs: @@ -29,11 +28,16 @@ jobs: - name: Append data to Google Sheet if: success() - uses: jroehl/gsheet.action@v2.0.0 + uses: jroehl/gsheet-action@v2.0.0 with: - gcp_sa_key: ${{ secrets.GCP_SA_KEY }} - spreadsheet_id: ${{ secrets.SPREADSHEET_ID }} - sheet_name: 'Weekly Reports' # The name of the tab in your sheet - data: ${{ steps.report.outputs.csv_data }} - major_dimension: 'ROWS' - value_input_option: 'USER_ENTERED' + spreadsheetId: ${{ secrets.SPREADSHEET_ID }} + commands: | + [ + { + "command": "appendData", + "params": { + "sheetName": "Weekly Reports", + "data": "${{ steps.report.outputs.csv_data }}" + } + } + ] From dc7b4fda642a0025760cc77f49da0f4d03af3506 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 17:08:22 -0400 Subject: [PATCH 109/136] Update weekly-velocity-report.yml --- .github/workflows/weekly-velocity-report.yml | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 7d11af5f..852da9c4 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -28,16 +28,9 @@ jobs: - name: Append data to Google Sheet if: success() - uses: jroehl/gsheet-action@v2.0.0 + uses: gautamkrishnar/append-csv-to-google-sheet-action@v2 with: - spreadsheetId: ${{ secrets.SPREADSHEET_ID }} - commands: | - [ - { - "command": "appendData", - "params": { - "sheetName": "Weekly Reports", - "data": "${{ steps.report.outputs.csv_data }}" - } - } - ] + sheet-name: "Weekly Reports" # The name of the tab in your sheet + csv-text: ${{ steps.report.outputs.csv_data }} + spreadsheet-id: ${{ secrets.SPREADSHEET_ID }} + google-api-key-base64: ${{ secrets.GCP_SA_KEY }} From 47de37eb0a6470843218b8accc6344391717052b Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 17:10:37 -0400 Subject: [PATCH 110/136] Update weekly-velocity-report.yml --- .github/workflows/weekly-velocity-report.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 852da9c4..7d11af5f 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -28,9 +28,16 @@ jobs: - name: Append data to Google Sheet if: success() - uses: gautamkrishnar/append-csv-to-google-sheet-action@v2 + uses: jroehl/gsheet-action@v2.0.0 with: - sheet-name: "Weekly Reports" # The name of the tab in your sheet - csv-text: ${{ steps.report.outputs.csv_data }} - spreadsheet-id: ${{ secrets.SPREADSHEET_ID }} - google-api-key-base64: ${{ secrets.GCP_SA_KEY }} + spreadsheetId: ${{ secrets.SPREADSHEET_ID }} + commands: | + [ + { + "command": "appendData", + "params": { + "sheetName": "Weekly Reports", + "data": "${{ steps.report.outputs.csv_data }}" + } + } + ] From 57003ca68c68430459d356f7be137357d28dda79 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 17:18:19 -0400 Subject: [PATCH 111/136] Update weekly-velocity-report.yml --- .github/workflows/weekly-velocity-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 7d11af5f..16991191 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -28,7 +28,7 @@ jobs: - name: Append data to Google Sheet if: success() - uses: jroehl/gsheet-action@v2.0.0 + uses: jroehl/gsheet-action@v2.1.1 with: spreadsheetId: ${{ secrets.SPREADSHEET_ID }} commands: | From 8d993156e74b3b57edfd120547beb7ba052b0053 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Tue, 5 Aug 2025 14:38:43 -0700 Subject: [PATCH 112/136] Fix format (#5617) --- .github/workflows/weekly-velocity-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 16991191..e6265481 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -4,7 +4,7 @@ name: Weekly Velocity Report on: schedule: - - cron: "0 9 * * 1" # Runs every Monday at 9:00 AM UTC + - cron: '0 9 * * 1' # Runs every Monday at 9:00 AM UTC workflow_dispatch: jobs: From aacae1de43a202e35ea88ed3ae5829586711f06f Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:55:54 -0700 Subject: [PATCH 113/136] fix(core): prevent UI shift after vim edit (#5315) --- packages/cli/src/ui/App.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 2 + .../cli/src/ui/hooks/useReactToolScheduler.ts | 3 + .../core/src/core/coreToolScheduler.test.ts | 4 ++ packages/core/src/core/coreToolScheduler.ts | 4 ++ .../core/src/tools/modifiable-tool.test.ts | 9 +++ packages/core/src/tools/modifiable-tool.ts | 3 +- packages/core/src/utils/editor.test.ts | 55 +++++++++++++++---- packages/core/src/utils/editor.ts | 15 +++-- 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f07a5386..66396c36 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -505,6 +505,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { performMemoryRefresh, modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, + refreshStatic, ); // Input handling diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index e53e77dc..63ba961f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -93,6 +93,7 @@ export const useGeminiStream = ( performMemoryRefresh: () => Promise, modelSwitchedFromQuotaError: boolean, setModelSwitchedFromQuotaError: React.Dispatch>, + onEditorClose: () => void, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -133,6 +134,7 @@ export const useGeminiStream = ( config, setPendingHistoryItem, getPreferredEditor, + onEditorClose, ); const pendingToolCallGroupDisplay = useMemo( diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 307a90cf..01993650 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -70,6 +70,7 @@ export function useReactToolScheduler( React.SetStateAction >, getPreferredEditor: () => EditorType | undefined, + onEditorClose: () => void, ): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] { const [toolCallsForDisplay, setToolCallsForDisplay] = useState< TrackedToolCall[] @@ -140,6 +141,7 @@ export function useReactToolScheduler( onToolCallsUpdate: toolCallsUpdateHandler, getPreferredEditor, config, + onEditorClose, }), [ config, @@ -147,6 +149,7 @@ export function useReactToolScheduler( allToolCallsCompleteHandler, toolCallsUpdateHandler, getPreferredEditor, + onEditorClose, ], ); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 80651a14..623fb436 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -136,6 +136,7 @@ describe('CoreToolScheduler', () => { onAllToolCallsComplete, onToolCallsUpdate, getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), }); const abortController = new AbortController(); @@ -205,6 +206,7 @@ describe('CoreToolScheduler with payload', () => { onAllToolCallsComplete, onToolCallsUpdate, getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), }); const abortController = new AbortController(); @@ -482,6 +484,7 @@ describe('CoreToolScheduler edit cancellation', () => { onAllToolCallsComplete, onToolCallsUpdate, getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), }); const abortController = new AbortController(); @@ -571,6 +574,7 @@ describe('CoreToolScheduler YOLO mode', () => { onAllToolCallsComplete, onToolCallsUpdate, getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), }); const abortController = new AbortController(); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index b4c10a64..5f2cc895 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -224,6 +224,7 @@ interface CoreToolSchedulerOptions { onToolCallsUpdate?: ToolCallsUpdateHandler; getPreferredEditor: () => EditorType | undefined; config: Config; + onEditorClose: () => void; } export class CoreToolScheduler { @@ -234,6 +235,7 @@ export class CoreToolScheduler { private onToolCallsUpdate?: ToolCallsUpdateHandler; private getPreferredEditor: () => EditorType | undefined; private config: Config; + private onEditorClose: () => void; constructor(options: CoreToolSchedulerOptions) { this.config = options.config; @@ -242,6 +244,7 @@ export class CoreToolScheduler { this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onToolCallsUpdate = options.onToolCallsUpdate; this.getPreferredEditor = options.getPreferredEditor; + this.onEditorClose = options.onEditorClose; } private setStatusInternal( @@ -563,6 +566,7 @@ export class CoreToolScheduler { modifyContext as ModifyContext, editorType, signal, + this.onEditorClose, ); this.setArgsInternal(callId, updatedParams); this.setStatusInternal(callId, 'awaiting_approval', { diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts index 47cf41fe..eb7e8dbf 100644 --- a/packages/core/src/tools/modifiable-tool.test.ts +++ b/packages/core/src/tools/modifiable-tool.test.ts @@ -94,6 +94,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith( @@ -148,6 +149,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); const stats = await fsp.stat(diffDir); @@ -165,6 +167,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mkdirSpy).not.toHaveBeenCalled(); @@ -183,6 +186,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mockCreatePatch).toHaveBeenCalledWith( @@ -211,6 +215,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mockCreatePatch).toHaveBeenCalledWith( @@ -241,6 +246,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ), ).rejects.toThrow('Editor failed to open'); @@ -267,6 +273,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(consoleErrorSpy).toHaveBeenCalledTimes(2); @@ -290,6 +297,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mockOpenDiff).toHaveBeenCalledOnce(); @@ -311,6 +319,7 @@ describe('modifyWithEditor', () => { mockModifyContext, 'vscode' as EditorType, abortSignal, + vi.fn(), ); expect(mockOpenDiff).toHaveBeenCalledOnce(); diff --git a/packages/core/src/tools/modifiable-tool.ts b/packages/core/src/tools/modifiable-tool.ts index 4f96a49c..42de3eb6 100644 --- a/packages/core/src/tools/modifiable-tool.ts +++ b/packages/core/src/tools/modifiable-tool.ts @@ -138,6 +138,7 @@ export async function modifyWithEditor( modifyContext: ModifyContext, editorType: EditorType, _abortSignal: AbortSignal, + onEditorClose: () => void, ): Promise> { const currentContent = await modifyContext.getCurrentContent(originalParams); const proposedContent = @@ -150,7 +151,7 @@ export async function modifyWithEditor( ); try { - await openDiff(oldPath, newPath, editorType); + await openDiff(oldPath, newPath, editorType, onEditorClose); const result = getUpdatedParams( oldPath, newPath, diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 203223ae..afdc2b24 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -331,7 +331,7 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; expect(spawn).toHaveBeenCalledWith( diffCommand.command, @@ -361,9 +361,9 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( - 'spawn error', - ); + await expect( + openDiff('old.txt', 'new.txt', editor, () => {}), + ).rejects.toThrow('spawn error'); }); it(`should reject if ${editor} exits with non-zero code`, async () => { @@ -375,9 +375,9 @@ describe('editor utils', () => { }), }; (spawn as Mock).mockReturnValue(mockSpawn); - await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( - `${editor} exited with code 1`, - ); + await expect( + openDiff('old.txt', 'new.txt', editor, () => {}), + ).rejects.toThrow(`${editor} exited with code 1`); }); } @@ -385,7 +385,7 @@ describe('editor utils', () => { for (const editor of execSyncEditors) { it(`should call execSync for ${editor} on non-windows`, async () => { Object.defineProperty(process, 'platform', { value: 'linux' }); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); expect(execSync).toHaveBeenCalledTimes(1); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const expectedCommand = `${ @@ -399,7 +399,7 @@ describe('editor utils', () => { it(`should call execSync for ${editor} on windows`, async () => { Object.defineProperty(process, 'platform', { value: 'win32' }); - await openDiff('old.txt', 'new.txt', editor); + await openDiff('old.txt', 'new.txt', editor, () => {}); expect(execSync).toHaveBeenCalledTimes(1); const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const expectedCommand = `${diffCommand.command} ${diffCommand.args.join( @@ -417,11 +417,46 @@ describe('editor utils', () => { .spyOn(console, 'error') .mockImplementation(() => {}); // @ts-expect-error Testing unsupported editor - await openDiff('old.txt', 'new.txt', 'foobar'); + await openDiff('old.txt', 'new.txt', 'foobar', () => {}); expect(consoleErrorSpy).toHaveBeenCalledWith( 'No diff tool available. Install a supported editor.', ); }); + + describe('onEditorClose callback', () => { + it('should call onEditorClose for execSync editors', async () => { + (execSync as Mock).mockReturnValue(Buffer.from(`/usr/bin/`)); + const onEditorClose = vi.fn(); + await openDiff('old.txt', 'new.txt', 'vim', onEditorClose); + expect(execSync).toHaveBeenCalledTimes(1); + expect(onEditorClose).toHaveBeenCalledTimes(1); + }); + + it('should call onEditorClose for execSync editors when an error is thrown', async () => { + (execSync as Mock).mockImplementation(() => { + throw new Error('test error'); + }); + const onEditorClose = vi.fn(); + openDiff('old.txt', 'new.txt', 'vim', onEditorClose); + expect(execSync).toHaveBeenCalledTimes(1); + expect(onEditorClose).toHaveBeenCalledTimes(1); + }); + + it('should not call onEditorClose for spawn editors', async () => { + const onEditorClose = vi.fn(); + const mockSpawn = { + on: vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }), + }; + (spawn as Mock).mockReturnValue(mockSpawn); + await openDiff('old.txt', 'new.txt', 'vscode', onEditorClose); + expect(spawn).toHaveBeenCalledTimes(1); + expect(onEditorClose).not.toHaveBeenCalled(); + }); + }); }); describe('allowEditorTypeInSandbox', () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 704d1cbb..f22297df 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -164,6 +164,7 @@ export async function openDiff( oldPath: string, newPath: string, editor: EditorType, + onEditorClose: () => void, ): Promise { const diffCommand = getDiffCommand(oldPath, newPath, editor); if (!diffCommand) { @@ -206,10 +207,16 @@ export async function openDiff( process.platform === 'win32' ? `${diffCommand.command} ${diffCommand.args.join(' ')}` : `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`; - execSync(command, { - stdio: 'inherit', - encoding: 'utf8', - }); + try { + execSync(command, { + stdio: 'inherit', + encoding: 'utf8', + }); + } catch (e) { + console.error('Error in onEditorClose callback:', e); + } finally { + onEditorClose(); + } break; } From dd85aaa951f8df0af1b6477922b3a5c061b7d1ba Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 5 Aug 2025 14:56:38 -0700 Subject: [PATCH 114/136] bug(core): Fix flaky test by using waitFor. (#5540) Co-authored-by: Sandy Tao --- .../src/ui/components/InputPrompt.test.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 2291b5a1..f050ba07 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -5,6 +5,7 @@ */ import { render } from 'ink-testing-library'; +import { waitFor } from '@testing-library/react'; import { InputPrompt, InputPromptProps } from './InputPrompt.js'; import type { TextBuffer } from './shared/text-buffer.js'; import { Config } from '@google/gemini-cli-core'; @@ -1226,11 +1227,12 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); stdin.write('\x1B'); - await wait(); - const frame = stdout.lastFrame(); - expect(frame).not.toContain('(r:)'); - expect(frame).not.toContain('echo hello'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + + expect(stdout.lastFrame()).not.toContain('echo hello'); unmount(); }); @@ -1240,9 +1242,11 @@ describe('InputPrompt', () => { stdin.write('\x12'); await wait(); stdin.write('\t'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + expect(props.buffer.setText).toHaveBeenCalledWith('echo hello'); unmount(); }); @@ -1253,9 +1257,11 @@ describe('InputPrompt', () => { await wait(); expect(stdout.lastFrame()).toContain('(r:)'); stdin.write('\r'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); + expect(props.onSubmit).toHaveBeenCalledWith('echo hello'); unmount(); }); @@ -1268,9 +1274,10 @@ describe('InputPrompt', () => { await wait(); expect(stdout.lastFrame()).toContain('(r:)'); stdin.write('\x1B'); - await wait(); - expect(stdout.lastFrame()).not.toContain('(r:)'); + await waitFor(() => { + expect(stdout.lastFrame()).not.toContain('(r:)'); + }); expect(props.buffer.text).toBe('initial text'); expect(props.buffer.cursor).toEqual([0, 3]); From faf6a5497a7fd902edb4dfd0941c4157edb62dd5 Mon Sep 17 00:00:00 2001 From: Hiroaki Mitsuyoshi <131056197+flowernotfound@users.noreply.github.com> Date: Wed, 6 Aug 2025 06:58:09 +0900 Subject: [PATCH 115/136] feat(docs): Add /chat delete command in commands.md (#5408) Co-authored-by: Sandy Tao --- docs/cli/commands.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 96f47a2a..c4590f5f 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -27,6 +27,9 @@ Slash commands provide meta-level control over the CLI itself. - **Usage:** `/chat resume ` - **`list`** - **Description:** Lists available tags for chat state resumption. + - **`delete`** + - **Description:** Deletes a saved conversation checkpoint. + - **Usage:** `/chat delete ` - **`/clear`** - **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared. From 29c3825604fdc82b483902bf79f204673e2dfdae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Medrano=20Llamas?= <45878745+rmedranollamas@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:59:31 +0200 Subject: [PATCH 116/136] fix(mcp): clear prompt registry on refresh to prevent duplicates (#5385) Co-authored-by: Jacob Richman Co-authored-by: Sandy Tao --- packages/core/src/prompts/prompt-registry.ts | 18 +++++++++++++ packages/core/src/tools/tool-registry.test.ts | 8 ++++-- packages/core/src/tools/tool-registry.ts | 26 ++++++++++++------- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/core/src/prompts/prompt-registry.ts b/packages/core/src/prompts/prompt-registry.ts index 56699130..a94183ac 100644 --- a/packages/core/src/prompts/prompt-registry.ts +++ b/packages/core/src/prompts/prompt-registry.ts @@ -53,4 +53,22 @@ export class PromptRegistry { } return serverPrompts.sort((a, b) => a.name.localeCompare(b.name)); } + + /** + * Clears all the prompts from the registry. + */ + clear(): void { + this.prompts.clear(); + } + + /** + * Removes all prompts from a specific server. + */ + removePromptsByServer(serverName: string): void { + for (const [name, prompt] of this.prompts.entries()) { + if (prompt.serverName === serverName) { + this.prompts.delete(name); + } + } + } } diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index de7c6309..88b23d84 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -172,6 +172,10 @@ describe('ToolRegistry', () => { ); vi.spyOn(config, 'getMcpServers'); vi.spyOn(config, 'getMcpServerCommand'); + vi.spyOn(config, 'getPromptRegistry').mockReturnValue({ + clear: vi.fn(), + removePromptsByServer: vi.fn(), + } as any); mockDiscoverMcpTools.mockReset().mockResolvedValue(undefined); }); @@ -353,7 +357,7 @@ describe('ToolRegistry', () => { mcpServerConfigVal, undefined, toolRegistry, - undefined, + config.getPromptRegistry(), false, ); }); @@ -376,7 +380,7 @@ describe('ToolRegistry', () => { mcpServerConfigVal, undefined, toolRegistry, - undefined, + config.getPromptRegistry(), false, ); }); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 57627ee0..e60b8f74 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -150,6 +150,14 @@ export class ToolRegistry { this.tools.set(tool.name, tool); } + private removeDiscoveredTools(): void { + for (const tool of this.tools.values()) { + if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) { + this.tools.delete(tool.name); + } + } + } + /** * Discovers tools from project (if available and configured). * Can be called multiple times to update discovered tools. @@ -157,11 +165,9 @@ export class ToolRegistry { */ async discoverAllTools(): Promise { // remove any previously discovered tools - for (const tool of this.tools.values()) { - if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) { - this.tools.delete(tool.name); - } - } + this.removeDiscoveredTools(); + + this.config.getPromptRegistry().clear(); await this.discoverAndRegisterToolsFromCommand(); @@ -182,11 +188,9 @@ export class ToolRegistry { */ async discoverMcpTools(): Promise { // remove any previously discovered tools - for (const tool of this.tools.values()) { - if (tool instanceof DiscoveredMCPTool) { - this.tools.delete(tool.name); - } - } + this.removeDiscoveredTools(); + + this.config.getPromptRegistry().clear(); // discover tools using MCP servers, if configured await discoverMcpTools( @@ -210,6 +214,8 @@ export class ToolRegistry { } } + this.config.getPromptRegistry().removePromptsByServer(serverName); + const mcpServers = this.config.getMcpServers() ?? {}; const serverConfig = mcpServers[serverName]; if (serverConfig) { From dadf05809c4978455a646ab4ef95421fbe758657 Mon Sep 17 00:00:00 2001 From: Mikhail Aksenov Date: Wed, 6 Aug 2025 00:02:16 +0200 Subject: [PATCH 117/136] feat: mcp - support audiences for OAuth2 (#5265) --- docs/tools/mcp-server.md | 1 + packages/core/src/mcp/oauth-provider.test.ts | 2 ++ packages/core/src/mcp/oauth-provider.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+) diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 1222c693..850c228e 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -169,6 +169,7 @@ Use the `/mcp auth` command to manage OAuth authentication: - **`scopes`** (string[]): Required OAuth scopes - **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`) - **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs +- **`audiences`** (string[]): Audiences the token is valid for #### Token Management diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 74eb42c0..3991aecc 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -48,6 +48,7 @@ describe('MCPOAuthProvider', () => { tokenUrl: 'https://auth.example.com/token', scopes: ['read', 'write'], redirectUri: 'http://localhost:7777/oauth/callback', + audiences: ['https://api.example.com'], }; const mockToken: MCPOAuthToken = { @@ -722,6 +723,7 @@ describe('MCPOAuthProvider', () => { expect(capturedUrl!).toContain('code_challenge_method=S256'); expect(capturedUrl!).toContain('scope=read+write'); expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com'); + expect(capturedUrl!).toContain('audience=https%3A%2F%2Fapi.example.com'); }); it('should correctly append parameters to an authorization URL that already has query params', async () => { diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 5052e8af..c86478c6 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -22,6 +22,7 @@ export interface MCPOAuthConfig { authorizationUrl?: string; tokenUrl?: string; scopes?: string[]; + audiences?: string[]; redirectUri?: string; tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token } @@ -297,6 +298,10 @@ export class MCPOAuthProvider { params.append('scope', config.scopes.join(' ')); } + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to authorization URL const resourceUrl = mcpServerUrl || config.authorizationUrl!; @@ -346,6 +351,10 @@ export class MCPOAuthProvider { params.append('client_secret', config.clientSecret); } + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to token URL const resourceUrl = mcpServerUrl || config.tokenUrl!; @@ -404,6 +413,10 @@ export class MCPOAuthProvider { params.append('scope', config.scopes.join(' ')); } + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to token URL const resourceUrl = mcpServerUrl || tokenUrl; From 2e9236fab432eadb186215576069e13f65bde17b Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 18:11:06 -0400 Subject: [PATCH 118/136] Update weekly-velocity-report.yml --- .github/workflows/weekly-velocity-report.yml | 51 ++++++-------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index e6265481..91abc974 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -1,43 +1,24 @@ -# .github/workflows/weekly-velocity-report.yml +name: Test Third-Party Action Access -name: Weekly Velocity Report - -on: - schedule: - - cron: '0 9 * * 1' # Runs every Monday at 9:00 AM UTC - workflow_dispatch: +on: workflow_dispatch jobs: - generate_report: + test: runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: This is an official action that will likely work uses: actions/checkout@v4 - - name: Generate Weekly Report as CSV - id: report - env: - GH_TOKEN: ${{ secrets.GH_PAT }} - GITHUB_REPO: ${{ github.repository }} - run: | - chmod +x ./.github/workflows/scripts/generate-report.sh - REPORT_CSV=$(./.github/workflows/scripts/generate-report.sh) - echo "csv_data<> $GITHUB_OUTPUT - echo "$REPORT_CSV" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Append data to Google Sheet - if: success() - uses: jroehl/gsheet-action@v2.1.1 + - name: This is a very popular, non-official action + uses: actions/setup-node@v4 with: - spreadsheetId: ${{ secrets.SPREADSHEET_ID }} - commands: | - [ - { - "command": "appendData", - "params": { - "sheetName": "Weekly Reports", - "data": "${{ steps.report.outputs.csv_data }}" - } - } - ] + node-version: '20' + + - name: This is the Google Sheets action we want to use + uses: gautamkrishnar/append-csv-to-google-sheet-action@v2 + with: + # We don't need real inputs, we just want to see if it can be found + sheet-name: 'Test' + csv-text: 'test' + spreadsheet-id: 'test' + google-api-key-base64: 'test' From 82fa7a06600a71544a2d0200cb19588b273f21b3 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Tue, 5 Aug 2025 15:32:06 -0700 Subject: [PATCH 119/136] fix(format) Fix format for .github/workflows/weekly-velocity-report.yml (#5622) --- .github/workflows/weekly-velocity-report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml index 91abc974..dc0471bb 100644 --- a/.github/workflows/weekly-velocity-report.yml +++ b/.github/workflows/weekly-velocity-report.yml @@ -13,12 +13,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - + - name: This is the Google Sheets action we want to use uses: gautamkrishnar/append-csv-to-google-sheet-action@v2 with: # We don't need real inputs, we just want to see if it can be found - sheet-name: 'Test' + sheet-name: 'Test' csv-text: 'test' spreadsheet-id: 'test' google-api-key-base64: 'test' From 1b08a6c063da803cb79e7d6df236f32d4b9a6a56 Mon Sep 17 00:00:00 2001 From: xyizko <164354015+xyizko@users.noreply.github.com> Date: Wed, 6 Aug 2025 04:11:27 +0530 Subject: [PATCH 120/136] fix(minor): Grammar and Typos (#5053) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- docs/cli/authentication.md | 2 +- docs/tools/multi-file.md | 2 +- docs/tools/shell.md | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 41612af3..ad531676 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ You have two options to install Gemini CLI. ### With Homebrew 1. **Prerequisites:** Ensure you have [Homebrew](https://brew.sh/) installed. -2. **Install the CLI** Execute the following command in your terminal: +2. **Install the CLI:** Execute the following command in your terminal: ```bash brew install gemini-cli diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index d9adcfb1..564f0da3 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -3,7 +3,7 @@ The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods: 1. **Login with Google (Gemini Code Assist):** - - Use this option to log in with your google account. + - Use this option to log in with your Google account. - During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs. - Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on). - Users may have to specify a GOOGLE_CLOUD_PROJECT if: diff --git a/docs/tools/multi-file.md b/docs/tools/multi-file.md index 1bc495f6..68c1a3ae 100644 --- a/docs/tools/multi-file.md +++ b/docs/tools/multi-file.md @@ -52,7 +52,7 @@ Read the main README, all Markdown files in the `docs` directory, and a specific read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"]) ``` -Read all JavaScript files but explicitly including test files and all JPEGs in an `images` folder: +Read all JavaScript files but explicitly include test files and all JPEGs in an `images` folder: ``` read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False) diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 3e2a00e4..253e0218 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -137,6 +137,5 @@ To block all shell commands, add the `run_shell_command` wildcard to `excludeToo ## Security Note for `excludeTools` -Command-specific restrictions in -`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands +Command-specific restrictions in `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands that can be executed. From bed6ab1ccea8edfb93c4f53590dffeb0f5c1f10a Mon Sep 17 00:00:00 2001 From: William Thurston Date: Tue, 5 Aug 2025 15:43:15 -0700 Subject: [PATCH 121/136] fix(start): use absolute path to resolve CLI package (#3196) Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: Sandy Tao --- scripts/start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/start.js b/scripts/start.js index 5ff1a3ac..ae100f28 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -55,7 +55,7 @@ if (process.env.DEBUG && !sandboxCommand) { } } -nodeArgs.push('./packages/cli'); +nodeArgs.push(join(root, 'packages', 'cli')); nodeArgs.push(...process.argv.slice(2)); const env = { From c402784d97ce771dbd2c4244c3a13ec57da120ab Mon Sep 17 00:00:00 2001 From: 8bitmp3 <19637339+8bitmp3@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:43:41 +0100 Subject: [PATCH 122/136] Fix and improve Gemini CLI troubleshooting.md doc (#2734) Co-authored-by: Sandy Tao --- docs/troubleshooting.md | 55 +++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8c500445..dde2a8ef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,28 +1,38 @@ -# Troubleshooting Guide +# Troubleshooting guide -This guide provides solutions to common issues and debugging tips. +This guide provides solutions to common issues and debugging tips, including topics on: -## Authentication +- Authentication or login errors +- Frequently asked questions (FAQs) +- Debugging tips +- Existing GitHub Issues similar to yours or creating new Issues + +## Authentication or login errors - **Error: `Failed to login. Message: Request contains an invalid argument`** - - Users with Google Workspace accounts, or users with Google Cloud accounts + - Users with Google Workspace accounts or Google Cloud accounts associated with their Gmail accounts may not be able to activate the free tier of the Google Code Assist plan. - For Google Cloud accounts, you can work around this by setting `GOOGLE_CLOUD_PROJECT` to your project ID. - - You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a + - Alternatively, you can obtain the Gemini API key from + [Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a separate free tier. ## Frequently asked questions (FAQs) - **Q: How do I update Gemini CLI to the latest version?** - - A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`. + - A: If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`. -- **Q: Where are Gemini CLI configuration files stored?** - - A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details. +- **Q: Where are the Gemini CLI configuration or settings files stored?** + - A: The Gemini CLI configuration is stored in two `settings.json` files: + 1. In your home directory: `~/.gemini/settings.json`. + 2. In your project's root directory: `./.gemini/settings.json`. + + Refer to [Gemini CLI Configuration](./cli/configuration.md) for more details. - **Q: Why don't I see cached token counts in my stats output?** - - A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command. + - A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI. ## Common error messages and solutions @@ -31,26 +41,27 @@ This guide provides solutions to common issues and debugging tips. - **Solution:** Either stop the other process that is using the port or configure the MCP server to use a different port. -- **Error: Command not found (when attempting to run Gemini CLI).** - - **Cause:** Gemini CLI is not correctly installed or not in your system's PATH. +- **Error: Command not found (when attempting to run Gemini CLI with `gemini`).** + - **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`. - **Solution:** - 1. Ensure Gemini CLI installation was successful. - 2. If installed globally, check that your npm global binary directory is in your PATH. - 3. If running from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). + The update depends on how you installed Gemini CLI: + - If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. + - If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`. - **Error: `MODULE_NOT_FOUND` or import errors.** - **Cause:** Dependencies are not installed correctly, or the project hasn't been built. - **Solution:** 1. Run `npm install` to ensure all dependencies are present. 2. Run `npm run build` to compile the project. + 3. Verify that the build completed successfully with `npm run start`. - **Error: "Operation not permitted", "Permission denied", or similar.** - - **Cause:** If sandboxing is enabled, then the application is likely attempting an operation restricted by your sandbox, such as writing outside the project directory or system temp directory. - - **Solution:** See [Sandboxing](./cli/configuration.md#sandboxing) for more information, including how to customize your sandbox configuration. + - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. + - **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration. -- **CLI is not interactive in "CI" environments** - - **Issue:** The CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. - - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode. +- **Gemini CLI is not running in interactive mode in "CI" environments** + - **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. + - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode. - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini` - **DEBUG mode not working from project .env file** @@ -72,9 +83,11 @@ This guide provides solutions to common issues and debugging tips. - **Tool issues:** - If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs. - For `run_shell_command`, check that the command works directly in your shell first. - - For file system tools, double-check paths and permissions. + - For _file system tools_, verify that paths are correct and check the permissions. - **Pre-flight checks:** - Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors. -If you encounter an issue not covered here, consider searching the project's issue tracker on GitHub or reporting a new issue with detailed information. +## Existing GitHub Issues similar to yours or creating new Issues + +If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! From aebe3ace3c6de9ef03d694f7175dc5b1288a90fd Mon Sep 17 00:00:00 2001 From: sangwook <73056306+Han5991@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:47:18 +0900 Subject: [PATCH 123/136] perf(core): implement parallel file processing for 74% performance improvement (#4763) Co-authored-by: Jacob Richman Co-authored-by: Sandy Tao --- .../core/src/tools/read-many-files.test.ts | 135 +++++++++++++ packages/core/src/tools/read-many-files.ts | 185 +++++++++++++----- 2 files changed, 267 insertions(+), 53 deletions(-) diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 68bb9b0e..6ddd2a08 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -477,4 +477,139 @@ describe('ReadManyFilesTool', () => { fs.rmSync(tempDir2, { recursive: true, force: true }); }); }); + + describe('Batch Processing', () => { + const createMultipleFiles = (count: number, contentPrefix = 'Content') => { + const files: string[] = []; + for (let i = 0; i < count; i++) { + const fileName = `file${i}.txt`; + createFile(fileName, `${contentPrefix} ${i}`); + files.push(fileName); + } + return files; + }; + + const createFile = (filePath: string, content = '') => { + const fullPath = path.join(tempRootDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + }; + + it('should process files in parallel for performance', async () => { + // Mock detectFileType to add artificial delay to simulate I/O + const detectFileTypeSpy = vi.spyOn( + await import('../utils/fileUtils.js'), + 'detectFileType', + ); + + // Create files + const fileCount = 4; + const files = createMultipleFiles(fileCount, 'Batch test'); + + // Mock with 100ms delay per file to simulate I/O operations + detectFileTypeSpy.mockImplementation(async (_filePath: string) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return 'text'; + }); + + const startTime = Date.now(); + const params = { paths: files }; + const result = await tool.execute(params, new AbortController().signal); + const endTime = Date.now(); + + const processingTime = endTime - startTime; + + console.log( + `Processing time: ${processingTime}ms for ${fileCount} files`, + ); + + // Verify parallel processing performance improvement + // Parallel processing should complete in ~100ms (single file time) + // Sequential would take ~400ms (4 files × 100ms each) + expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation + + // Verify all files were processed + const content = result.llmContent as string[]; + expect(content).toHaveLength(fileCount); + + // Cleanup mock + detectFileTypeSpy.mockRestore(); + }); + + it('should handle batch processing errors gracefully', async () => { + // Create mix of valid and problematic files + createFile('valid1.txt', 'Valid content 1'); + createFile('valid2.txt', 'Valid content 2'); + createFile('valid3.txt', 'Valid content 3'); + + const params = { + paths: [ + 'valid1.txt', + 'valid2.txt', + 'nonexistent-file.txt', // This will fail + 'valid3.txt', + ], + }; + + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + + // Should successfully process valid files despite one failure + expect(content.length).toBeGreaterThanOrEqual(3); + expect(result.returnDisplay).toContain('Successfully read'); + + // Verify valid files were processed + const expectedPath1 = path.join(tempRootDir, 'valid1.txt'); + const expectedPath3 = path.join(tempRootDir, 'valid3.txt'); + expect(content.some((c) => c.includes(expectedPath1))).toBe(true); + expect(content.some((c) => c.includes(expectedPath3))).toBe(true); + }); + + it('should execute file operations concurrently', async () => { + // Track execution order to verify concurrency + const executionOrder: string[] = []; + const detectFileTypeSpy = vi.spyOn( + await import('../utils/fileUtils.js'), + 'detectFileType', + ); + + const files = ['file1.txt', 'file2.txt', 'file3.txt']; + files.forEach((file) => createFile(file, 'test content')); + + // Mock to track concurrent vs sequential execution + detectFileTypeSpy.mockImplementation(async (filePath: string) => { + const fileName = filePath.split('/').pop() || ''; + executionOrder.push(`start:${fileName}`); + + // Add delay to make timing differences visible + await new Promise((resolve) => setTimeout(resolve, 50)); + + executionOrder.push(`end:${fileName}`); + return 'text'; + }); + + await tool.execute({ paths: files }, new AbortController().signal); + + console.log('Execution order:', executionOrder); + + // Verify concurrent execution pattern + // In parallel execution: all "start:" events should come before all "end:" events + // In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc. + + const startEvents = executionOrder.filter((e) => + e.startsWith('start:'), + ).length; + const firstEndIndex = executionOrder.findIndex((e) => + e.startsWith('end:'), + ); + const startsBeforeFirstEnd = executionOrder + .slice(0, firstEndIndex) + .filter((e) => e.startsWith('start:')).length; + + // For parallel processing, ALL start events should happen before the first end event + expect(startsBeforeFirstEnd).toBe(startEvents); // Should PASS with parallel implementation + + detectFileTypeSpy.mockRestore(); + }); + }); }); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 771577ec..1fa2e15c 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -70,6 +70,27 @@ export interface ReadManyFilesParams { }; } +/** + * Result type for file processing operations + */ +type FileProcessingResult = + | { + success: true; + filePath: string; + relativePathForDisplay: string; + fileReadResult: NonNullable< + Awaited> + >; + reason?: undefined; + } + | { + success: false; + filePath: string; + relativePathForDisplay: string; + fileReadResult?: undefined; + reason: string; + }; + /** * Default exclusion patterns for commonly ignored directories and binary file types. * These are compatible with glob ignore patterns. @@ -413,66 +434,124 @@ Use this tool when the user's query implies needing the content of several files const sortedFiles = Array.from(filesToConsider).sort(); - for (const filePath of sortedFiles) { - const relativePathForDisplay = path - .relative(this.config.getTargetDir(), filePath) - .replace(/\\/g, '/'); + const fileProcessingPromises = sortedFiles.map( + async (filePath): Promise => { + try { + const relativePathForDisplay = path + .relative(this.config.getTargetDir(), filePath) + .replace(/\\/g, '/'); - const fileType = await detectFileType(filePath); + const fileType = await detectFileType(filePath); - if (fileType === 'image' || fileType === 'pdf') { - const fileExtension = path.extname(filePath).toLowerCase(); - const fileNameWithoutExtension = path.basename(filePath, fileExtension); - const requestedExplicitly = inputPatterns.some( - (pattern: string) => - pattern.toLowerCase().includes(fileExtension) || - pattern.includes(fileNameWithoutExtension), - ); + if (fileType === 'image' || fileType === 'pdf') { + const fileExtension = path.extname(filePath).toLowerCase(); + const fileNameWithoutExtension = path.basename( + filePath, + fileExtension, + ); + const requestedExplicitly = inputPatterns.some( + (pattern: string) => + pattern.toLowerCase().includes(fileExtension) || + pattern.includes(fileNameWithoutExtension), + ); - if (!requestedExplicitly) { - skippedFiles.push({ - path: relativePathForDisplay, - reason: - 'asset file (image/pdf) was not explicitly requested by name or extension', - }); - continue; - } - } + if (!requestedExplicitly) { + return { + success: false, + filePath, + relativePathForDisplay, + reason: + 'asset file (image/pdf) was not explicitly requested by name or extension', + }; + } + } - // Use processSingleFileContent for all file types now - const fileReadResult = await processSingleFileContent( - filePath, - this.config.getTargetDir(), - ); - - if (fileReadResult.error) { - skippedFiles.push({ - path: relativePathForDisplay, - reason: `Read error: ${fileReadResult.error}`, - }); - } else { - if (typeof fileReadResult.llmContent === 'string') { - const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( - '{filePath}', + // Use processSingleFileContent for all file types now + const fileReadResult = await processSingleFileContent( filePath, + this.config.getTargetDir(), ); - contentParts.push(`${separator}\n\n${fileReadResult.llmContent}\n\n`); - } else { - contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf + + if (fileReadResult.error) { + return { + success: false, + filePath, + relativePathForDisplay, + reason: `Read error: ${fileReadResult.error}`, + }; + } + + return { + success: true, + filePath, + relativePathForDisplay, + fileReadResult, + }; + } catch (error) { + const relativePathForDisplay = path + .relative(this.config.getTargetDir(), filePath) + .replace(/\\/g, '/'); + + return { + success: false, + filePath, + relativePathForDisplay, + reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + }; } - processedFilesRelativePaths.push(relativePathForDisplay); - const lines = - typeof fileReadResult.llmContent === 'string' - ? fileReadResult.llmContent.split('\n').length - : undefined; - const mimetype = getSpecificMimeType(filePath); - recordFileOperationMetric( - this.config, - FileOperation.READ, - lines, - mimetype, - path.extname(filePath), - ); + }, + ); + + const results = await Promise.allSettled(fileProcessingPromises); + + for (const result of results) { + if (result.status === 'fulfilled') { + const fileResult = result.value; + + if (!fileResult.success) { + // Handle skipped files (images/PDFs not requested or read errors) + skippedFiles.push({ + path: fileResult.relativePathForDisplay, + reason: fileResult.reason, + }); + } else { + // Handle successfully processed files + const { filePath, relativePathForDisplay, fileReadResult } = + fileResult; + + if (typeof fileReadResult.llmContent === 'string') { + const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( + '{filePath}', + filePath, + ); + contentParts.push( + `${separator}\n\n${fileReadResult.llmContent}\n\n`, + ); + } else { + contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf + } + + processedFilesRelativePaths.push(relativePathForDisplay); + + const lines = + typeof fileReadResult.llmContent === 'string' + ? fileReadResult.llmContent.split('\n').length + : undefined; + const mimetype = getSpecificMimeType(filePath); + recordFileOperationMetric( + this.config, + FileOperation.READ, + lines, + mimetype, + path.extname(filePath), + ); + } + } else { + // Handle Promise rejection (unexpected errors) + skippedFiles.push({ + path: 'unknown', + reason: `Unexpected error: ${result.reason}`, + }); } } From 6a72cd064bccb5fda4618671c2da63c4e22c1ef9 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Tue, 5 Aug 2025 15:50:30 -0700 Subject: [PATCH 124/136] check for the prompt capability before listing prompts from MCP servers (#5616) Co-authored-by: Jacob Richman Co-authored-by: Sandy Tao --- packages/core/src/tools/mcp-client.test.ts | 51 ++++++++++++++++++---- packages/core/src/tools/mcp-client.ts | 3 ++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index a8289d3b..9997d60e 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -58,9 +58,7 @@ describe('mcp-client', () => { const mockedClient = {} as unknown as ClientLib.Client; const consoleErrorSpy = vi .spyOn(console, 'error') - .mockImplementation(() => { - // no-op - }); + .mockImplementation(() => {}); const testError = new Error('Invalid tool name'); vi.mocked(DiscoveredMCPTool).mockImplementation( @@ -113,12 +111,17 @@ describe('mcp-client', () => { { name: 'prompt2' }, ], }); + const mockGetServerCapabilities = vi.fn().mockReturnValue({ + prompts: {}, + }); const mockedClient = { + getServerCapabilities: mockGetServerCapabilities, request: mockRequest, } as unknown as ClientLib.Client; await discoverPrompts('test-server', mockedClient, mockedPromptRegistry); + expect(mockGetServerCapabilities).toHaveBeenCalledOnce(); expect(mockRequest).toHaveBeenCalledWith( { method: 'prompts/list', params: {} }, expect.anything(), @@ -129,37 +132,67 @@ describe('mcp-client', () => { const mockRequest = vi.fn().mockResolvedValue({ prompts: [], }); + const mockGetServerCapabilities = vi.fn().mockReturnValue({ + prompts: {}, + }); + const mockedClient = { + getServerCapabilities: mockGetServerCapabilities, request: mockRequest, } as unknown as ClientLib.Client; const consoleLogSpy = vi .spyOn(console, 'debug') - .mockImplementation(() => { - // no-op - }); + .mockImplementation(() => {}); await discoverPrompts('test-server', mockedClient, mockedPromptRegistry); + expect(mockGetServerCapabilities).toHaveBeenCalledOnce(); expect(mockRequest).toHaveBeenCalledOnce(); expect(consoleLogSpy).not.toHaveBeenCalled(); consoleLogSpy.mockRestore(); }); + it('should do nothing if the server has no prompt support', async () => { + const mockRequest = vi.fn().mockResolvedValue({ + prompts: [], + }); + const mockGetServerCapabilities = vi.fn().mockReturnValue({}); + + const mockedClient = { + getServerCapabilities: mockGetServerCapabilities, + request: mockRequest, + } as unknown as ClientLib.Client; + + const consoleLogSpy = vi + .spyOn(console, 'debug') + .mockImplementation(() => {}); + + await discoverPrompts('test-server', mockedClient, mockedPromptRegistry); + + expect(mockGetServerCapabilities).toHaveBeenCalledOnce(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(consoleLogSpy).not.toHaveBeenCalled(); + + consoleLogSpy.mockRestore(); + }); + it('should log an error if discovery fails', async () => { const testError = new Error('test error'); testError.message = 'test error'; const mockRequest = vi.fn().mockRejectedValue(testError); + const mockGetServerCapabilities = vi.fn().mockReturnValue({ + prompts: {}, + }); const mockedClient = { + getServerCapabilities: mockGetServerCapabilities, request: mockRequest, } as unknown as ClientLib.Client; const consoleErrorSpy = vi .spyOn(console, 'error') - .mockImplementation(() => { - // no-op - }); + .mockImplementation(() => {}); await discoverPrompts('test-server', mockedClient, mockedPromptRegistry); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 00f2197a..26244d9e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -496,6 +496,9 @@ export async function discoverPrompts( promptRegistry: PromptRegistry, ): Promise { try { + // Only request prompts if the server supports them. + if (mcpClient.getServerCapabilities()?.prompts == null) return []; + const response = await mcpClient.request( { method: 'prompts/list', params: {} }, ListPromptsResultSchema, From 268627469b384ba3fa8dfe2e05b5186248013070 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Tue, 5 Aug 2025 18:52:58 -0400 Subject: [PATCH 125/136] Refactor IDE client state management, improve user-facing error messages, and add logging of connection events (#5591) Co-authored-by: matt korwel --- packages/cli/src/config/config.test.ts | 10 - packages/cli/src/config/config.ts | 9 +- .../cli/src/ui/commands/ideCommand.test.ts | 10 +- packages/cli/src/ui/commands/ideCommand.ts | 90 ++++-- packages/core/src/config/config.test.ts | 2 - packages/core/src/config/config.ts | 27 +- .../core/src/config/flashFallback.test.ts | 5 +- packages/core/src/ide/ide-client.ts | 265 +++++++++--------- .../clearcut-logger/clearcut-logger.ts | 14 + .../clearcut-logger/event-metadata-key.ts | 7 + packages/core/src/telemetry/constants.ts | 1 + packages/core/src/telemetry/loggers.ts | 23 ++ packages/core/src/telemetry/telemetry.test.ts | 2 - packages/core/src/telemetry/types.ts | 20 +- packages/core/src/tools/tool-registry.test.ts | 3 +- .../utils/flashFallback.integration.test.ts | 2 - 16 files changed, 285 insertions(+), 205 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index f5d0ddf8..64ecdbb8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1007,16 +1007,6 @@ describe('loadCliConfig ideModeFeature', () => { const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getIdeModeFeature()).toBe(false); }); - - it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.SANDBOX = 'true'; - const settings: Settings = { ideModeFeature: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeModeFeature()).toBe(false); - }); }); vi.mock('fs', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d3d37c6a..beba9602 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -22,7 +22,6 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, - IdeClient, } from '@google/gemini-cli-core'; import { Settings } from './settings.js'; @@ -296,13 +295,10 @@ export async function loadCliConfig( ) || false; const memoryImportFormat = settings.memoryImportFormat || 'tree'; + const ideMode = settings.ideMode ?? false; - const ideModeFeature = - (argv.ideModeFeature ?? settings.ideModeFeature ?? false) && - !process.env.SANDBOX; - - const ideClient = IdeClient.getInstance(ideMode && ideModeFeature); + argv.ideModeFeature ?? settings.ideModeFeature ?? false; const allExtensions = annotateActiveExtensions( extensions, @@ -471,7 +467,6 @@ export async function loadCliConfig( summarizeToolOutput: settings.summarizeToolOutput, ideMode, ideModeFeature, - ideClient, }); } diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 4f2b7af2..9898b1e8 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -42,9 +42,15 @@ describe('ideCommand', () => { mockConfig = { getIdeModeFeature: vi.fn(), getIdeMode: vi.fn(), - getIdeClient: vi.fn(), + getIdeClient: vi.fn(() => ({ + reconnect: vi.fn(), + disconnect: vi.fn(), + getCurrentIde: vi.fn(), + getDetectedIdeDisplayName: vi.fn(), + getConnectionStatus: vi.fn(), + })), + setIdeModeAndSyncConnection: vi.fn(), setIdeMode: vi.fn(), - setIdeClientDisconnected: vi.fn(), } as unknown as Config; platformSpy = vi.spyOn(process, 'platform', 'get'); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index c6d65264..fe9f764a 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -10,6 +10,7 @@ import { IDEConnectionStatus, getIdeDisplayName, getIdeInstaller, + IdeClient, } from '@google/gemini-cli-core'; import { CommandContext, @@ -19,6 +20,35 @@ import { } from './types.js'; import { SettingScope } from '../../config/settings.js'; +function getIdeStatusMessage(ideClient: IdeClient): { + messageType: 'info' | 'error'; + content: string; +} { + const connection = ideClient.getConnectionStatus(); + switch (connection.status) { + case IDEConnectionStatus.Connected: + return { + messageType: 'info', + content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, + }; + case IDEConnectionStatus.Connecting: + return { + messageType: 'info', + content: `🟡 Connecting...`, + }; + default: { + let content = `🔴 Disconnected`; + if (connection?.details) { + content += `: ${connection.details}`; + } + return { + messageType: 'error', + content, + }; + } + } +} + export const ideCommand = (config: Config | null): SlashCommand | null => { if (!config || !config.getIdeModeFeature()) { return null; @@ -54,33 +84,13 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { name: 'status', description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, - action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = ideClient.getConnectionStatus(); - switch (connection.status) { - case IDEConnectionStatus.Connected: - return { - type: 'message', - messageType: 'info', - content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`, - } as const; - case IDEConnectionStatus.Connecting: - return { - type: 'message', - messageType: 'info', - content: `🟡 Connecting...`, - } as const; - default: { - let content = `🔴 Disconnected`; - if (connection?.details) { - content += `: ${connection.details}`; - } - return { - type: 'message', - messageType: 'error', - content, - } as const; - } - } + action: (): SlashCommandActionReturn => { + const { messageType, content } = getIdeStatusMessage(ideClient); + return { + type: 'message', + messageType, + content, + } as const; }, }; @@ -110,6 +120,10 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { ); const result = await installer.install(); + if (result.success) { + config.setIdeMode(true); + context.services.settings.setValue(SettingScope.User, 'ideMode', true); + } context.ui.addItem( { type: result.success ? 'info' : 'error', @@ -126,8 +140,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { context.services.settings.setValue(SettingScope.User, 'ideMode', true); - config.setIdeMode(true); - config.setIdeClientConnected(); + await config.setIdeModeAndSyncConnection(true); + const { messageType, content } = getIdeStatusMessage(ideClient); + context.ui.addItem( + { + type: messageType, + text: content, + }, + Date.now(), + ); }, }; @@ -137,8 +158,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { context.services.settings.setValue(SettingScope.User, 'ideMode', false); - config.setIdeMode(false); - config.setIdeClientDisconnected(); + await config.setIdeModeAndSyncConnection(false); + const { messageType, content } = getIdeStatusMessage(ideClient); + context.ui.addItem( + { + type: messageType, + text: content, + }, + Date.now(), + ); }, }; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index dd50fd41..64692139 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -18,7 +18,6 @@ import { } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; -import { IdeClient } from '../ide/ide-client.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -120,7 +119,6 @@ describe('Server Config (config.ts)', () => { telemetry: TELEMETRY_SETTINGS, sessionId: SESSION_ID, model: MODEL, - ideClient: IdeClient.getInstance(false), }; beforeEach(() => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 22996f3e..fa51a6af 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -48,6 +48,8 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import { IdeClient } from '../ide/ide-client.js'; import type { Content } from '@google/genai'; +import { logIdeConnection } from '../telemetry/loggers.js'; +import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js'; // Re-export OAuth config type export type { MCPOAuthConfig }; @@ -187,7 +189,6 @@ export interface ConfigParameters { summarizeToolOutput?: Record; ideModeFeature?: boolean; ideMode?: boolean; - ideClient: IdeClient; loadMemoryFromIncludeDirectories?: boolean; } @@ -305,7 +306,11 @@ export class Config { this.summarizeToolOutput = params.summarizeToolOutput; this.ideModeFeature = params.ideModeFeature ?? false; this.ideMode = params.ideMode ?? false; - this.ideClient = params.ideClient; + this.ideClient = IdeClient.getInstance(); + if (this.ideMode && this.ideModeFeature) { + this.ideClient.connect(); + logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.START)); + } this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; @@ -633,10 +638,6 @@ export class Config { return this.ideModeFeature; } - getIdeClient(): IdeClient { - return this.ideClient; - } - getIdeMode(): boolean { return this.ideMode; } @@ -645,12 +646,18 @@ export class Config { this.ideMode = value; } - setIdeClientDisconnected(): void { - this.ideClient.setDisconnected(); + async setIdeModeAndSyncConnection(value: boolean): Promise { + this.ideMode = value; + if (value) { + await this.ideClient.connect(); + logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.SESSION)); + } else { + this.ideClient.disconnect(); + } } - setIdeClientConnected(): void { - this.ideClient.reconnect(this.ideMode && this.ideModeFeature); + getIdeClient(): IdeClient { + return this.ideClient; } async getGitService(): Promise { diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 0b68f993..5665a7e0 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from './config.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; -import { IdeClient } from '../ide/ide-client.js'; + import fs from 'node:fs'; vi.mock('node:fs'); @@ -26,7 +26,6 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, - ideClient: IdeClient.getInstance(false), }); // Initialize contentGeneratorConfig for testing @@ -51,7 +50,6 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, - ideClient: IdeClient.getInstance(false), }); // Should not crash when contentGeneratorConfig is undefined @@ -75,7 +73,6 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: 'custom-model', - ideClient: IdeClient.getInstance(false), }); expect(newConfig.getModel()).toBe('custom-model'); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index be24db3e..8f967147 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -33,154 +33,38 @@ export enum IDEConnectionStatus { * Manages the connection to and interaction with the IDE server. */ export class IdeClient { - client: Client | undefined = undefined; + private static instance: IdeClient; + private client: Client | undefined = undefined; private state: IDEConnectionState = { status: IDEConnectionStatus.Disconnected, + details: + 'IDE integration is currently disabled. To enable it, run /ide enable.', }; - private static instance: IdeClient; private readonly currentIde: DetectedIde | undefined; private readonly currentIdeDisplayName: string | undefined; - constructor(ideMode: boolean) { + private constructor() { this.currentIde = detectIde(); if (this.currentIde) { this.currentIdeDisplayName = getIdeDisplayName(this.currentIde); } - if (!ideMode) { - return; - } - this.init().catch((err) => { - logger.debug('Failed to initialize IdeClient:', err); - }); } - static getInstance(ideMode: boolean): IdeClient { + static getInstance(): IdeClient { if (!IdeClient.instance) { - IdeClient.instance = new IdeClient(ideMode); + IdeClient.instance = new IdeClient(); } return IdeClient.instance; } - getCurrentIde(): DetectedIde | undefined { - return this.currentIde; - } - - getConnectionStatus(): IDEConnectionState { - return this.state; - } - - private setState(status: IDEConnectionStatus, details?: string) { - this.state = { status, details }; - - if (status === IDEConnectionStatus.Disconnected) { - logger.debug('IDE integration is disconnected. ', details); - ideContext.clearIdeContext(); - } - } - - private getPortFromEnv(): string | undefined { - const port = process.env['GEMINI_CLI_IDE_SERVER_PORT']; - if (!port) { - this.setState( - IDEConnectionStatus.Disconnected, - 'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.', - ); - return undefined; - } - return port; - } - - private validateWorkspacePath(): boolean { - const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; - if (!ideWorkspacePath) { - this.setState( - IDEConnectionStatus.Disconnected, - 'IDE integration requires a single workspace folder to be open in the IDE. Please ensure one folder is open and try again.', - ); - return false; - } - if (ideWorkspacePath !== process.cwd()) { - this.setState( - IDEConnectionStatus.Disconnected, - `Gemini CLI is running in a different directory (${process.cwd()}) from the IDE's open workspace (${ideWorkspacePath}). Please run Gemini CLI in the same directory.`, - ); - return false; - } - return true; - } - - private registerClientHandlers() { - if (!this.client) { - return; - } - - this.client.setNotificationHandler( - IdeContextNotificationSchema, - (notification) => { - ideContext.setIdeContext(notification.params); - }, - ); - - this.client.onerror = (_error) => { - this.setState(IDEConnectionStatus.Disconnected, 'Client error.'); - }; - - this.client.onclose = () => { - this.setState(IDEConnectionStatus.Disconnected, 'Connection closed.'); - }; - } - - async reconnect(ideMode: boolean) { - IdeClient.instance = new IdeClient(ideMode); - } - - private async establishConnection(port: string) { - let transport: StreamableHTTPClientTransport | undefined; - try { - this.client = new Client({ - name: 'streamable-http-client', - // TODO(#3487): use the CLI version here. - version: '1.0.0', - }); - - transport = new StreamableHTTPClientTransport( - new URL(`http://localhost:${port}/mcp`), - ); - - this.registerClientHandlers(); - - await this.client.connect(transport); - - this.setState(IDEConnectionStatus.Connected); - } catch (error) { - this.setState( - IDEConnectionStatus.Disconnected, - `Failed to connect to IDE server: ${error}`, - ); - if (transport) { - try { - await transport.close(); - } catch (closeError) { - logger.debug('Failed to close transport:', closeError); - } - } - } - } - - async init(): Promise { - if (this.state.status === IDEConnectionStatus.Connected) { - return; - } - if (!this.currentIde) { - this.setState( - IDEConnectionStatus.Disconnected, - 'Not running in a supported IDE, skipping connection.', - ); - return; - } - + async connect(): Promise { this.setState(IDEConnectionStatus.Connecting); + if (!this.currentIde || !this.currentIdeDisplayName) { + this.setState(IDEConnectionStatus.Disconnected); + return; + } + if (!this.validateWorkspacePath()) { return; } @@ -193,15 +77,132 @@ export class IdeClient { await this.establishConnection(port); } - dispose() { + disconnect() { + this.setState( + IDEConnectionStatus.Disconnected, + 'IDE integration disabled. To enable it again, run /ide enable.', + ); this.client?.close(); } + getCurrentIde(): DetectedIde | undefined { + return this.currentIde; + } + + getConnectionStatus(): IDEConnectionState { + return this.state; + } + getDetectedIdeDisplayName(): string | undefined { return this.currentIdeDisplayName; } - setDisconnected() { - this.setState(IDEConnectionStatus.Disconnected); + private setState(status: IDEConnectionStatus, details?: string) { + const isAlreadyDisconnected = + this.state.status === IDEConnectionStatus.Disconnected && + status === IDEConnectionStatus.Disconnected; + + // Only update details if the state wasn't already disconnected, so that + // the first detail message is preserved. + if (!isAlreadyDisconnected) { + this.state = { status, details }; + } + + if (status === IDEConnectionStatus.Disconnected) { + logger.debug('IDE integration disconnected:', details); + ideContext.clearIdeContext(); + } + } + + private validateWorkspacePath(): boolean { + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (ideWorkspacePath === undefined) { + this.setState( + IDEConnectionStatus.Disconnected, + `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + ); + return false; + } + if (ideWorkspacePath === '') { + this.setState( + IDEConnectionStatus.Disconnected, + `To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`, + ); + return false; + } + if (ideWorkspacePath !== process.cwd()) { + this.setState( + IDEConnectionStatus.Disconnected, + `Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${this.currentIdeDisplayName}. Please run the CLI from the same directory as your project's root folder.`, + ); + return false; + } + return true; + } + + private getPortFromEnv(): string | undefined { + const port = process.env['GEMINI_CLI_IDE_SERVER_PORT']; + if (!port) { + this.setState( + IDEConnectionStatus.Disconnected, + `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + ); + return undefined; + } + return port; + } + + private registerClientHandlers() { + if (!this.client) { + return; + } + + this.client.setNotificationHandler( + IdeContextNotificationSchema, + (notification) => { + ideContext.setIdeContext(notification.params); + }, + ); + this.client.onerror = (_error) => { + this.setState( + IDEConnectionStatus.Disconnected, + `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, + ); + }; + this.client.onclose = () => { + this.setState( + IDEConnectionStatus.Disconnected, + `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, + ); + }; + } + + private async establishConnection(port: string) { + let transport: StreamableHTTPClientTransport | undefined; + try { + this.client = new Client({ + name: 'streamable-http-client', + // TODO(#3487): use the CLI version here. + version: '1.0.0', + }); + transport = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/mcp`), + ); + await this.client.connect(transport); + this.registerClientHandlers(); + this.setState(IDEConnectionStatus.Connected); + } catch (_error) { + this.setState( + IDEConnectionStatus.Disconnected, + `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + ); + if (transport) { + try { + await transport.close(); + } catch (closeError) { + logger.debug('Failed to close transport:', closeError); + } + } + } } } diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 6b85a664..649d82b6 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -21,6 +21,7 @@ import { NextSpeakerCheckEvent, SlashCommandEvent, MalformedJsonResponseEvent, + IdeConnectionEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; @@ -44,6 +45,7 @@ const loop_detected_event_name = 'loop_detected'; const next_speaker_check_event_name = 'next_speaker_check'; const slash_command_event_name = 'slash_command'; const malformed_json_response_event_name = 'malformed_json_response'; +const ide_connection_event_name = 'ide_connection'; export interface LogResponse { nextRequestWaitMs?: number; @@ -578,6 +580,18 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logIdeConnectionEvent(event: IdeConnectionEvent): void { + const data = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_IDE_CONNECTION_TYPE, + value: JSON.stringify(event.connection_type), + }, + ]; + + this.enqueueLogEvent(this.createLogEvent(ide_connection_event_name, data)); + this.flushIfNeeded(); + } + logEndSessionEvent(event: EndSessionEvent): void { const data = [ { 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 0fc35894..54f570f1 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -190,6 +190,13 @@ export enum EventMetadataKey { // Logs the model that produced the malformed JSON response. GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45, + + // ========================================================================== + // IDE Connection Event Keys + // =========================================================================== + + // Logs the type of the IDE connection. + GEMINI_CLI_IDE_CONNECTION_TYPE = 46, } export function getEventMetadataKey( diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 7dd5c8d1..7d840815 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'gemini_cli.config'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command'; +export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection'; export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count'; export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 2aa0d86a..e3726ccb 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -12,6 +12,7 @@ import { EVENT_API_REQUEST, EVENT_API_RESPONSE, EVENT_CLI_CONFIG, + EVENT_IDE_CONNECTION, EVENT_TOOL_CALL, EVENT_USER_PROMPT, EVENT_FLASH_FALLBACK, @@ -23,6 +24,7 @@ import { ApiErrorEvent, ApiRequestEvent, ApiResponseEvent, + IdeConnectionEvent, StartSessionEvent, ToolCallEvent, UserPromptEvent, @@ -355,3 +357,24 @@ export function logSlashCommand( }; logger.emit(logRecord); } + +export function logIdeConnection( + config: Config, + event: IdeConnectionEvent, +): void { + ClearcutLogger.getInstance(config)?.logIdeConnectionEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_IDE_CONNECTION, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Ide connection. Type: ${event.connection_type}.`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 8ebb3d9a..9734e382 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -12,7 +12,6 @@ import { } from './sdk.js'; import { Config } from '../config/config.js'; import { NodeSDK } from '@opentelemetry/sdk-node'; -import { IdeClient } from '../ide/ide-client.js'; vi.mock('@opentelemetry/sdk-node'); vi.mock('../config/config.js'); @@ -30,7 +29,6 @@ describe('telemetry', () => { targetDir: '/test/dir', debugMode: false, cwd: '/test/dir', - ideClient: IdeClient.getInstance(false), }); vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true); vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 9d1fd77a..668421f0 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -308,6 +308,23 @@ export class MalformedJsonResponseEvent { } } +export enum IdeConnectionType { + START = 'start', + SESSION = 'session', +} + +export class IdeConnectionEvent { + 'event.name': 'ide_connection'; + 'event.timestamp': string; // ISO 8601 + connection_type: IdeConnectionType; + + constructor(connection_type: IdeConnectionType) { + this['event.name'] = 'ide_connection'; + this['event.timestamp'] = new Date().toISOString(); + this.connection_type = connection_type; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -320,4 +337,5 @@ export type TelemetryEvent = | LoopDetectedEvent | NextSpeakerCheckEvent | SlashCommandEvent - | MalformedJsonResponseEvent; + | MalformedJsonResponseEvent + | IdeConnectionEvent; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 88b23d84..24b6ca5f 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -30,7 +30,7 @@ import { Schema, } from '@google/genai'; import { spawn } from 'node:child_process'; -import { IdeClient } from '../ide/ide-client.js'; + import fs from 'node:fs'; vi.mock('node:fs'); @@ -140,7 +140,6 @@ const baseConfigParams: ConfigParameters = { geminiMdFileCount: 0, approvalMode: ApprovalMode.DEFAULT, sessionId: 'test-session-id', - ideClient: IdeClient.getInstance(false), }; describe('ToolRegistry', () => { diff --git a/packages/core/src/utils/flashFallback.integration.test.ts b/packages/core/src/utils/flashFallback.integration.test.ts index 7f18b24f..9211ad2f 100644 --- a/packages/core/src/utils/flashFallback.integration.test.ts +++ b/packages/core/src/utils/flashFallback.integration.test.ts @@ -17,7 +17,6 @@ import { import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { retryWithBackoff } from './retry.js'; import { AuthType } from '../core/contentGenerator.js'; -import { IdeClient } from '../ide/ide-client.js'; vi.mock('node:fs'); @@ -35,7 +34,6 @@ describe('Flash Fallback Integration', () => { debugMode: false, cwd: '/test', model: 'gemini-2.5-pro', - ideClient: IdeClient.getInstance(false), }); // Reset simulation state for each test From 2141b39c3d713a19f2dd8012a76c2ff8b7c30a5e Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 5 Aug 2025 16:11:21 -0700 Subject: [PATCH 126/136] feat(cli): route non-interactive output to stderr (#5624) --- packages/cli/src/nonInteractiveCli.test.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 36 +++++++++++++-------- packages/cli/src/ui/utils/ConsolePatcher.ts | 27 ++++++++++------ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 938eb4e7..3e4ce037 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -70,6 +70,7 @@ describe('runNonInteractive', () => { getIdeMode: vi.fn().mockReturnValue(false), getFullContext: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), } as unknown as Config; }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8e573134..8b056a28 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -17,28 +17,37 @@ import { import { Content, Part, FunctionCall } from '@google/genai'; import { parseAndFormatApiError } from './ui/utils/errorParsing.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; export async function runNonInteractive( config: Config, input: string, prompt_id: string, ): Promise { - await config.initialize(); - // Handle EPIPE errors when the output is piped to a command that closes early. - process.stdout.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') { - // Exit gracefully if the pipe is closed. - process.exit(0); - } + const consolePatcher = new ConsolePatcher({ + stderr: true, + debugMode: config.getDebugMode(), }); - const geminiClient = config.getGeminiClient(); - const toolRegistry: ToolRegistry = await config.getToolRegistry(); - - const abortController = new AbortController(); - let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }]; - let turnCount = 0; try { + await config.initialize(); + consolePatcher.patch(); + // Handle EPIPE errors when the output is piped to a command that closes early. + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') { + // Exit gracefully if the pipe is closed. + process.exit(0); + } + }); + + const geminiClient = config.getGeminiClient(); + const toolRegistry: ToolRegistry = await config.getToolRegistry(); + + const abortController = new AbortController(); + let currentMessages: Content[] = [ + { role: 'user', parts: [{ text: input }] }, + ]; + let turnCount = 0; while (true) { turnCount++; if ( @@ -133,6 +142,7 @@ export async function runNonInteractive( ); process.exit(1); } finally { + consolePatcher.cleanup(); if (isTelemetrySdkInitialized()) { await shutdownTelemetry(); } diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index 10be3bc7..a429698d 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -8,8 +8,9 @@ import util from 'util'; import { ConsoleMessageItem } from '../types.js'; interface ConsolePatcherParams { - onNewMessage: (message: Omit) => void; + onNewMessage?: (message: Omit) => void; debugMode: boolean; + stderr?: boolean; } export class ConsolePatcher { @@ -46,16 +47,22 @@ export class ConsolePatcher { originalMethod: (...args: unknown[]) => void, ) => (...args: unknown[]) => { - if (this.params.debugMode) { - originalMethod.apply(console, args); - } + if (this.params.stderr) { + if (type !== 'debug' || this.params.debugMode) { + this.originalConsoleError(this.formatArgs(args)); + } + } else { + if (this.params.debugMode) { + originalMethod.apply(console, args); + } - if (type !== 'debug' || this.params.debugMode) { - this.params.onNewMessage({ - type, - content: this.formatArgs(args), - count: 1, - }); + if (type !== 'debug' || this.params.debugMode) { + this.params.onNewMessage?.({ + type, + content: this.formatArgs(args), + count: 1, + }); + } } }; } From 12a9bc3ed94fab3071529b5304d46bcc5b4fe756 Mon Sep 17 00:00:00 2001 From: Bryant Chandler Date: Tue, 5 Aug 2025 16:18:03 -0700 Subject: [PATCH 127/136] feat(core, cli): Introduce high-performance FileSearch engine (#5136) Co-authored-by: Jacob Richman --- package-lock.json | 53 + packages/cli/package.json | 3 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 380 ++++ packages/cli/src/ui/hooks/useAtCompletion.ts | 228 +++ .../src/ui/hooks/useCommandCompletion.test.ts | 1644 +++-------------- .../cli/src/ui/hooks/useCommandCompletion.tsx | 667 ++----- .../ui/hooks/useReverseSearchCompletion.tsx | 9 +- .../src/ui/hooks/useSlashCompletion.test.ts | 434 +++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 187 ++ packages/core/package.json | 4 + packages/core/src/index.ts | 1 + .../src/utils/filesearch/crawlCache.test.ts | 112 ++ .../core/src/utils/filesearch/crawlCache.ts | 65 + .../src/utils/filesearch/fileSearch.test.ts | 642 +++++++ .../core/src/utils/filesearch/fileSearch.ts | 269 +++ .../core/src/utils/filesearch/ignore.test.ts | 65 + packages/core/src/utils/filesearch/ignore.ts | 93 + .../src/utils/filesearch/result-cache.test.ts | 56 + .../core/src/utils/filesearch/result-cache.ts | 70 + packages/test-utils/index.ts | 7 + packages/test-utils/package.json | 18 + .../src/file-system-test-helpers.ts | 98 + packages/test-utils/src/index.ts | 7 + packages/test-utils/tsconfig.json | 11 + 24 files changed, 3204 insertions(+), 1919 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useAtCompletion.test.ts create mode 100644 packages/cli/src/ui/hooks/useAtCompletion.ts create mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.test.ts create mode 100644 packages/cli/src/ui/hooks/useSlashCompletion.ts create mode 100644 packages/core/src/utils/filesearch/crawlCache.test.ts create mode 100644 packages/core/src/utils/filesearch/crawlCache.ts create mode 100644 packages/core/src/utils/filesearch/fileSearch.test.ts create mode 100644 packages/core/src/utils/filesearch/fileSearch.ts create mode 100644 packages/core/src/utils/filesearch/ignore.test.ts create mode 100644 packages/core/src/utils/filesearch/ignore.ts create mode 100644 packages/core/src/utils/filesearch/result-cache.test.ts create mode 100644 packages/core/src/utils/filesearch/result-cache.ts create mode 100644 packages/test-utils/index.ts create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/src/file-system-test-helpers.ts create mode 100644 packages/test-utils/src/index.ts create mode 100644 packages/test-utils/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 7f6cfc4a..b16c4904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -932,6 +932,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@google/gemini-cli-test-utils": { + "resolved": "packages/test-utils", + "link": true + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -2401,6 +2405,13 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-dLqxmi5VJRC9XTvc/oaTtk+bDb4RRqxLZPZ3jIpYBHEnDXX8lu02w2yWI6NsPPsELuVK298Z2iR8jgoWKRdUVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -11694,6 +11705,7 @@ }, "devDependencies": { "@babel/runtime": "^7.27.6", + "@google/gemini-cli-test-utils": "file:../test-utils", "@testing-library/react": "^16.3.0", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", @@ -11876,6 +11888,7 @@ "chardet": "^2.1.0", "diff": "^7.0.0", "dotenv": "^17.1.0", + "fdir": "^6.4.6", "glob": "^10.4.5", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", @@ -11884,6 +11897,7 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", + "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", @@ -11891,10 +11905,12 @@ "ws": "^8.18.0" }, "devDependencies": { + "@google/gemini-cli-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/micromatch": "^4.0.8", "@types/minimatch": "^5.1.2", + "@types/picomatch": "^4.0.1", "@types/ws": "^8.5.10", "typescript": "^5.3.3", "vitest": "^3.1.1" @@ -11940,6 +11956,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "packages/core/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "packages/core/node_modules/ignore": { "version": "7.0.5", "license": "MIT", @@ -11953,6 +11983,29 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "packages/core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/test-utils": { + "name": "@google/gemini-cli-test-utils", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", "version": "0.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d9bd400..ca64f6f7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -73,7 +73,8 @@ "pretty-format": "^30.0.2", "react-dom": "^19.1.0", "typescript": "^5.3.3", - "vitest": "^3.1.1" + "vitest": "^3.1.1", + "@google/gemini-cli-test-utils": "file:../test-utils" }, "engines": { "node": ">=20" diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts new file mode 100644 index 00000000..bf2453f5 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useAtCompletion } from './useAtCompletion.js'; +import { Config, FileSearch } from '@google/gemini-cli-core'; +import { + createTmpDir, + cleanupTmpDir, + FileSystemStructure, +} from '@google/gemini-cli-test-utils'; +import { useState } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; + +// Test harness to capture the state from the hook's callbacks. +function useTestHarnessForAtCompletion( + enabled: boolean, + pattern: string, + config: Config | undefined, + cwd: string, +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + + useAtCompletion({ + enabled, + pattern, + config, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + + return { suggestions, isLoadingSuggestions }; +} + +describe('useAtCompletion', () => { + let testRootDir: string; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getFileFilteringOptions: vi.fn(() => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + })), + } as unknown as Config; + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (testRootDir) { + await cleanupTmpDir(testRootDir); + } + vi.restoreAllMocks(); + }); + + describe('File Search Logic', () => { + it('should perform a recursive search for an empty pattern', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + src: { + 'index.js': '', + components: ['Button.tsx', 'Button with spaces.tsx'], + }, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + 'src/components/', + 'file.txt', + 'src/components/Button\\ with\\ spaces.tsx', + 'src/components/Button.tsx', + 'src/index.js', + ]); + }); + + it('should correctly filter the recursive list based on a pattern', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + src: { + 'index.js': '', + components: { + 'Button.tsx': '', + }, + }, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + 'src/components/', + 'src/components/Button.tsx', + 'src/index.js', + ]); + }); + + it('should append a trailing slash to directory paths in suggestions', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + dir: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'dir/', + 'file.txt', + ]); + }); + }); + + describe('UI State and Loading Behavior', () => { + it('should be in a loading state during initial file system crawl', async () => { + testRootDir = await createTmpDir({}); + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + // It's initially true because the effect runs synchronously. + expect(result.current.isLoadingSuggestions).toBe(true); + + // Wait for the loading to complete. + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(false); + }); + }); + + it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'a.txt', + ]); + }); + expect(result.current.isLoadingSuggestions).toBe(false); + + rerender({ pattern: 'b' }); + + // Wait for the final result + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'b.txt', + ]); + }); + + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + // Spy on the search method to introduce an artificial delay + const originalSearch = FileSearch.prototype.search; + vi.spyOn(FileSearch.prototype, 'search').mockImplementation( + async function (...args) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return originalSearch.apply(this, args); + }, + ); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + // Wait for the initial (slow) search to complete + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'a.txt', + ]); + }); + + // Now, rerender to trigger the second search + rerender({ pattern: 'b' }); + + // Wait for the loading indicator to appear + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(true); + }); + + // Suggestions should be cleared while loading + expect(result.current.suggestions).toEqual([]); + + // Wait for the final (slow) search to complete + await waitFor( + () => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'b.txt', + ]); + }, + { timeout: 1000 }, + ); // Increase timeout for the slow search + + expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should abort the previous search when a new one starts', async () => { + const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; + testRootDir = await createTmpDir(structure); + + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const searchSpy = vi + .spyOn(FileSearch.prototype, 'search') + .mockImplementation(async (...args) => { + const delay = args[0] === 'a' ? 500 : 50; + await new Promise((resolve) => setTimeout(resolve, delay)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [args[0] as any]; + }); + + const { result, rerender } = renderHook( + ({ pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), + { initialProps: { pattern: 'a' } }, + ); + + // Wait for the hook to be ready (initialization is complete) + await waitFor(() => { + expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object)); + }); + + // Now that the first search is in-flight, trigger the second one. + act(() => { + rerender({ pattern: 'b' }); + }); + + // The abort should have been called for the first search. + expect(abortSpy).toHaveBeenCalledTimes(1); + + // Wait for the final result, which should be from the second, faster search. + await waitFor( + () => { + expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']); + }, + { timeout: 1000 }, + ); + + // The search spy should have been called for both patterns. + expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object)); + + vi.restoreAllMocks(); + }); + }); + + describe('Filtering and Configuration', () => { + it('should respect .gitignore files', async () => { + const gitignoreContent = ['dist/', '*.log'].join('\n'); + const structure: FileSystemStructure = { + '.git': {}, + '.gitignore': gitignoreContent, + dist: {}, + 'test.log': '', + src: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + '.gitignore', + ]); + }); + + it('should work correctly when config is undefined', async () => { + const structure: FileSystemStructure = { + node_modules: {}, + src: {}, + }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', undefined, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'node_modules/', + 'src/', + ]); + }); + + it('should reset and re-initialize when the cwd changes', async () => { + const structure1: FileSystemStructure = { 'file1.txt': '' }; + const rootDir1 = await createTmpDir(structure1); + const structure2: FileSystemStructure = { 'file2.txt': '' }; + const rootDir2 = await createTmpDir(structure2); + + const { result, rerender } = renderHook( + ({ cwd, pattern }) => + useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd), + { + initialProps: { + cwd: rootDir1, + pattern: 'file', + }, + }, + ); + + // Wait for initial suggestions from the first directory + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'file1.txt', + ]); + }); + + // Change the CWD + act(() => { + rerender({ cwd: rootDir2, pattern: 'file' }); + }); + + // After CWD changes, suggestions should be cleared and it should load again. + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(true); + expect(result.current.suggestions).toEqual([]); + }); + + // Wait for the new suggestions from the second directory + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'file2.txt', + ]); + }); + expect(result.current.isLoadingSuggestions).toBe(false); + + await cleanupTmpDir(rootDir1); + await cleanupTmpDir(rootDir2); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts new file mode 100644 index 00000000..eaa2a5e6 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useReducer, useRef } from 'react'; +import { Config, FileSearch, escapePath } from '@google/gemini-cli-core'; +import { + Suggestion, + MAX_SUGGESTIONS_TO_SHOW, +} from '../components/SuggestionsDisplay.js'; + +export enum AtCompletionStatus { + IDLE = 'idle', + INITIALIZING = 'initializing', + READY = 'ready', + SEARCHING = 'searching', + ERROR = 'error', +} + +interface AtCompletionState { + status: AtCompletionStatus; + suggestions: Suggestion[]; + isLoading: boolean; + pattern: string | null; +} + +type AtCompletionAction = + | { type: 'INITIALIZE' } + | { type: 'INITIALIZE_SUCCESS' } + | { type: 'SEARCH'; payload: string } + | { type: 'SEARCH_SUCCESS'; payload: Suggestion[] } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'ERROR' } + | { type: 'RESET' }; + +const initialState: AtCompletionState = { + status: AtCompletionStatus.IDLE, + suggestions: [], + isLoading: false, + pattern: null, +}; + +function atCompletionReducer( + state: AtCompletionState, + action: AtCompletionAction, +): AtCompletionState { + switch (action.type) { + case 'INITIALIZE': + return { + ...state, + status: AtCompletionStatus.INITIALIZING, + isLoading: true, + }; + case 'INITIALIZE_SUCCESS': + return { ...state, status: AtCompletionStatus.READY, isLoading: false }; + case 'SEARCH': + // Keep old suggestions, don't set loading immediately + return { + ...state, + status: AtCompletionStatus.SEARCHING, + pattern: action.payload, + }; + case 'SEARCH_SUCCESS': + return { + ...state, + status: AtCompletionStatus.READY, + suggestions: action.payload, + isLoading: false, + }; + case 'SET_LOADING': + // Only show loading if we are still in a searching state + if (state.status === AtCompletionStatus.SEARCHING) { + return { ...state, isLoading: action.payload, suggestions: [] }; + } + return state; + case 'ERROR': + return { + ...state, + status: AtCompletionStatus.ERROR, + isLoading: false, + suggestions: [], + }; + case 'RESET': + return initialState; + default: + return state; + } +} + +export interface UseAtCompletionProps { + enabled: boolean; + pattern: string; + config: Config | undefined; + cwd: string; + setSuggestions: (suggestions: Suggestion[]) => void; + setIsLoadingSuggestions: (isLoading: boolean) => void; +} + +export function useAtCompletion(props: UseAtCompletionProps): void { + const { + enabled, + pattern, + config, + cwd, + setSuggestions, + setIsLoadingSuggestions, + } = props; + const [state, dispatch] = useReducer(atCompletionReducer, initialState); + const fileSearch = useRef(null); + const searchAbortController = useRef(null); + const slowSearchTimer = useRef(null); + + useEffect(() => { + setSuggestions(state.suggestions); + }, [state.suggestions, setSuggestions]); + + useEffect(() => { + setIsLoadingSuggestions(state.isLoading); + }, [state.isLoading, setIsLoadingSuggestions]); + + useEffect(() => { + dispatch({ type: 'RESET' }); + }, [cwd, config]); + + // Reacts to user input (`pattern`) ONLY. + useEffect(() => { + if (!enabled) { + return; + } + if (pattern === null) { + dispatch({ type: 'RESET' }); + return; + } + + if (state.status === AtCompletionStatus.IDLE) { + dispatch({ type: 'INITIALIZE' }); + } else if ( + (state.status === AtCompletionStatus.READY || + state.status === AtCompletionStatus.SEARCHING) && + pattern !== state.pattern // Only search if the pattern has changed + ) { + dispatch({ type: 'SEARCH', payload: pattern }); + } + }, [enabled, pattern, state.status, state.pattern]); + + // The "Worker" that performs async operations based on status. + useEffect(() => { + const initialize = async () => { + try { + const searcher = new FileSearch({ + projectRoot: cwd, + ignoreDirs: [], + useGitignore: + config?.getFileFilteringOptions()?.respectGitIgnore ?? true, + useGeminiignore: + config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true, + cache: true, + cacheTtl: 30, // 30 seconds + }); + await searcher.initialize(); + fileSearch.current = searcher; + dispatch({ type: 'INITIALIZE_SUCCESS' }); + if (state.pattern !== null) { + dispatch({ type: 'SEARCH', payload: state.pattern }); + } + } catch (_) { + dispatch({ type: 'ERROR' }); + } + }; + + const search = async () => { + if (!fileSearch.current || state.pattern === null) { + return; + } + + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + + const controller = new AbortController(); + searchAbortController.current = controller; + + slowSearchTimer.current = setTimeout(() => { + dispatch({ type: 'SET_LOADING', payload: true }); + }, 100); + + try { + const results = await fileSearch.current.search(state.pattern, { + signal: controller.signal, + maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, + }); + + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + + if (controller.signal.aborted) { + return; + } + + const suggestions = results.map((p) => ({ + label: p, + value: escapePath(p), + })); + dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions }); + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + dispatch({ type: 'ERROR' }); + } + } + }; + + if (state.status === AtCompletionStatus.INITIALIZING) { + initialize(); + } else if (state.status === AtCompletionStatus.SEARCHING) { + search(); + } + + return () => { + searchAbortController.current?.abort(); + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + }; + }, [state.status, state.pattern, config, cwd]); +} diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index 005b4e7d..a3c96935 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -9,33 +9,84 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useCommandCompletion } from './useCommandCompletion.js'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import * as os from 'os'; -import { CommandContext, SlashCommand } from '../commands/types.js'; -import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; +import { CommandContext } from '../commands/types.js'; +import { Config } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; +import { useEffect } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { UseAtCompletionProps, useAtCompletion } from './useAtCompletion.js'; +import { + UseSlashCompletionProps, + useSlashCompletion, +} from './useSlashCompletion.js'; + +vi.mock('./useAtCompletion', () => ({ + useAtCompletion: vi.fn(), +})); + +vi.mock('./useSlashCompletion', () => ({ + useSlashCompletion: vi.fn(() => ({ + completionStart: 0, + completionEnd: 0, + })), +})); + +// Helper to set up mocks in a consistent way for both child hooks +const setupMocks = ({ + atSuggestions = [], + slashSuggestions = [], + isLoading = false, + isPerfectMatch = false, + slashCompletionRange = { completionStart: 0, completionEnd: 0 }, +}: { + atSuggestions?: Suggestion[]; + slashSuggestions?: Suggestion[]; + isLoading?: boolean; + isPerfectMatch?: boolean; + slashCompletionRange?: { completionStart: number; completionEnd: number }; +}) => { + // Mock for @-completions + (useAtCompletion as vi.Mock).mockImplementation( + ({ + enabled, + setSuggestions, + setIsLoadingSuggestions, + }: UseAtCompletionProps) => { + useEffect(() => { + if (enabled) { + setIsLoadingSuggestions(isLoading); + setSuggestions(atSuggestions); + } + }, [enabled, setSuggestions, setIsLoadingSuggestions]); + }, + ); + + // Mock for /-completions + (useSlashCompletion as vi.Mock).mockImplementation( + ({ + enabled, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }: UseSlashCompletionProps) => { + useEffect(() => { + if (enabled) { + setIsLoadingSuggestions(isLoading); + setSuggestions(slashSuggestions); + setIsPerfectMatch(isPerfectMatch); + } + }, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]); + // The hook returns a range, which we can mock simply + return slashCompletionRange; + }, + ); +}; describe('useCommandCompletion', () => { - let testRootDir: string; - let mockConfig: Config; - - // A minimal mock is sufficient for these tests. const mockCommandContext = {} as CommandContext; - let testDirs: string[]; - - async function createEmptyDir(...pathSegments: string[]) { - const fullPath = path.join(testRootDir, ...pathSegments); - await fs.mkdir(fullPath, { recursive: true }); - return fullPath; - } - - async function createTestFile(content: string, ...pathSegments: string[]) { - const fullPath = path.join(testRootDir, ...pathSegments); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, content); - return fullPath; - } + const mockConfig = {} as Config; + const testDirs: string[] = []; + const testRootDir = '/'; // Helper to create real TextBuffer objects within renderHook function useTextBufferForTest(text: string, cursorOffset?: number) { @@ -48,45 +99,25 @@ describe('useCommandCompletion', () => { }); } - beforeEach(async () => { - testRootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'slash-completion-unit-test-'), - ); - testDirs = [testRootDir]; - mockConfig = { - getTargetDir: () => testRootDir, - getWorkspaceContext: () => ({ - getDirectories: () => testDirs, - }), - getProjectRoot: () => testRootDir, - getFileFilteringOptions: vi.fn(() => ({ - respectGitIgnore: true, - respectGeminiIgnore: true, - })), - getEnableRecursiveFileSearch: vi.fn(() => true), - getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)), - } as unknown as Config; - + beforeEach(() => { vi.clearAllMocks(); + // Reset to default mocks before each test + setupMocks({}); }); - afterEach(async () => { + afterEach(() => { vi.restoreAllMocks(); - await fs.rm(testRootDir, { recursive: true, force: true }); }); describe('Core Hook Behavior', () => { describe('State Management', () => { it('should initialize with default state', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest(''), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, mockConfig, @@ -100,56 +131,51 @@ describe('useCommandCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should reset state when isActive becomes false', () => { - const slashCommands = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; + it('should reset state when completion mode becomes IDLE', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], + }); - const { result, rerender } = renderHook( - ({ text }) => { - const textBuffer = useTextBufferForTest(text); - return useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - mockConfig, - ); - }, - { initialProps: { text: '/help' } }, - ); - - // Inactive because of the leading space - rerender({ text: ' /help' }); - - expect(result.current.suggestions).toEqual([]); - expect(result.current.activeSuggestionIndex).toBe(-1); - expect(result.current.visibleStartIndex).toBe(0); - expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); - }); - - it('should reset all state to default values', async () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/help'), + const { result } = renderHook(() => { + const textBuffer = useTextBufferForTest('@file'); + const completion = useCommandCompletion( + textBuffer, testDirs, testRootDir, - slashCommands, + [], + mockCommandContext, + false, + mockConfig, + ); + return { completion, textBuffer }; + }); + + await waitFor(() => { + expect(result.current.completion.suggestions).toHaveLength(1); + }); + + expect(result.current.completion.showSuggestions).toBe(true); + + act(() => { + result.current.textBuffer.replaceRangeByOffset( + 0, + 5, + 'just some text', + ); + }); + + await waitFor(() => { + expect(result.current.completion.showSuggestions).toBe(false); + }); + }); + + it('should reset all state to default values', () => { + const { result } = renderHook(() => + useCommandCompletion( + useTextBufferForTest('@files'), + testDirs, + testRootDir, + [], mockCommandContext, false, mockConfig, @@ -165,30 +191,84 @@ describe('useCommandCompletion', () => { result.current.resetCompletionState(); }); - // Wait for async suggestions clearing - await waitFor(() => { - expect(result.current.suggestions).toEqual([]); - }); - - expect(result.current.suggestions).toEqual([]); expect(result.current.activeSuggestionIndex).toBe(-1); expect(result.current.visibleStartIndex).toBe(0); expect(result.current.showSuggestions).toBe(false); - expect(result.current.isLoadingSuggestions).toBe(false); + }); + + it('should call useAtCompletion with the correct query for an escaped space', async () => { + const text = '@src/a\\ file.txt'; + renderHook(() => + useCommandCompletion( + useTextBufferForTest(text), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'src/a\\ file.txt', + }), + ); + }); + }); + + it('should correctly identify the completion context with multiple @ symbols', async () => { + const text = '@file1 @file2'; + const cursorOffset = 3; // @fi|le1 @file2 + + renderHook(() => + useCommandCompletion( + useTextBufferForTest(text, cursorOffset), + testDirs, + testRootDir, + [], + mockCommandContext, + false, + mockConfig, + ), + ); + + await waitFor(() => { + expect(useAtCompletion).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + pattern: 'file1', + }), + ); + }); }); }); describe('Navigation', () => { + const mockSuggestions = [ + { label: 'cmd1', value: 'cmd1' }, + { label: 'cmd2', value: 'cmd2' }, + { label: 'cmd3', value: 'cmd3' }, + { label: 'cmd4', value: 'cmd4' }, + { label: 'cmd5', value: 'cmd5' }, + ]; + + beforeEach(() => { + setupMocks({ slashSuggestions: mockSuggestions }); + }); + it('should handle navigateUp with no suggestions', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; + setupMocks({ slashSuggestions: [] }); + const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest(''), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, mockConfig, @@ -203,18 +283,15 @@ describe('useCommandCompletion', () => { }); it('should handle navigateDown with no suggestions', () => { - const slashCommands = [ - { name: 'dummy', description: 'dummy' }, - ] as unknown as SlashCommand[]; + setupMocks({ slashSuggestions: [] }); const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest(''), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); @@ -226,930 +303,127 @@ describe('useCommandCompletion', () => { expect(result.current.activeSuggestionIndex).toBe(-1); }); - it('should navigate up through suggestions with wrap-around', () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateUp(); - }); - - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should navigate down through suggestions with wrap-around', () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/h'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - expect(result.current.suggestions.length).toBe(1); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateDown(); - }); - - expect(result.current.activeSuggestionIndex).toBe(0); - }); - - it('should handle navigation with multiple suggestions', () => { - const slashCommands = [ - { name: 'help', description: 'Show help' }, - { name: 'stats', description: 'Show stats' }, - { name: 'clear', description: 'Clear screen' }, - { name: 'memory', description: 'Manage memory' }, - { name: 'chat', description: 'Manage chat' }, - ] as unknown as SlashCommand[]; + it('should navigate up through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ), ); - expect(result.current.suggestions.length).toBe(5); - expect(result.current.activeSuggestionIndex).toBe(0); - - act(() => { - result.current.navigateDown(); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); }); - expect(result.current.activeSuggestionIndex).toBe(1); - act(() => { - result.current.navigateDown(); - }); - expect(result.current.activeSuggestionIndex).toBe(2); - - act(() => { - result.current.navigateUp(); - }); - expect(result.current.activeSuggestionIndex).toBe(1); - - act(() => { - result.current.navigateUp(); - }); expect(result.current.activeSuggestionIndex).toBe(0); act(() => { result.current.navigateUp(); }); + expect(result.current.activeSuggestionIndex).toBe(4); }); - it('should handle navigation with large suggestion lists and scrolling', () => { - const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({ - name: `command${i}`, - description: `Command ${i}`, - })) as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/command'), - testDirs, - testRootDir, - largeMockCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - expect(result.current.suggestions.length).toBe(15); - expect(result.current.activeSuggestionIndex).toBe(0); - expect(result.current.visibleStartIndex).toBe(0); - - act(() => { - result.current.navigateUp(); - }); - - expect(result.current.activeSuggestionIndex).toBe(14); - expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8)); - }); - }); - }); - - describe('Slash Command Completion (`/`)', () => { - describe('Top-Level Commands', () => { - it('should suggest all top-level commands for the root slash', async () => { - const slashCommands = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - }, - { - name: 'stats', - altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', - }, - { - name: 'clear', - description: 'Clear the screen', - }, - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - ], - }, - { - name: 'chat', - description: 'Manage chat history', - }, - ] as unknown as SlashCommand[]; + it('should navigate down through suggestions with wrap-around', async () => { const { result } = renderHook(() => useCommandCompletion( useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - expect(result.current.suggestions.length).toBe(slashCommands.length); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), - ); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); + }); + + act(() => { + result.current.setActiveSuggestionIndex(4); + }); + expect(result.current.activeSuggestionIndex).toBe(4); + + act(() => { + result.current.navigateDown(); + }); + + expect(result.current.activeSuggestionIndex).toBe(0); }); - it('should filter commands based on partial input', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - }, - ] as unknown as SlashCommand[]; + it('should handle navigation with multiple suggestions', async () => { const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/mem'), + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - expect(result.current.suggestions).toEqual([ - { label: 'memory', value: 'memory', description: 'Manage memory' }, - ]); - expect(result.current.showSuggestions).toBe(true); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(5); + }); + + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => result.current.navigateDown()); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => result.current.navigateDown()); + expect(result.current.activeSuggestionIndex).toBe(2); + + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(1); + + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(0); + + act(() => result.current.navigateUp()); + expect(result.current.activeSuggestionIndex).toBe(4); }); - it('should suggest commands based on partial altNames', async () => { - const slashCommands = [ - { - name: 'stats', - altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', - }, - ] as unknown as SlashCommand[]; + it('should automatically select the first item when suggestions are available', async () => { + setupMocks({ slashSuggestions: mockSuggestions }); + const { result } = renderHook(() => useCommandCompletion( - useTextBufferForTest('/usag'), // part of the word "usage" + useTextBufferForTest('/'), testDirs, testRootDir, - slashCommands, + [], mockCommandContext, + false, + mockConfig, ), ); - expect(result.current.suggestions).toEqual([ - { - label: 'stats', - value: 'stats', - description: 'check session stats. Usage: /stats [model|tools]', - }, - ]); - }); - - it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { - const slashCommands = [ - { - name: 'clear', - description: 'Clear the screen', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/clear'), // No trailing space - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it.each([['/?'], ['/usage']])( - 'should not suggest commands when altNames is fully typed', - async (query) => { - const mockSlashCommands = [ - { - name: 'help', - altNames: ['?'], - description: 'Show help', - action: vi.fn(), - }, - { - name: 'stats', - altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest(query), - testDirs, - testRootDir, - mockSlashCommands, - mockCommandContext, - ), + await waitFor(() => { + expect(result.current.suggestions.length).toBe( + mockSuggestions.length, ); - - expect(result.current.suggestions).toHaveLength(0); - }, - ); - - it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { - const slashCommands = [ - { - name: 'clear', - description: 'Clear the screen', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/clear '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - - it('should not provide suggestions for an unknown command', async () => { - const slashCommands = [ - { - name: 'help', - description: 'Show help', - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/unknown-command'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - describe('Sub-Commands', () => { - it('should suggest sub-commands for a parent command', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/memory'), // Note: no trailing space - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - // Assert that suggestions for sub-commands are shown immediately - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'show', value: 'show', description: 'Show memory' }, - { label: 'add', value: 'add', description: 'Add to memory' }, - ]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/memory'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'show', value: 'show', description: 'Show memory' }, - { label: 'add', value: 'add', description: 'Add to memory' }, - ]), - ); - }); - - it('should filter sub-commands by prefix', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/memory a'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toEqual([ - { label: 'add', value: 'add', description: 'Add to memory' }, - ]); - }); - - it('should provide no suggestions for an invalid sub-command', async () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/memory dothisnow'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - - describe('Argument Completion', () => { - it('should call the command.completion function for argument suggestions', async () => { - const availableTags = [ - 'my-chat-tag-1', - 'my-chat-tag-2', - 'another-channel', - ]; - const mockCompletionFn = vi - .fn() - .mockImplementation( - async (_context: CommandContext, partialArg: string) => - availableTags.filter((tag) => tag.startsWith(partialArg)), - ); - - const slashCommands = [ - { - name: 'chat', - description: 'Manage chat history', - subCommands: [ - { - name: 'resume', - description: 'Resume a saved chat', - completion: mockCompletionFn, - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/chat resume my-ch'), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); + expect(result.current.activeSuggestionIndex).toBe(0); }); - - expect(mockCompletionFn).toHaveBeenCalledWith( - mockCommandContext, - 'my-ch', - ); - - expect(result.current.suggestions).toEqual([ - { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, - { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, - ]); - }); - - it('should call command.completion with an empty string when args start with a space', async () => { - const mockCompletionFn = vi - .fn() - .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); - - const slashCommands = [ - { - name: 'chat', - description: 'Manage chat history', - subCommands: [ - { - name: 'resume', - description: 'Resume a saved chat', - completion: mockCompletionFn, - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should handle completion function that returns null', async () => { - const completionFn = vi.fn().mockResolvedValue(null); - const slashCommands = [ - { - name: 'chat', - description: 'Manage chat history', - subCommands: [ - { - name: 'resume', - description: 'Resume a saved chat', - completion: completionFn, - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/chat resume '), - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(0); - expect(result.current.showSuggestions).toBe(false); - }); - }); - }); - - describe('File Path Completion (`@`)', () => { - describe('Basic Completion', () => { - it('should use glob for top-level @ completions when available', async () => { - await createTestFile('', 'src', 'index.ts'); - await createTestFile('', 'derp', 'script.ts'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@s'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'derp/script.ts', - value: 'derp/script.ts', - }, - { label: 'src', value: 'src' }, - ]), - ); - }); - - it('should handle directory-specific completions with git filtering', async () => { - await createEmptyDir('.git'); - await createTestFile('*.log', '.gitignore'); - await createTestFile('', 'src', 'component.tsx'); - await createTestFile('', 'src', 'temp.log'); - await createTestFile('', 'src', 'index.ts'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@src/comp'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should filter out .log files but include matching .tsx files - expect(result.current.suggestions).toEqual([ - { label: 'component.tsx', value: 'component.tsx' }, - ]); - }); - - it('should include dotfiles in glob search when input starts with a dot', async () => { - await createTestFile('', '.env'); - await createTestFile('', '.gitignore'); - await createTestFile('', 'src', 'index.ts'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@.'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toEqual([ - { label: '.env', value: '.env' }, - { label: '.gitignore', value: '.gitignore' }, - ]); - }); - }); - - describe('Configuration-based Behavior', () => { - it('should not perform recursive search when disabled in config', async () => { - const mockConfigNoRecursive = { - ...mockConfig, - getEnableRecursiveFileSearch: vi.fn(() => false), - } as unknown as Config; - - await createEmptyDir('data'); - await createEmptyDir('dist'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfigNoRecursive, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - expect(result.current.suggestions).toEqual([ - { label: 'data/', value: 'data/' }, - { label: 'dist/', value: 'dist/' }, - ]); - }); - - it('should work without config (fallback behavior)', async () => { - await createEmptyDir('src'); - await createEmptyDir('node_modules'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - undefined, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Without config, should include all files - expect(result.current.suggestions).toHaveLength(3); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { label: 'src/', value: 'src/' }, - { label: 'node_modules/', value: 'node_modules/' }, - { label: 'README.md', value: 'README.md' }, - ]), - ); - }); - - it('should handle git discovery service initialization failure gracefully', async () => { - // Intentionally don't create a .git directory to cause an initialization failure. - await createEmptyDir('src'); - await createTestFile('', 'README.md'); - - const consoleSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Since we use centralized service, initialization errors are handled at config level - // This test should verify graceful fallback behavior - expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0); - // Should still show completions even if git discovery fails - expect(result.current.suggestions.length).toBeGreaterThan(0); - - consoleSpy.mockRestore(); - }); - }); - - describe('Git-Aware Filtering', () => { - it('should filter git-ignored entries from @ completions', async () => { - await createEmptyDir('.git'); - await createTestFile('dist', '.gitignore'); - await createEmptyDir('data'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@d'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toEqual( - expect.arrayContaining([{ label: 'data', value: 'data' }]), - ); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should filter git-ignored directories from @ completions', async () => { - await createEmptyDir('.git'); - await createTestFile('node_modules\ndist\n.env', '.gitignore'); - // gitignored entries - await createEmptyDir('node_modules'); - await createEmptyDir('dist'); - await createTestFile('', '.env'); - - // visible - await createEmptyDir('src'); - await createTestFile('', 'README.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - // Wait for async operations to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce - }); - - expect(result.current.suggestions).toEqual([ - { label: 'README.md', value: 'README.md' }, - { label: 'src/', value: 'src/' }, - ]); - expect(result.current.showSuggestions).toBe(true); - }); - - it('should handle recursive search with git-aware filtering', async () => { - await createEmptyDir('.git'); - await createTestFile('node_modules/\ntemp/', '.gitignore'); - await createTestFile('', 'data', 'test.txt'); - await createEmptyDir('dist'); - await createEmptyDir('node_modules'); - await createTestFile('', 'src', 'index.ts'); - await createEmptyDir('src', 'components'); - await createTestFile('', 'temp', 'temp.log'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@t'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - // Should not include anything from node_modules or dist - const suggestionLabels = result.current.suggestions.map((s) => s.label); - expect(suggestionLabels).not.toContain('temp/'); - expect(suggestionLabels).not.toContain('node_modules/'); }); }); }); describe('handleAutocomplete', () => { - it('should complete a partial command', () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; + it('should complete a partial command', async () => { + setupMocks({ + slashSuggestions: [{ label: 'memory', value: 'memory' }], + slashCompletionRange: { completionStart: 1, completionEnd: 4 }, + }); const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('/mem'); @@ -1157,18 +431,17 @@ describe('useCommandCompletion', () => { textBuffer, testDirs, testRootDir, - slashCommands, + [], mockCommandContext, false, - mockConfig, ); return { ...completion, textBuffer }; }); - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'memory', - ]); + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); act(() => { result.current.handleAutocomplete(0); @@ -1177,99 +450,11 @@ describe('useCommandCompletion', () => { expect(result.current.textBuffer.text).toBe('/memory '); }); - it('should append a sub-command when the parent is complete', () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/memory'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; + it('should complete a file path', async () => { + setupMocks({ + atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - // Suggestions are populated by useEffect - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'show', - 'add', - ]); - - act(() => { - result.current.handleAutocomplete(1); // index 1 is 'add' - }); - - expect(result.current.textBuffer.text).toBe('/memory add '); - }); - - it('should complete a command with an alternative name', () => { - const slashCommands = [ - { - name: 'memory', - description: 'Manage memory', - subCommands: [ - { - name: 'show', - description: 'Show memory', - }, - { - name: 'add', - description: 'Add to memory', - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/?'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - slashCommands, - mockCommandContext, - false, - - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'help', - value: 'help', - description: 'Show help', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('/help '); - }); - - it('should complete a file path', () => { const { result } = renderHook(() => { const textBuffer = useTextBufferForTest('@src/fi'); const completion = useCommandCompletion( @@ -1284,9 +469,8 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); act(() => { @@ -1296,10 +480,14 @@ describe('useCommandCompletion', () => { expect(result.current.textBuffer.text).toBe('@src/file1.txt '); }); - it('should complete a file path when cursor is not at the end of the line', () => { - const text = '@src/fi le.txt'; + it('should complete a file path when cursor is not at the end of the line', async () => { + const text = '@src/fi is a good file'; const cursorOffset = 7; // after "i" + setupMocks({ + atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], + }); + const { result } = renderHook(() => { const textBuffer = useTextBufferForTest(text, cursorOffset); const completion = useCommandCompletion( @@ -1314,303 +502,17 @@ describe('useCommandCompletion', () => { return { ...completion, textBuffer }; }); - result.current.suggestions.push({ - label: 'file1.txt', - value: 'file1.txt', + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); }); act(() => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt'); - }); - - it('should complete the correct file path with multiple @-commands', () => { - const text = '@file1.txt @src/fi'; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(text); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - result.current.suggestions.push({ - label: 'file2.txt', - value: 'file2.txt', - }); - - act(() => { - result.current.handleAutocomplete(0); - }); - - expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt '); - }); - }); - - describe('File Path Escaping', () => { - it('should escape special characters in file names', async () => { - await createTestFile('', 'my file.txt'); - await createTestFile('', 'file(1).txt'); - await createTestFile('', 'backup[old].txt'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), + expect(result.current.textBuffer.text).toBe( + '@src/file1.txt is a good file', ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file.txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('my\\ file.txt'); - }); - - it('should escape parentheses in file names', async () => { - await createTestFile('', 'document(final).docx'); - await createTestFile('', 'script(v2).sh'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@doc'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'document(final).docx', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('document\\(final\\).docx'); - }); - - it('should escape square brackets in file names', async () => { - await createTestFile('', 'backup[2024-01-01].zip'); - await createTestFile('', 'config[dev].json'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@backup'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'backup[2024-01-01].zip', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip'); - }); - - it('should escape multiple special characters in file names', async () => { - await createTestFile('', 'my file (backup) [v1.2].txt'); - await createTestFile('', 'data & config {prod}.json'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find( - (s) => s.label === 'my file (backup) [v1.2].txt', - ); - expect(suggestion).toBeDefined(); - expect(suggestion!.value).toBe( - 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt', - ); - }); - - it('should preserve path separators while escaping special characters', async () => { - await createTestFile( - '', - 'projects', - 'my project (2024)', - 'file with spaces.txt', - ); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@projects/my'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('my project'), - ); - expect(suggestion).toBeDefined(); - // Should escape spaces and parentheses but preserve forward slashes - expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/); - expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator - }); - - it('should normalize Windows path separators to forward slashes while preserving escaping', async () => { - // Create test with complex nested structure - await createTestFile( - '', - 'deep', - 'nested', - 'special folder', - 'file with (parentheses).txt', - ); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@deep/nested/special'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestion = result.current.suggestions.find((s) => - s.label.includes('special folder'), - ); - expect(suggestion).toBeDefined(); - // Should use forward slashes for path separators and escape spaces - expect(suggestion!.value).toContain('special\\ folder/'); - expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators - }); - - it('should handle directory names with special characters', async () => { - await createEmptyDir('my documents (personal)'); - await createEmptyDir('config [production]'); - await createEmptyDir('data & logs'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const docSuggestion = suggestions.find( - (s) => s.label === 'my documents (personal)/', - ); - expect(docSuggestion).toBeDefined(); - expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/'); - - const configSuggestion = suggestions.find( - (s) => s.label === 'config [production]/', - ); - expect(configSuggestion).toBeDefined(); - expect(configSuggestion!.value).toBe('config\\ \\[production\\]/'); - - const dataSuggestion = suggestions.find( - (s) => s.label === 'data & logs/', - ); - expect(dataSuggestion).toBeDefined(); - expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/'); - }); - - it('should handle files with various shell metacharacters', async () => { - await createTestFile('', 'file$var.txt'); - await createTestFile('', 'important!.md'); - - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ), - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 150)); - }); - - const suggestions = result.current.suggestions; - - const dollarSuggestion = suggestions.find( - (s) => s.label === 'file$var.txt', - ); - expect(dollarSuggestion).toBeDefined(); - expect(dollarSuggestion!.value).toBe('file\\$var.txt'); - - const importantSuggestion = suggestions.find( - (s) => s.label === 'important!.md', - ); - expect(importantSuggestion).toBeDefined(); - expect(importantSuggestion!.value).toBe('important\\!.md'); }); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 9227be39..07d0e056 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -4,20 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useCallback, useMemo, useRef } from 'react'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { glob } from 'glob'; -import { - isNodeError, - escapePath, - unescapePath, - getErrorMessage, - Config, - FileDiscoveryService, - DEFAULT_FILE_FILTERING_OPTIONS, - SHELL_SPECIAL_CHARS, -} from '@google/gemini-cli-core'; +import { useCallback, useMemo, useEffect } from 'react'; import { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; import { @@ -26,8 +13,17 @@ import { } from '../components/shared/text-buffer.js'; import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; +import { useAtCompletion } from './useAtCompletion.js'; +import { useSlashCompletion } from './useSlashCompletion.js'; +import { Config } from '@google/gemini-cli-core'; import { useCompletion } from './useCompletion.js'; +export enum CompletionMode { + IDLE = 'IDLE', + AT = 'AT', + SLASH = 'SLASH', +} + export interface UseCommandCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; @@ -72,541 +68,109 @@ export function useCommandCompletion( navigateDown, } = useCompletion(); - const completionStart = useRef(-1); - const completionEnd = useRef(-1); - const cursorRow = buffer.cursor[0]; const cursorCol = buffer.cursor[1]; - // Check if cursor is after @ or / without unescaped spaces - const commandIndex = useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { - return currentLine.indexOf('/'); - } - - // For other completions like '@', we search backwards from the cursor. - - const codePoints = toCodePoints(currentLine); - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; - - if (char === ' ') { - // Check for unescaped spaces. - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - return -1; // Inactive on unescaped space. - } - } else if (char === '@') { - // Active if we find an '@' before any unescaped space. - return i; + const { completionMode, query, completionStart, completionEnd } = + useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + return { + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, + }; } - } - return -1; - }, [cursorRow, cursorCol, buffer.lines]); + const codePoints = toCodePoints(currentLine); + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; + + if (char === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + }; + } + } else if (char === '@') { + let end = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + end = i; + break; + } + } + } + const pathStart = i + 1; + const partialPath = currentLine.substring(pathStart, end); + return { + completionMode: CompletionMode.AT, + query: partialPath, + completionStart: pathStart, + completionEnd: end, + }; + } + } + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + }; + }, [cursorRow, cursorCol, buffer.lines]); + + useAtCompletion({ + enabled: completionMode === CompletionMode.AT, + pattern: query || '', + config, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + + const slashCompletionRange = useSlashCompletion({ + enabled: completionMode === CompletionMode.SLASH, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }); useEffect(() => { - if (commandIndex === -1 || reverseSearchActive) { - setTimeout(resetCompletionState, 0); - return; - } + setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); + setVisibleStartIndex(0); + }, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]); - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); - - if (codePoints[commandIndex] === '/') { - // Always reset perfect match at the beginning of processing. - setIsPerfectMatch(false); - - const fullPath = currentLine.substring(commandIndex + 1); - const hasTrailingSpace = currentLine.endsWith(' '); - - // Get all non-empty parts of the command. - const rawParts = fullPath.split(/\s+/).filter((p) => p); - - let commandPathParts = rawParts; - let partial = ''; - - // If there's no trailing space, the last part is potentially a partial segment. - // We tentatively separate it. - if (!hasTrailingSpace && rawParts.length > 0) { - partial = rawParts[rawParts.length - 1]; - commandPathParts = rawParts.slice(0, -1); - } - - // Traverse the Command Tree using the tentative completed path - let currentLevel: readonly SlashCommand[] | undefined = slashCommands; - let leafCommand: SlashCommand | null = null; - - for (const part of commandPathParts) { - if (!currentLevel) { - leafCommand = null; - currentLevel = []; - break; - } - const found: SlashCommand | undefined = currentLevel.find( - (cmd) => cmd.name === part || cmd.altNames?.includes(part), - ); - if (found) { - leafCommand = found; - currentLevel = found.subCommands as - | readonly SlashCommand[] - | undefined; - } else { - leafCommand = null; - currentLevel = []; - break; - } - } - - let exactMatchAsParent: SlashCommand | undefined; - // Handle the Ambiguous Case - if (!hasTrailingSpace && currentLevel) { - exactMatchAsParent = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.subCommands, - ); - - if (exactMatchAsParent) { - // It's a perfect match for a parent command. Override our initial guess. - // Treat it as a completed command path. - leafCommand = exactMatchAsParent; - currentLevel = exactMatchAsParent.subCommands; - partial = ''; // We now want to suggest ALL of its sub-commands. - } - } - - // Check for perfect, executable match - if (!hasTrailingSpace) { - if (leafCommand && partial === '' && leafCommand.action) { - // Case: /command - command has action, no sub-commands were suggested - setIsPerfectMatch(true); - } else if (currentLevel) { - // Case: /command subcommand - const perfectMatch = currentLevel.find( - (cmd) => - (cmd.name === partial || cmd.altNames?.includes(partial)) && - cmd.action, - ); - if (perfectMatch) { - setIsPerfectMatch(true); - } - } - } - - const depth = commandPathParts.length; - const isArgumentCompletion = - leafCommand?.completion && - (hasTrailingSpace || - (rawParts.length > depth && depth > 0 && partial !== '')); - - // Set completion range - if (hasTrailingSpace || exactMatchAsParent) { - completionStart.current = currentLine.length; - completionEnd.current = currentLine.length; - } else if (partial) { - if (isArgumentCompletion) { - const commandSoFar = `/${commandPathParts.join(' ')}`; - const argStartIndex = - commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); - completionStart.current = argStartIndex; - } else { - completionStart.current = currentLine.length - partial.length; - } - completionEnd.current = currentLine.length; - } else { - // e.g. / - completionStart.current = commandIndex + 1; - completionEnd.current = currentLine.length; - } - - // Provide Suggestions based on the now-corrected context - if (isArgumentCompletion) { - const fetchAndSetSuggestions = async () => { - setIsLoadingSuggestions(true); - const argString = rawParts.slice(depth).join(' '); - const results = - (await leafCommand!.completion!(commandContext, argString)) || []; - const finalSuggestions = results.map((s) => ({ label: s, value: s })); - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - }; - fetchAndSetSuggestions(); - return; - } - - // Command/Sub-command Completion - const commandsToSearch = currentLevel || []; - if (commandsToSearch.length > 0) { - let potentialSuggestions = commandsToSearch.filter( - (cmd) => - cmd.description && - (cmd.name.startsWith(partial) || - cmd.altNames?.some((alt) => alt.startsWith(partial))), - ); - - // If a user's input is an exact match and it is a leaf command, - // enter should submit immediately. - if (potentialSuggestions.length > 0 && !hasTrailingSpace) { - const perfectMatch = potentialSuggestions.find( - (s) => s.name === partial || s.altNames?.includes(partial), - ); - if (perfectMatch && perfectMatch.action) { - potentialSuggestions = []; - } - } - - const finalSuggestions = potentialSuggestions.map((cmd) => ({ - label: cmd.name, - value: cmd.name, - description: cmd.description, - })); - - setSuggestions(finalSuggestions); - setShowSuggestions(finalSuggestions.length > 0); - setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1); - setIsLoadingSuggestions(false); - return; - } - - // If we fall through, no suggestions are available. + useEffect(() => { + if (completionMode === CompletionMode.IDLE || reverseSearchActive) { resetCompletionState(); return; } - - // Handle At Command Completion - completionEnd.current = codePoints.length; - for (let i = cursorCol; i < codePoints.length; i++) { - if (codePoints[i] === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - - if (backslashCount % 2 === 0) { - completionEnd.current = i; - break; - } - } - } - - const pathStart = commandIndex + 1; - const partialPath = currentLine.substring(pathStart, completionEnd.current); - const lastSlashIndex = partialPath.lastIndexOf('/'); - completionStart.current = - lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1; - const baseDirRelative = - lastSlashIndex === -1 - ? '.' - : partialPath.substring(0, lastSlashIndex + 1); - const prefix = unescapePath( - lastSlashIndex === -1 - ? partialPath - : partialPath.substring(lastSlashIndex + 1), - ); - - let isMounted = true; - - const findFilesRecursively = async ( - startDir: string, - searchPrefix: string, - fileDiscovery: FileDiscoveryService | null, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - currentRelativePath = '', - depth = 0, - maxDepth = 10, // Limit recursion depth - maxResults = 50, // Limit number of results - ): Promise => { - if (depth > maxDepth) { - return []; - } - - const lowerSearchPrefix = searchPrefix.toLowerCase(); - let foundSuggestions: Suggestion[] = []; - try { - const entries = await fs.readdir(startDir, { withFileTypes: true }); - for (const entry of entries) { - if (foundSuggestions.length >= maxResults) break; - - const entryPathRelative = path.join(currentRelativePath, entry.name); - const entryPathFromRoot = path.relative( - startDir, - path.join(startDir, entry.name), - ); - - // Conditionally ignore dotfiles - if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - - // Check if this entry should be ignored by filtering options - if ( - fileDiscovery && - fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions) - ) { - continue; - } - - if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { - foundSuggestions.push({ - label: entryPathRelative + (entry.isDirectory() ? '/' : ''), - value: escapePath( - entryPathRelative + (entry.isDirectory() ? '/' : ''), - ), - }); - } - if ( - entry.isDirectory() && - entry.name !== 'node_modules' && - !entry.name.startsWith('.') - ) { - if (foundSuggestions.length < maxResults) { - foundSuggestions = foundSuggestions.concat( - await findFilesRecursively( - path.join(startDir, entry.name), - searchPrefix, // Pass original searchPrefix for recursive calls - fileDiscovery, - filterOptions, - entryPathRelative, - depth + 1, - maxDepth, - maxResults - foundSuggestions.length, - ), - ); - } - } - } - } catch (_err) { - // Ignore errors like permission denied or ENOENT during recursive search - } - return foundSuggestions.slice(0, maxResults); - }; - - const findFilesWithGlob = async ( - searchPrefix: string, - fileDiscoveryService: FileDiscoveryService, - filterOptions: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - searchDir: string, - maxResults = 50, - ): Promise => { - const globPattern = `**/${searchPrefix}*`; - const files = await glob(globPattern, { - cwd: searchDir, - dot: searchPrefix.startsWith('.'), - nocase: true, - }); - - const suggestions: Suggestion[] = files - .filter((file) => { - if (fileDiscoveryService) { - return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); - } - return true; - }) - .map((file: string) => { - const absolutePath = path.resolve(searchDir, file); - const label = path.relative(cwd, absolutePath); - return { - label, - value: escapePath(label), - }; - }) - .slice(0, maxResults); - - return suggestions; - }; - - const fetchSuggestions = async () => { - setIsLoadingSuggestions(true); - let fetchedSuggestions: Suggestion[] = []; - - const fileDiscoveryService = config ? config.getFileService() : null; - const enableRecursiveSearch = - config?.getEnableRecursiveFileSearch() ?? true; - const filterOptions = - config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; - - try { - // If there's no slash, or it's the root, do a recursive search from workspace directories - for (const dir of dirs) { - let fetchedSuggestionsPerDir: Suggestion[] = []; - if ( - partialPath.indexOf('/') === -1 && - prefix && - enableRecursiveSearch - ) { - if (fileDiscoveryService) { - fetchedSuggestionsPerDir = await findFilesWithGlob( - prefix, - fileDiscoveryService, - filterOptions, - dir, - ); - } else { - fetchedSuggestionsPerDir = await findFilesRecursively( - dir, - prefix, - null, - filterOptions, - ); - } - } else { - // Original behavior: list files in the specific directory - const lowerPrefix = prefix.toLowerCase(); - const baseDirAbsolute = path.resolve(dir, baseDirRelative); - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, - }); - - // Filter entries using git-aware filtering - const filteredEntries = []; - for (const entry of entries) { - // Conditionally ignore dotfiles - if (!prefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - - const relativePath = path.relative( - dir, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile( - relativePath, - filterOptions, - ) - ) { - continue; - } - - filteredEntries.push(entry); - } - - fetchedSuggestionsPerDir = filteredEntries.map((entry) => { - const absolutePath = path.resolve(baseDirAbsolute, entry.name); - const label = - cwd === dir ? entry.name : path.relative(cwd, absolutePath); - const suggestionLabel = entry.isDirectory() ? label + '/' : label; - return { - label: suggestionLabel, - value: escapePath(suggestionLabel), - }; - }); - } - fetchedSuggestions = [ - ...fetchedSuggestions, - ...fetchedSuggestionsPerDir, - ]; - } - - // Like glob, we always return forward slashes for path separators, even on Windows. - // But preserve backslash escaping for special characters. - const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`; - const pathSeparatorRegex = new RegExp( - `\\\\${specialCharsLookahead}`, - 'g', - ); - fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({ - ...suggestion, - label: suggestion.label.replace(pathSeparatorRegex, '/'), - value: suggestion.value.replace(pathSeparatorRegex, '/'), - })); - - // Sort by depth, then directories first, then alphabetically - fetchedSuggestions.sort((a, b) => { - const depthA = (a.label.match(/\//g) || []).length; - const depthB = (b.label.match(/\//g) || []).length; - - if (depthA !== depthB) { - return depthA - depthB; - } - - const aIsDir = a.label.endsWith('/'); - const bIsDir = b.label.endsWith('/'); - if (aIsDir && !bIsDir) return -1; - if (!aIsDir && bIsDir) return 1; - - // exclude extension when comparing - const filenameA = a.label.substring( - 0, - a.label.length - path.extname(a.label).length, - ); - const filenameB = b.label.substring( - 0, - b.label.length - path.extname(b.label).length, - ); - - return ( - filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label) - ); - }); - - if (isMounted) { - setSuggestions(fetchedSuggestions); - setShowSuggestions(fetchedSuggestions.length > 0); - setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); - setVisibleStartIndex(0); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (isMounted) { - setSuggestions([]); - setShowSuggestions(false); - } - } else { - console.error( - `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, - ); - if (isMounted) { - resetCompletionState(); - } - } - } - if (isMounted) { - setIsLoadingSuggestions(false); - } - }; - - const debounceTimeout = setTimeout(fetchSuggestions, 100); - - return () => { - isMounted = false; - clearTimeout(debounceTimeout); - }; + // Show suggestions if we are loading OR if there are results to display. + setShowSuggestions(isLoadingSuggestions || suggestions.length > 0); }, [ - buffer.text, - cursorRow, - cursorCol, - buffer.lines, - dirs, - cwd, - commandIndex, - resetCompletionState, - slashCommands, - commandContext, - config, + completionMode, + suggestions.length, + isLoadingSuggestions, reverseSearchActive, - setSuggestions, + resetCompletionState, setShowSuggestions, - setActiveSuggestionIndex, - setIsLoadingSuggestions, - setIsPerfectMatch, - setVisibleStartIndex, ]); const handleAutocomplete = useCallback( @@ -616,18 +180,23 @@ export function useCommandCompletion( } const suggestion = suggestions[indexToUse].value; - if (completionStart.current === -1 || completionEnd.current === -1) { + let start = completionStart; + let end = completionEnd; + if (completionMode === CompletionMode.SLASH) { + start = slashCompletionRange.completionStart; + end = slashCompletionRange.completionEnd; + } + + if (start === -1 || end === -1) { return; } - const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/'; let suggestionText = suggestion; - if (isSlash) { - // If we are inserting (not replacing), and the preceding character is not a space, add one. + if (completionMode === CompletionMode.SLASH) { if ( - completionStart.current === completionEnd.current && - completionStart.current > commandIndex + 1 && - (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' ' + start === end && + start > 1 && + (buffer.lines[cursorRow] || '')[start - 1] !== ' ' ) { suggestionText = ' ' + suggestionText; } @@ -636,12 +205,20 @@ export function useCommandCompletion( suggestionText += ' '; buffer.replaceRangeByOffset( - logicalPosToOffset(buffer.lines, cursorRow, completionStart.current), - logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current), + logicalPosToOffset(buffer.lines, cursorRow, start), + logicalPosToOffset(buffer.lines, cursorRow, end), suggestionText, ); }, - [cursorRow, buffer, suggestions, commandIndex], + [ + cursorRow, + buffer, + suggestions, + completionMode, + completionStart, + completionEnd, + slashCompletionRange, + ], ); return { diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx index 1cc7e602..3fb9217e 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -41,12 +41,17 @@ export function useReverseSearchCompletion( navigateDown, } = useCompletion(); - // whenever reverseSearchActive is on, filter history useEffect(() => { if (!reverseSearchActive) { resetCompletionState(); + } + }, [reverseSearchActive, resetCompletionState]); + + useEffect(() => { + if (!reverseSearchActive) { return; } + const q = buffer.text.toLowerCase(); const matches = shellHistory.reduce((acc, cmd) => { const idx = cmd.toLowerCase().indexOf(q); @@ -55,6 +60,7 @@ export function useReverseSearchCompletion( } return acc; }, []); + setSuggestions(matches); setShowSuggestions(matches.length > 0); setActiveSuggestionIndex(matches.length > 0 ? 0 : -1); @@ -62,7 +68,6 @@ export function useReverseSearchCompletion( buffer.text, shellHistory, reverseSearchActive, - resetCompletionState, setActiveSuggestionIndex, setShowSuggestions, setSuggestions, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts new file mode 100644 index 00000000..ba26f2d2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -0,0 +1,434 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useSlashCompletion } from './useSlashCompletion.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; +import { useState } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; + +// Test harness to capture the state from the hook's callbacks. +function useTestHarnessForSlashCompletion( + enabled: boolean, + query: string | null, + slashCommands: readonly SlashCommand[], + commandContext: CommandContext, +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); + const [isPerfectMatch, setIsPerfectMatch] = useState(false); + + const { completionStart, completionEnd } = useSlashCompletion({ + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }); + + return { + suggestions, + isLoadingSuggestions, + isPerfectMatch, + completionStart, + completionEnd, + }; +} + +describe('useSlashCompletion', () => { + // A minimal mock is sufficient for these tests. + const mockCommandContext = {} as CommandContext; + + describe('Top-Level Commands', () => { + it('should suggest all top-level commands for the root slash', async () => { + const slashCommands = [ + { name: 'help', altNames: ['?'], description: 'Show help' }, + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + }, + { name: 'clear', description: 'Clear the screen' }, + { + name: 'memory', + description: 'Manage memory', + subCommands: [{ name: 'show', description: 'Show memory' }], + }, + { name: 'chat', description: 'Manage chat history' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions.length).toBe(slashCommands.length); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), + ); + }); + + it('should filter commands based on partial input', async () => { + const slashCommands = [ + { name: 'memory', description: 'Manage memory' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/mem', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'memory', value: 'memory', description: 'Manage memory' }, + ]); + }); + + it('should suggest commands based on partial altNames', async () => { + const slashCommands = [ + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/usag', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { + label: 'stats', + value: 'stats', + description: 'check session stats. Usage: /stats [model|tools]', + }, + ]); + }); + + it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { + const slashCommands = [ + { name: 'clear', description: 'Clear the screen', action: vi.fn() }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/clear', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + + it.each([['/?'], ['/usage']])( + 'should not suggest commands when altNames is fully typed', + async (query) => { + const mockSlashCommands = [ + { + name: 'help', + altNames: ['?'], + description: 'Show help', + action: vi.fn(), + }, + { + name: 'stats', + altNames: ['usage'], + description: 'check session stats. Usage: /stats [model|tools]', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + query, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }, + ); + + it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { + const slashCommands = [ + { name: 'clear', description: 'Clear the screen' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/clear ', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + + it('should not provide suggestions for an unknown command', async () => { + const slashCommands = [ + { name: 'help', description: 'Show help' }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/unknown-command', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + }); + + describe('Sub-Commands', () => { + it('should suggest sub-commands for a parent command', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { name: 'show', description: 'Show memory' }, + { name: 'add', description: 'Add to memory' }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/memory', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), + ); + }); + + it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { name: 'show', description: 'Show memory' }, + { name: 'add', description: 'Add to memory' }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/memory ', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), + ); + }); + + it('should filter sub-commands by prefix', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { name: 'show', description: 'Show memory' }, + { name: 'add', description: 'Add to memory' }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/memory a', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'add', value: 'add', description: 'Add to memory' }, + ]); + }); + + it('should provide no suggestions for an invalid sub-command', async () => { + const slashCommands = [ + { + name: 'memory', + description: 'Manage memory', + subCommands: [ + { name: 'show', description: 'Show memory' }, + { name: 'add', description: 'Add to memory' }, + ], + }, + ] as unknown as SlashCommand[]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/memory dothisnow', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + }); + }); + + describe('Argument Completion', () => { + it('should call the command.completion function for argument suggestions', async () => { + const availableTags = [ + 'my-chat-tag-1', + 'my-chat-tag-2', + 'another-channel', + ]; + const mockCompletionFn = vi + .fn() + .mockImplementation( + async (_context: CommandContext, partialArg: string) => + availableTags.filter((tag) => tag.startsWith(partialArg)), + ); + + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: mockCompletionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume my-ch', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith( + mockCommandContext, + 'my-ch', + ); + }); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, + { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, + ]); + }); + }); + + it('should call command.completion with an empty string when args start with a space', async () => { + const mockCompletionFn = vi + .fn() + .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); + + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: mockCompletionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); + }); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(3); + }); + }); + + it('should handle completion function that returns null', async () => { + const completionFn = vi.fn().mockResolvedValue(null); + const slashCommands = [ + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'resume', + description: 'Resume a saved chat', + completion: completionFn, + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/chat resume ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts new file mode 100644 index 00000000..9836362f --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { Suggestion } from '../components/SuggestionsDisplay.js'; +import { CommandContext, SlashCommand } from '../commands/types.js'; + +export interface UseSlashCompletionProps { + enabled: boolean; + query: string | null; + slashCommands: readonly SlashCommand[]; + commandContext: CommandContext; + setSuggestions: (suggestions: Suggestion[]) => void; + setIsLoadingSuggestions: (isLoading: boolean) => void; + setIsPerfectMatch: (isMatch: boolean) => void; +} + +export function useSlashCompletion(props: UseSlashCompletionProps): { + completionStart: number; + completionEnd: number; +} { + const { + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + } = props; + const [completionStart, setCompletionStart] = useState(-1); + const [completionEnd, setCompletionEnd] = useState(-1); + + useEffect(() => { + if (!enabled || query === null) { + return; + } + + const fullPath = query?.substring(1) || ''; + const hasTrailingSpace = !!query?.endsWith(' '); + const rawParts = fullPath.split(/\s+/).filter((p) => p); + let commandPathParts = rawParts; + let partial = ''; + + if (!hasTrailingSpace && rawParts.length > 0) { + partial = rawParts[rawParts.length - 1]; + commandPathParts = rawParts.slice(0, -1); + } + + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + let leafCommand: SlashCommand | null = null; + + for (const part of commandPathParts) { + if (!currentLevel) { + leafCommand = null; + currentLevel = []; + break; + } + const found: SlashCommand | undefined = currentLevel.find( + (cmd) => cmd.name === part || cmd.altNames?.includes(part), + ); + if (found) { + leafCommand = found; + currentLevel = found.subCommands as readonly SlashCommand[] | undefined; + } else { + leafCommand = null; + currentLevel = []; + break; + } + } + + let exactMatchAsParent: SlashCommand | undefined; + if (!hasTrailingSpace && currentLevel) { + exactMatchAsParent = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.subCommands, + ); + + if (exactMatchAsParent) { + leafCommand = exactMatchAsParent; + currentLevel = exactMatchAsParent.subCommands; + partial = ''; + } + } + + setIsPerfectMatch(false); + if (!hasTrailingSpace) { + if (leafCommand && partial === '' && leafCommand.action) { + setIsPerfectMatch(true); + } else if (currentLevel) { + const perfectMatch = currentLevel.find( + (cmd) => + (cmd.name === partial || cmd.altNames?.includes(partial)) && + cmd.action, + ); + if (perfectMatch) { + setIsPerfectMatch(true); + } + } + } + + const depth = commandPathParts.length; + const isArgumentCompletion = + leafCommand?.completion && + (hasTrailingSpace || + (rawParts.length > depth && depth > 0 && partial !== '')); + + if (hasTrailingSpace || exactMatchAsParent) { + setCompletionStart(query.length); + setCompletionEnd(query.length); + } else if (partial) { + if (isArgumentCompletion) { + const commandSoFar = `/${commandPathParts.join(' ')}`; + const argStartIndex = + commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0); + setCompletionStart(argStartIndex); + } else { + setCompletionStart(query.length - partial.length); + } + setCompletionEnd(query.length); + } else { + setCompletionStart(1); + setCompletionEnd(query.length); + } + + if (isArgumentCompletion) { + const fetchAndSetSuggestions = async () => { + setIsLoadingSuggestions(true); + const argString = rawParts.slice(depth).join(' '); + const results = + (await leafCommand!.completion!(commandContext, argString)) || []; + const finalSuggestions = results.map((s) => ({ label: s, value: s })); + setSuggestions(finalSuggestions); + setIsLoadingSuggestions(false); + }; + fetchAndSetSuggestions(); + return; + } + + const commandsToSearch = currentLevel || []; + if (commandsToSearch.length > 0) { + let potentialSuggestions = commandsToSearch.filter( + (cmd) => + cmd.description && + (cmd.name.startsWith(partial) || + cmd.altNames?.some((alt) => alt.startsWith(partial))), + ); + + if (potentialSuggestions.length > 0 && !hasTrailingSpace) { + const perfectMatch = potentialSuggestions.find( + (s) => s.name === partial || s.altNames?.includes(partial), + ); + if (perfectMatch && perfectMatch.action) { + potentialSuggestions = []; + } + } + + const finalSuggestions = potentialSuggestions.map((cmd) => ({ + label: cmd.name, + value: cmd.name, + description: cmd.description, + })); + + setSuggestions(finalSuggestions); + return; + } + + setSuggestions([]); + }, [ + enabled, + query, + slashCommands, + commandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + ]); + + return { + completionStart, + completionEnd, + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index cc5e9c2a..6e42a4a9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "chardet": "^2.1.0", "diff": "^7.0.0", "dotenv": "^17.1.0", + "fdir": "^6.4.6", "glob": "^10.4.5", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", @@ -42,6 +43,7 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", + "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", @@ -49,10 +51,12 @@ "ws": "^8.18.0" }, "devDependencies": { + "@google/gemini-cli-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/micromatch": "^4.0.8", "@types/minimatch": "^5.1.2", + "@types/picomatch": "^4.0.1", "@types/ws": "^8.5.10", "typescript": "^5.3.3", "vitest": "^3.1.1" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7dfd90f..e60bd048 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,7 @@ export * from './utils/shell-utils.js'; export * from './utils/systemEncoding.js'; export * from './utils/textUtils.js'; export * from './utils/formatters.js'; +export * from './utils/filesearch/fileSearch.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/utils/filesearch/crawlCache.test.ts b/packages/core/src/utils/filesearch/crawlCache.test.ts new file mode 100644 index 00000000..2feab61a --- /dev/null +++ b/packages/core/src/utils/filesearch/crawlCache.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { getCacheKey, read, write, clear } from './crawlCache.js'; + +describe('CrawlCache', () => { + describe('getCacheKey', () => { + it('should generate a consistent hash', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/foo', 'bar'); + expect(key1).toBe(key2); + }); + + it('should generate a different hash for different directories', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/bar', 'bar'); + expect(key1).not.toBe(key2); + }); + + it('should generate a different hash for different ignore content', () => { + const key1 = getCacheKey('/foo', 'bar'); + const key2 = getCacheKey('/foo', 'baz'); + expect(key1).not.toBe(key2); + }); + }); + + describe('in-memory cache operations', () => { + beforeEach(() => { + // Ensure a clean slate before each test + clear(); + }); + + afterEach(() => { + // Restore real timers after each test that uses fake ones + vi.useRealTimers(); + }); + + it('should write and read data from the cache', () => { + const key = 'test-key'; + const data = ['foo', 'bar']; + write(key, data, 10000); // 10 second TTL + const cachedData = read(key); + expect(cachedData).toEqual(data); + }); + + it('should return undefined for a nonexistent key', () => { + const cachedData = read('nonexistent-key'); + expect(cachedData).toBeUndefined(); + }); + + it('should clear the cache', () => { + const key = 'test-key'; + const data = ['foo', 'bar']; + write(key, data, 10000); + clear(); + const cachedData = read(key); + expect(cachedData).toBeUndefined(); + }); + + it('should automatically evict a cache entry after its TTL expires', async () => { + vi.useFakeTimers(); + const key = 'ttl-key'; + const data = ['foo']; + const ttl = 5000; // 5 seconds + + write(key, data, ttl); + + // Should exist immediately after writing + expect(read(key)).toEqual(data); + + // Advance time just before expiration + await vi.advanceTimersByTimeAsync(ttl - 1); + expect(read(key)).toEqual(data); + + // Advance time past expiration + await vi.advanceTimersByTimeAsync(1); + expect(read(key)).toBeUndefined(); + }); + + it('should reset the timer when an entry is updated', async () => { + vi.useFakeTimers(); + const key = 'update-key'; + const initialData = ['initial']; + const updatedData = ['updated']; + const ttl = 5000; // 5 seconds + + // Write initial data + write(key, initialData, ttl); + + // Advance time, but not enough to expire + await vi.advanceTimersByTimeAsync(3000); + expect(read(key)).toEqual(initialData); + + // Update the data, which should reset the timer + write(key, updatedData, ttl); + expect(read(key)).toEqual(updatedData); + + // Advance time again. If the timer wasn't reset, the total elapsed + // time (3000 + 3000 = 6000) would cause an eviction. + await vi.advanceTimersByTimeAsync(3000); + expect(read(key)).toEqual(updatedData); + + // Advance past the new expiration time + await vi.advanceTimersByTimeAsync(2001); + expect(read(key)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/utils/filesearch/crawlCache.ts b/packages/core/src/utils/filesearch/crawlCache.ts new file mode 100644 index 00000000..3cc948c6 --- /dev/null +++ b/packages/core/src/utils/filesearch/crawlCache.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; + +const crawlCache = new Map(); +const cacheTimers = new Map(); + +/** + * Generates a unique cache key based on the project directory and the content + * of ignore files. This ensures that the cache is invalidated if the project + * or ignore rules change. + */ +export const getCacheKey = ( + directory: string, + ignoreContent: string, +): string => { + const hash = crypto.createHash('sha256'); + hash.update(directory); + hash.update(ignoreContent); + return hash.digest('hex'); +}; + +/** + * Reads cached data from the in-memory cache. + * Returns undefined if the key is not found. + */ +export const read = (key: string): string[] | undefined => crawlCache.get(key); + +/** + * Writes data to the in-memory cache and sets a timer to evict it after the TTL. + */ +export const write = (key: string, results: string[], ttlMs: number): void => { + // Clear any existing timer for this key to prevent premature deletion + if (cacheTimers.has(key)) { + clearTimeout(cacheTimers.get(key)!); + } + + // Store the new data + crawlCache.set(key, results); + + // Set a timer to automatically delete the cache entry after the TTL + const timerId = setTimeout(() => { + crawlCache.delete(key); + cacheTimers.delete(key); + }, ttlMs); + + // Store the timer handle so we can clear it if the entry is updated + cacheTimers.set(key, timerId); +}; + +/** + * Clears the entire cache and all active timers. + * Primarily used for testing. + */ +export const clear = (): void => { + for (const timerId of cacheTimers.values()) { + clearTimeout(timerId); + } + crawlCache.clear(); + cacheTimers.clear(); +}; diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts new file mode 100644 index 00000000..b804d623 --- /dev/null +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -0,0 +1,642 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as cache from './crawlCache.js'; +import { FileSearch, AbortError, filter } from './fileSearch.js'; +import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; + +type FileSearchWithPrivateMethods = FileSearch & { + performCrawl: () => Promise; +}; + +describe('FileSearch', () => { + let tmpDir: string; + afterEach(async () => { + if (tmpDir) { + await cleanupTmpDir(tmpDir); + } + vi.restoreAllMocks(); + }); + + it('should use .geminiignore rules', async () => { + tmpDir = await createTmpDir({ + '.geminiignore': 'dist/', + dist: ['ignored.js'], + src: ['not-ignored.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', '.geminiignore', 'src/not-ignored.js']); + }); + + it('should combine .gitignore and .geminiignore rules', async () => { + tmpDir = await createTmpDir({ + '.gitignore': 'dist/', + '.geminiignore': 'build/', + dist: ['ignored-by-git.js'], + build: ['ignored-by-gemini.js'], + src: ['not-ignored.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'src/', + '.geminiignore', + '.gitignore', + 'src/not-ignored.js', + ]); + }); + + it('should use ignoreDirs option', async () => { + tmpDir = await createTmpDir({ + logs: ['some.log'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: ['logs'], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', 'src/main.js']); + }); + + it('should handle negated directories', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['build/**', '!build/public', '!build/public/**'].join( + '\n', + ), + build: { + 'private.js': '', + public: ['index.html'], + }, + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'build/', + 'build/public/', + 'src/', + '.gitignore', + 'build/public/index.html', + 'src/main.js', + ]); + }); + + it('should filter results with a search pattern', async () => { + tmpDir = await createTmpDir({ + src: { + 'main.js': '', + 'util.ts': '', + 'style.css': '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('**/*.js'); + + expect(results).toEqual(['src/main.js']); + }); + + it('should handle root-level file negation', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['*.mk', '!Foo.mk'].join('\n'), + 'bar.mk': '', + 'Foo.mk': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['.gitignore', 'Foo.mk']); + }); + + it('should handle directory negation with glob', async () => { + tmpDir = await createTmpDir({ + '.gitignore': [ + 'third_party/**', + '!third_party/foo', + '!third_party/foo/bar', + '!third_party/foo/bar/baz_buffer', + ].join('\n'), + third_party: { + foo: { + bar: { + baz_buffer: '', + }, + }, + ignore_this: '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'third_party/', + 'third_party/foo/', + 'third_party/foo/bar/', + '.gitignore', + 'third_party/foo/bar/baz_buffer', + ]); + }); + + it('should correctly handle negated patterns in .gitignore', async () => { + tmpDir = await createTmpDir({ + '.gitignore': ['dist/**', '!dist/keep.js'].join('\n'), + dist: ['ignore.js', 'keep.js'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'dist/', + 'src/', + '.gitignore', + 'dist/keep.js', + 'src/main.js', + ]); + }); + + // New test cases start here + + it('should initialize correctly when ignore files are missing', async () => { + tmpDir = await createTmpDir({ + src: ['file1.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: true, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + // Expect no errors to be thrown during initialization + await expect(fileSearch.initialize()).resolves.toBeUndefined(); + const results = await fileSearch.search(''); + expect(results).toEqual(['src/', 'src/file1.js']); + }); + + it('should respect maxResults option in search', async () => { + tmpDir = await createTmpDir({ + src: { + 'file1.js': '', + 'file2.js': '', + 'file3.js': '', + 'file4.js': '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('**/*.js', { maxResults: 2 }); + + expect(results).toEqual(['src/file1.js', 'src/file2.js']); // Assuming alphabetical sort + }); + + it('should return empty array when no matches are found', async () => { + tmpDir = await createTmpDir({ + src: ['file1.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('nonexistent-file.xyz'); + + expect(results).toEqual([]); + }); + + it('should throw AbortError when filter is aborted', async () => { + const controller = new AbortController(); + const dummyPaths = Array.from({ length: 5000 }, (_, i) => `file${i}.js`); // Large array to ensure yielding + + const filterPromise = filter(dummyPaths, '*.js', controller.signal); + + // Abort after a short delay to ensure filter has started + setTimeout(() => controller.abort(), 1); + + await expect(filterPromise).rejects.toThrow(AbortError); + }); + + describe('with in-memory cache', () => { + beforeEach(() => { + cache.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should throw an error if search is called before initialization', async () => { + tmpDir = await createTmpDir({}); + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await expect(fileSearch.search('')).rejects.toThrow( + 'Engine not initialized. Call initialize() first.', + ); + }); + + it('should hit the cache for subsequent searches', async () => { + tmpDir = await createTmpDir({ 'file1.js': '' }); + const getOptions = () => ({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10, + }); + + const fs1 = new FileSearch(getOptions()); + const crawlSpy1 = vi.spyOn( + fs1 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs1.initialize(); + expect(crawlSpy1).toHaveBeenCalledTimes(1); + + // Second search should hit the cache because the options are identical + const fs2 = new FileSearch(getOptions()); + const crawlSpy2 = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + expect(crawlSpy2).not.toHaveBeenCalled(); + }); + + it('should miss the cache when ignore rules change', async () => { + tmpDir = await createTmpDir({ + '.gitignore': 'a.txt', + 'a.txt': '', + 'b.txt': '', + }); + const options = { + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10000, + }; + + // Initial search to populate the cache + const fs1 = new FileSearch(options); + const crawlSpy1 = vi.spyOn( + fs1 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs1.initialize(); + const results1 = await fs1.search(''); + expect(crawlSpy1).toHaveBeenCalledTimes(1); + expect(results1).toEqual(['.gitignore', 'b.txt']); + + // Modify the ignore file + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'b.txt'); + + // Second search should miss the cache and trigger a recrawl + const fs2 = new FileSearch(options); + const crawlSpy2 = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + const results2 = await fs2.search(''); + expect(crawlSpy2).toHaveBeenCalledTimes(1); + expect(results2).toEqual(['.gitignore', 'a.txt']); + }); + + it('should miss the cache after TTL expires', async () => { + vi.useFakeTimers(); + tmpDir = await createTmpDir({ 'file1.js': '' }); + const options = { + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10, // 10 seconds + }; + + // Initial search to populate the cache + const fs1 = new FileSearch(options); + await fs1.initialize(); + + // Advance time past the TTL + await vi.advanceTimersByTimeAsync(11000); + + // Second search should miss the cache and trigger a recrawl + const fs2 = new FileSearch(options); + const crawlSpy = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + + expect(crawlSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle empty or commented-only ignore files', async () => { + tmpDir = await createTmpDir({ + '.gitignore': '# This is a comment\n\n \n', + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: true, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', '.gitignore', 'src/main.js']); + }); + + it('should always ignore the .git directory', async () => { + tmpDir = await createTmpDir({ + '.git': ['config', 'HEAD'], + src: ['main.js'], + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, // Explicitly disable .gitignore to isolate this rule + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['src/', 'src/main.js']); + }); + + it('should be cancellable via AbortSignal', async () => { + const largeDir: Record = {}; + for (let i = 0; i < 100; i++) { + largeDir[`file${i}.js`] = ''; + } + tmpDir = await createTmpDir(largeDir); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + const controller = new AbortController(); + const searchPromise = fileSearch.search('**/*.js', { + signal: controller.signal, + }); + + // Yield to allow the search to start before aborting. + await new Promise((resolve) => setImmediate(resolve)); + + controller.abort(); + + await expect(searchPromise).rejects.toThrow(AbortError); + }); + + it('should leverage ResultCache for bestBaseQuery optimization', async () => { + tmpDir = await createTmpDir({ + src: { + 'foo.js': '', + 'bar.ts': '', + nested: { + 'baz.js': '', + }, + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, // Enable caching for this test + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + // Perform a broad search to prime the cache + const broadResults = await fileSearch.search('src/**'); + expect(broadResults).toEqual([ + 'src/', + 'src/nested/', + 'src/bar.ts', + 'src/foo.js', + 'src/nested/baz.js', + ]); + + // Perform a more specific search that should leverage the broad search's cached results + const specificResults = await fileSearch.search('src/**/*.js'); + expect(specificResults).toEqual(['src/foo.js', 'src/nested/baz.js']); + + // Although we can't directly inspect ResultCache.hits/misses from here, + // the correctness of specificResults after a broad search implicitly + // verifies that the caching mechanism, including bestBaseQuery, is working. + }); + + it('should be case-insensitive by default', async () => { + tmpDir = await createTmpDir({ + 'File1.Js': '', + 'file2.js': '', + 'FILE3.JS': '', + 'other.txt': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + + // Search with a lowercase pattern + let results = await fileSearch.search('file*.js'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + + // Search with an uppercase pattern + results = await fileSearch.search('FILE*.JS'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + + // Search with a mixed-case pattern + results = await fileSearch.search('FiLe*.Js'); + expect(results).toHaveLength(3); + expect(results).toEqual( + expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']), + ); + }); + + it('should respect maxResults even when the cache returns an exact match', async () => { + tmpDir = await createTmpDir({ + 'file1.js': '', + 'file2.js': '', + 'file3.js': '', + 'file4.js': '', + 'file5.js': '', + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, // Ensure caching is enabled + cacheTtl: 10000, + }); + + await fileSearch.initialize(); + + // 1. Perform a broad search to populate the cache with an exact match. + const initialResults = await fileSearch.search('*.js'); + expect(initialResults).toEqual([ + 'file1.js', + 'file2.js', + 'file3.js', + 'file4.js', + 'file5.js', + ]); + + // 2. Perform the same search again, but this time with a maxResults limit. + const limitedResults = await fileSearch.search('*.js', { maxResults: 2 }); + + // 3. Assert that the maxResults limit was respected, even with a cache hit. + expect(limitedResults).toEqual(['file1.js', 'file2.js']); + }); +}); diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts new file mode 100644 index 00000000..5915821a --- /dev/null +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { fdir } from 'fdir'; +import picomatch from 'picomatch'; +import { Ignore } from './ignore.js'; +import { ResultCache } from './result-cache.js'; +import * as cache from './crawlCache.js'; + +export type FileSearchOptions = { + projectRoot: string; + ignoreDirs: string[]; + useGitignore: boolean; + useGeminiignore: boolean; + cache: boolean; + cacheTtl: number; +}; + +export class AbortError extends Error { + constructor(message = 'Search aborted') { + super(message); + this.name = 'AbortError'; + } +} + +/** + * Filters a list of paths based on a given pattern. + * @param allPaths The list of all paths to filter. + * @param pattern The picomatch pattern to filter by. + * @param signal An AbortSignal to cancel the operation. + * @returns A promise that resolves to the filtered and sorted list of paths. + */ +export async function filter( + allPaths: string[], + pattern: string, + signal: AbortSignal | undefined, +): Promise { + const patternFilter = picomatch(pattern, { + dot: true, + contains: true, + nocase: true, + }); + + const results: string[] = []; + for (const [i, p] of allPaths.entries()) { + // Yield control to the event loop periodically to prevent blocking. + if (i % 1000 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + if (signal?.aborted) { + throw new AbortError(); + } + } + + if (patternFilter(p)) { + results.push(p); + } + } + + results.sort((a, b) => { + const aIsDir = a.endsWith('/'); + const bIsDir = b.endsWith('/'); + + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + // This is 40% faster than localeCompare and the only thing we would really + // gain from localeCompare is case-sensitive sort + return a < b ? -1 : a > b ? 1 : 0; + }); + + return results; +} + +export type SearchOptions = { + signal?: AbortSignal; + maxResults?: number; +}; + +/** + * Provides a fast and efficient way to search for files within a project, + * respecting .gitignore and .geminiignore rules, and utilizing caching + * for improved performance. + */ +export class FileSearch { + private readonly absoluteDir: string; + private readonly ignore: Ignore = new Ignore(); + private resultCache: ResultCache | undefined; + private allFiles: string[] = []; + + /** + * Constructs a new `FileSearch` instance. + * @param options Configuration options for the file search. + */ + constructor(private readonly options: FileSearchOptions) { + this.absoluteDir = path.resolve(options.projectRoot); + } + + /** + * Initializes the file search engine by loading ignore rules, crawling the + * file system, and building the in-memory cache. This method must be called + * before performing any searches. + */ + async initialize(): Promise { + this.loadIgnoreRules(); + await this.crawlFiles(); + this.buildResultCache(); + } + + /** + * Searches for files matching a given pattern. + * @param pattern The picomatch pattern to search for (e.g., '*.js', 'src/**'). + * @param options Search options, including an AbortSignal and maxResults. + * @returns A promise that resolves to a list of matching file paths, relative + * to the project root. + */ + async search( + pattern: string, + options: SearchOptions = {}, + ): Promise { + if (!this.resultCache) { + throw new Error('Engine not initialized. Call initialize() first.'); + } + + pattern = pattern || '*'; + + const { files: candidates, isExactMatch } = + await this.resultCache!.get(pattern); + + let filteredCandidates; + if (isExactMatch) { + filteredCandidates = candidates; + } else { + // Apply the user's picomatch pattern filter + filteredCandidates = await filter(candidates, pattern, options.signal); + this.resultCache!.set(pattern, filteredCandidates); + } + + // Trade-off: We apply a two-stage filtering process. + // 1. During the file system crawl (`performCrawl`), we only apply directory-level + // ignore rules (e.g., `node_modules/`, `dist/`). This is because applying + // a full ignore filter (which includes file-specific patterns like `*.log`) + // during the crawl can significantly slow down `fdir`. + // 2. Here, in the `search` method, we apply the full ignore filter + // (including file patterns) to the `filteredCandidates` (which have already + // been filtered by the user's search pattern and sorted). For autocomplete, + // the number of displayed results is small (MAX_SUGGESTIONS_TO_SHOW), + // so applying the full filter to this truncated list is much more efficient + // than applying it to every file during the initial crawl. + const fileFilter = this.ignore.getFileFilter(); + const results: string[] = []; + for (const [i, candidate] of filteredCandidates.entries()) { + // Yield to the event loop to avoid blocking on large result sets. + if (i % 1000 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + if (options.signal?.aborted) { + throw new AbortError(); + } + } + + if (results.length >= (options.maxResults ?? Infinity)) { + break; + } + // The `ignore` library throws an error if the path is '.', so we skip it. + if (candidate === '.') { + continue; + } + if (!fileFilter(candidate)) { + results.push(candidate); + } + } + return results; + } + + /** + * Loads ignore rules from .gitignore and .geminiignore files, and applies + * any additional ignore directories specified in the options. + */ + private loadIgnoreRules(): void { + if (this.options.useGitignore) { + const gitignorePath = path.join(this.absoluteDir, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + this.ignore.add(fs.readFileSync(gitignorePath, 'utf8')); + } + } + + if (this.options.useGeminiignore) { + const geminiignorePath = path.join(this.absoluteDir, '.geminiignore'); + if (fs.existsSync(geminiignorePath)) { + this.ignore.add(fs.readFileSync(geminiignorePath, 'utf8')); + } + } + + const ignoreDirs = ['.git', ...this.options.ignoreDirs]; + this.ignore.add( + ignoreDirs.map((dir) => { + if (dir.endsWith('/')) { + return dir; + } + return `${dir}/`; + }), + ); + } + + /** + * Crawls the file system to get a list of all files and directories, + * optionally using a cache for faster initialization. + */ + private async crawlFiles(): Promise { + if (this.options.cache) { + const cacheKey = cache.getCacheKey( + this.absoluteDir, + this.ignore.getFingerprint(), + ); + const cachedResults = cache.read(cacheKey); + + if (cachedResults) { + this.allFiles = cachedResults; + return; + } + } + + this.allFiles = await this.performCrawl(); + + if (this.options.cache) { + const cacheKey = cache.getCacheKey( + this.absoluteDir, + this.ignore.getFingerprint(), + ); + cache.write(cacheKey, this.allFiles, this.options.cacheTtl * 1000); + } + } + + /** + * Performs the actual file system crawl using `fdir`, applying directory + * ignore rules. + * @returns A promise that resolves to a list of all files and directories. + */ + private async performCrawl(): Promise { + const dirFilter = this.ignore.getDirectoryFilter(); + + // We use `fdir` for fast file system traversal. A key performance + // optimization for large workspaces is to exclude entire directories + // early in the traversal process. This is why we apply directory-specific + // ignore rules (e.g., `node_modules/`, `dist/`) directly to `fdir`'s + // exclude filter. + const api = new fdir() + .withRelativePaths() + .withDirs() + .withPathSeparator('/') // Always use unix style paths + .exclude((_, dirPath) => { + const relativePath = path.relative(this.absoluteDir, dirPath); + return dirFilter(`${relativePath}/`); + }); + + return api.crawl(this.absoluteDir).withPromise(); + } + + /** + * Builds the in-memory cache for fast pattern matching. + */ + private buildResultCache(): void { + this.resultCache = new ResultCache(this.allFiles, this.absoluteDir); + } +} diff --git a/packages/core/src/utils/filesearch/ignore.test.ts b/packages/core/src/utils/filesearch/ignore.test.ts new file mode 100644 index 00000000..ff375e3f --- /dev/null +++ b/packages/core/src/utils/filesearch/ignore.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { Ignore } from './ignore.js'; + +describe('Ignore', () => { + describe('getDirectoryFilter', () => { + it('should ignore directories matching directory patterns', () => { + const ig = new Ignore().add(['foo/', 'bar/']); + const dirFilter = ig.getDirectoryFilter(); + expect(dirFilter('foo/')).toBe(true); + expect(dirFilter('bar/')).toBe(true); + expect(dirFilter('baz/')).toBe(false); + }); + + it('should not ignore directories with file patterns', () => { + const ig = new Ignore().add(['foo.js', '*.log']); + const dirFilter = ig.getDirectoryFilter(); + expect(dirFilter('foo.js')).toBe(false); + expect(dirFilter('foo.log')).toBe(false); + }); + }); + + describe('getFileFilter', () => { + it('should not ignore files with directory patterns', () => { + const ig = new Ignore().add(['foo/', 'bar/']); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo')).toBe(false); + expect(fileFilter('foo/file.txt')).toBe(false); + }); + + it('should ignore files matching file patterns', () => { + const ig = new Ignore().add(['*.log', 'foo.js']); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo.log')).toBe(true); + expect(fileFilter('foo.js')).toBe(true); + expect(fileFilter('bar.txt')).toBe(false); + }); + }); + + it('should accumulate patterns across multiple add() calls', () => { + const ig = new Ignore().add('foo.js'); + ig.add('bar.js'); + const fileFilter = ig.getFileFilter(); + expect(fileFilter('foo.js')).toBe(true); + expect(fileFilter('bar.js')).toBe(true); + expect(fileFilter('baz.js')).toBe(false); + }); + + it('should return a stable and consistent fingerprint', () => { + const ig1 = new Ignore().add(['foo', '!bar']); + const ig2 = new Ignore().add('foo\n!bar'); + + // Fingerprints should be identical for the same rules. + expect(ig1.getFingerprint()).toBe(ig2.getFingerprint()); + + // Adding a new rule should change the fingerprint. + ig2.add('baz'); + expect(ig1.getFingerprint()).not.toBe(ig2.getFingerprint()); + }); +}); diff --git a/packages/core/src/utils/filesearch/ignore.ts b/packages/core/src/utils/filesearch/ignore.ts new file mode 100644 index 00000000..9f756f93 --- /dev/null +++ b/packages/core/src/utils/filesearch/ignore.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import ignore from 'ignore'; +import picomatch from 'picomatch'; + +const hasFileExtension = picomatch('**/*[*.]*'); + +export class Ignore { + private readonly allPatterns: string[] = []; + private dirIgnorer = ignore(); + private fileIgnorer = ignore(); + + /** + * Adds one or more ignore patterns. + * @param patterns A single pattern string or an array of pattern strings. + * Each pattern can be a glob-like string similar to .gitignore rules. + * @returns The `Ignore` instance for chaining. + */ + add(patterns: string | string[]): this { + if (typeof patterns === 'string') { + patterns = patterns.split(/\r?\n/); + } + + for (const p of patterns) { + const pattern = p.trim(); + + if (pattern === '' || pattern.startsWith('#')) { + continue; + } + + this.allPatterns.push(pattern); + + const isPositiveDirPattern = + pattern.endsWith('/') && !pattern.startsWith('!'); + + if (isPositiveDirPattern) { + this.dirIgnorer.add(pattern); + } else { + // An ambiguous pattern (e.g., "build") could match a file or a + // directory. To optimize the file system crawl, we use a heuristic: + // patterns without a dot in the last segment are included in the + // directory exclusion check. + // + // This heuristic can fail. For example, an ignore pattern of "my.assets" + // intended to exclude a directory will not be treated as a directory + // pattern because it contains a ".". This results in crawling a + // directory that should have been excluded, reducing efficiency. + // Correctness is still maintained. The incorrectly crawled directory + // will be filtered out by the final ignore check. + // + // For maximum crawl efficiency, users should explicitly mark directory + // patterns with a trailing slash (e.g., "my.assets/"). + this.fileIgnorer.add(pattern); + if (!hasFileExtension(pattern)) { + this.dirIgnorer.add(pattern); + } + } + } + + return this; + } + + /** + * Returns a predicate that matches explicit directory ignore patterns (patterns ending with '/'). + * @returns {(dirPath: string) => boolean} + */ + getDirectoryFilter(): (dirPath: string) => boolean { + return (dirPath: string) => this.dirIgnorer.ignores(dirPath); + } + + /** + * Returns a predicate that matches file ignore patterns (all patterns not ending with '/'). + * Note: This may also match directories if a file pattern matches a directory name, but all explicit directory patterns are handled by getDirectoryFilter. + * @returns {(filePath: string) => boolean} + */ + getFileFilter(): (filePath: string) => boolean { + return (filePath: string) => this.fileIgnorer.ignores(filePath); + } + + /** + * Returns a string representing the current set of ignore patterns. + * This can be used to generate a unique identifier for the ignore configuration, + * useful for caching purposes. + * @returns A string fingerprint of the ignore patterns. + */ + getFingerprint(): string { + return this.allPatterns.join('\n'); + } +} diff --git a/packages/core/src/utils/filesearch/result-cache.test.ts b/packages/core/src/utils/filesearch/result-cache.test.ts new file mode 100644 index 00000000..0b1b4e17 --- /dev/null +++ b/packages/core/src/utils/filesearch/result-cache.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { test, expect } from 'vitest'; +import { ResultCache } from './result-cache.js'; + +test('ResultCache basic usage', async () => { + const files = [ + 'foo.txt', + 'bar.js', + 'baz.md', + 'subdir/file.txt', + 'subdir/other.js', + 'subdir/nested/file.md', + ]; + const cache = new ResultCache(files, path.resolve('.')); + const { files: resultFiles, isExactMatch } = await cache.get('*.js'); + expect(resultFiles).toEqual(files); + expect(isExactMatch).toBe(false); +}); + +test('ResultCache cache hit/miss', async () => { + const files = ['foo.txt', 'bar.js', 'baz.md']; + const cache = new ResultCache(files, path.resolve('.')); + // First call: miss + const { files: result1Files, isExactMatch: isExactMatch1 } = + await cache.get('*.js'); + expect(result1Files).toEqual(files); + expect(isExactMatch1).toBe(false); + + // Simulate FileSearch applying the filter and setting the result + cache.set('*.js', ['bar.js']); + + // Second call: hit + const { files: result2Files, isExactMatch: isExactMatch2 } = + await cache.get('*.js'); + expect(result2Files).toEqual(['bar.js']); + expect(isExactMatch2).toBe(true); +}); + +test('ResultCache best base query', async () => { + const files = ['foo.txt', 'foobar.js', 'baz.md']; + const cache = new ResultCache(files, path.resolve('.')); + + // Cache a broader query + cache.set('foo', ['foo.txt', 'foobar.js']); + + // Search for a more specific query that starts with the broader one + const { files: resultFiles, isExactMatch } = await cache.get('foobar'); + expect(resultFiles).toEqual(['foo.txt', 'foobar.js']); + expect(isExactMatch).toBe(false); +}); diff --git a/packages/core/src/utils/filesearch/result-cache.ts b/packages/core/src/utils/filesearch/result-cache.ts new file mode 100644 index 00000000..77b99aec --- /dev/null +++ b/packages/core/src/utils/filesearch/result-cache.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Implements an in-memory cache for file search results. + * This cache optimizes subsequent searches by leveraging previously computed results. + */ +export class ResultCache { + private readonly cache: Map; + private hits = 0; + private misses = 0; + + constructor( + private readonly allFiles: string[], + private readonly absoluteDir: string, + ) { + this.cache = new Map(); + } + + /** + * Retrieves cached search results for a given query, or provides a base set + * of files to search from. + * @param query The search query pattern. + * @returns An object containing the files to search and a boolean indicating + * if the result is an exact cache hit. + */ + async get( + query: string, + ): Promise<{ files: string[]; isExactMatch: boolean }> { + const isCacheHit = this.cache.has(query); + + if (isCacheHit) { + this.hits++; + return { files: this.cache.get(query)!, isExactMatch: true }; + } + + this.misses++; + + // This is the core optimization of the memory cache. + // If a user first searches for "foo", and then for "foobar", + // we don't need to search through all files again. We can start + // from the results of the "foo" search. + // This finds the most specific, already-cached query that is a prefix + // of the current query. + let bestBaseQuery = ''; + for (const key of this.cache?.keys?.() ?? []) { + if (query.startsWith(key) && key.length > bestBaseQuery.length) { + bestBaseQuery = key; + } + } + + const filesToSearch = bestBaseQuery + ? this.cache.get(bestBaseQuery)! + : this.allFiles; + + return { files: filesToSearch, isExactMatch: false }; + } + + /** + * Stores search results in the cache. + * @param query The search query pattern. + * @param results The matching file paths to cache. + */ + set(query: string, results: string[]): void { + this.cache.set(query, results); + } +} diff --git a/packages/test-utils/index.ts b/packages/test-utils/index.ts new file mode 100644 index 00000000..d69ad168 --- /dev/null +++ b/packages/test-utils/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/file-system-test-helpers.js'; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 00000000..846a2ec0 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,18 @@ +{ + "name": "@google/gemini-cli-test-utils", + "version": "0.1.0", + "private": true, + "main": "src/index.ts", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "node ../../scripts/build_package.js", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/test-utils/src/file-system-test-helpers.ts b/packages/test-utils/src/file-system-test-helpers.ts new file mode 100644 index 00000000..f78c7af4 --- /dev/null +++ b/packages/test-utils/src/file-system-test-helpers.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Defines the structure of a virtual file system to be created for testing. + * Keys are file or directory names, and values can be: + * - A string: The content of a file. + * - A `FileSystemStructure` object: Represents a subdirectory with its own structure. + * - An array of strings or `FileSystemStructure` objects: Represents a directory + * where strings are empty files and objects are subdirectories. + * + * @example + * // Example 1: Simple files and directories + * const structure1 = { + * 'file1.txt': 'Hello, world!', + * 'empty-dir': [], + * 'src': { + * 'main.js': '// Main application file', + * 'utils.ts': '// Utility functions', + * }, + * }; + * + * @example + * // Example 2: Nested directories and empty files within an array + * const structure2 = { + * 'config.json': '{ "port": 3000 }', + * 'data': [ + * 'users.csv', + * 'products.json', + * { + * 'logs': [ + * 'error.log', + * 'access.log', + * ], + * }, + * ], + * }; + */ +export type FileSystemStructure = { + [name: string]: + | string + | FileSystemStructure + | Array; +}; + +/** + * Recursively creates files and directories based on the provided `FileSystemStructure`. + * @param dir The base directory where the structure will be created. + * @param structure The `FileSystemStructure` defining the files and directories. + */ +async function create(dir: string, structure: FileSystemStructure) { + for (const [name, content] of Object.entries(structure)) { + const newPath = path.join(dir, name); + if (typeof content === 'string') { + await fs.writeFile(newPath, content); + } else if (Array.isArray(content)) { + await fs.mkdir(newPath, { recursive: true }); + for (const item of content) { + if (typeof item === 'string') { + await fs.writeFile(path.join(newPath, item), ''); + } else { + await create(newPath, item as FileSystemStructure); + } + } + } else if (typeof content === 'object' && content !== null) { + await fs.mkdir(newPath, { recursive: true }); + await create(newPath, content as FileSystemStructure); + } + } +} + +/** + * Creates a temporary directory and populates it with a given file system structure. + * @param structure The `FileSystemStructure` to create within the temporary directory. + * @returns A promise that resolves to the absolute path of the created temporary directory. + */ +export async function createTmpDir( + structure: FileSystemStructure, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + await create(tmpDir, structure); + return tmpDir; +} + +/** + * Cleans up (deletes) a temporary directory and its contents. + * @param dir The absolute path to the temporary directory to clean up. + */ +export async function cleanupTmpDir(dir: string) { + await fs.rm(dir, { recursive: true, force: true }); +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 00000000..b8af8aa7 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './file-system-test-helpers.js'; diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 00000000..ee9b84b1 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["DOM", "DOM.Iterable", "ES2021"], + "composite": true, + "types": ["node"] + }, + "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} From 91035ad7b0a6d976c003f37fef9513da8f3635e9 Mon Sep 17 00:00:00 2001 From: Justin Mahood Date: Tue, 5 Aug 2025 16:29:37 -0700 Subject: [PATCH 128/136] Fix(vim): Fix shell mode in Vim mode (#5567) Co-authored-by: Jacob Richman --- packages/cli/src/ui/hooks/vim.test.ts | 67 ++++++++++++++++++++++++++- packages/cli/src/ui/hooks/vim.ts | 18 +++++-- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.ts index f939982f..0139119e 100644 --- a/packages/cli/src/ui/hooks/vim.test.ts +++ b/packages/cli/src/ui/hooks/vim.test.ts @@ -1203,7 +1203,9 @@ describe('useVim hook', () => { }); // Press escape to clear pending state - exitInsertMode(result); + act(() => { + result.current.handleInput({ name: 'escape' }); + }); // Now 'w' should just move cursor, not delete act(() => { @@ -1215,6 +1217,69 @@ describe('useVim hook', () => { expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); }); }); + + describe('NORMAL mode escape behavior', () => { + it('should pass escape through when no pending operator is active', () => { + mockVimContext.vimMode = 'NORMAL'; + const { result } = renderVimHook(); + + const handled = result.current.handleInput({ name: 'escape' }); + + expect(handled).toBe(false); + }); + + it('should handle escape and clear pending operator', () => { + mockVimContext.vimMode = 'NORMAL'; + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + let handled: boolean | undefined; + act(() => { + handled = result.current.handleInput({ name: 'escape' }); + }); + + expect(handled).toBe(true); + }); + }); + }); + + describe('Shell command pass-through', () => { + it('should pass through ctrl+r in INSERT mode', () => { + mockVimContext.vimMode = 'INSERT'; + const { result } = renderVimHook(); + + const handled = result.current.handleInput({ name: 'r', ctrl: true }); + + expect(handled).toBe(false); + }); + + it('should pass through ! in INSERT mode when buffer is empty', () => { + mockVimContext.vimMode = 'INSERT'; + const emptyBuffer = createMockBuffer(''); + const { result } = renderVimHook(emptyBuffer); + + const handled = result.current.handleInput({ sequence: '!' }); + + expect(handled).toBe(false); + }); + + it('should handle ! as input in INSERT mode when buffer is not empty', () => { + mockVimContext.vimMode = 'INSERT'; + const nonEmptyBuffer = createMockBuffer('not empty'); + const { result } = renderVimHook(nonEmptyBuffer); + const key = { sequence: '!', name: '!' }; + + act(() => { + result.current.handleInput(key); + }); + + expect(nonEmptyBuffer.handleInput).toHaveBeenCalledWith( + expect.objectContaining(key), + ); + }); }); // Line operations (dd, cc) are tested in text-buffer.test.ts diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index cb65e1ee..97b73121 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -260,7 +260,8 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { normalizedKey.name === 'tab' || (normalizedKey.name === 'return' && !normalizedKey.ctrl) || normalizedKey.name === 'up' || - normalizedKey.name === 'down' + normalizedKey.name === 'down' || + (normalizedKey.ctrl && normalizedKey.name === 'r') ) { return false; // Let InputPrompt handle completion } @@ -270,6 +271,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle clipboard functionality } + // Let InputPrompt handle shell commands + if (normalizedKey.sequence === '!' && buffer.text.length === 0) { + return false; + } + // Special handling for Enter key to allow command submission (lower priority than completion) if ( normalizedKey.name === 'return' && @@ -399,10 +405,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Handle NORMAL mode if (state.mode === 'NORMAL') { - // Handle Escape key in NORMAL mode - clear all pending states + // If in NORMAL mode, allow escape to pass through to other handlers + // if there's no pending operation. if (normalizedKey.name === 'escape') { - dispatch({ type: 'CLEAR_PENDING_STATES' }); - return true; // Handled by vim + if (state.pendingOperator) { + dispatch({ type: 'CLEAR_PENDING_STATES' }); + return true; // Handled by vim + } + return false; // Pass through to other handlers } // Handle count input (numbers 1-9, and 0 if count > 0) From 805114aef82de95c7d66be9f7ba170cf61dfb9fa Mon Sep 17 00:00:00 2001 From: David Rees <770765+studgeek@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:30:57 -0700 Subject: [PATCH 129/136] fix(docs): Fix code block delimiters in commands.md (#5521) Co-authored-by: Sandy Tao --- docs/cli/commands.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index c4590f5f..c912ca2d 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -256,7 +256,7 @@ Please generate a Conventional Commit message based on the following git diff: ```diff !{git diff --staged} -```` +``` """ @@ -277,7 +277,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi ```bash mkdir -p ~/.gemini/commands/refactor touch ~/.gemini/commands/refactor/pure.toml -```` +``` **2. Add the content to the file:** From aeb660226669a030c483f7e3945a4b797519715f Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 5 Aug 2025 23:59:14 +0000 Subject: [PATCH 130/136] Remove a few witty loading phrases (#5631) Co-authored-by: matt korwel --- packages/cli/src/ui/hooks/usePhraseCycler.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 83d68601..e87b4a03 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -38,7 +38,6 @@ export const WITTY_LOADING_PHRASES = [ 'Defragmenting memories... both RAM and personal...', 'Rebooting the humor module...', 'Caching the essentials (mostly cat memes)...', - 'Running sudo make me a sandwich...', 'Optimizing for ludicrous speed', "Swapping bits... don't tell the bytes...", 'Garbage collecting... be right back...', @@ -66,12 +65,10 @@ export const WITTY_LOADING_PHRASES = [ "Just a moment, I'm tuning the algorithms...", 'Warp speed engaged...', 'Mining for more Dilithium crystals...', - "I'm Giving Her all she's got Captain!", "Don't panic...", 'Following the white rabbit...', 'The truth is in here... somewhere...', 'Blowing on the cartridge...', - 'Looking for the princess in another castle...', 'Loading... Do a barrel roll!', 'Waiting for the respawn...', 'Finishing the Kessel Run in less than 12 parsecs...', From 02f7e48c51efab542d734ea23bec3ef375cd3d7d Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Tue, 5 Aug 2025 20:01:18 -0400 Subject: [PATCH 131/136] Removed GitHub Actions experiment files (#5627) --- .github/workflows/scripts/generate-report.sh | 23 ------------------- .github/workflows/weekly-velocity-report.yml | 24 -------------------- 2 files changed, 47 deletions(-) delete mode 100644 .github/workflows/scripts/generate-report.sh delete mode 100644 .github/workflows/weekly-velocity-report.yml diff --git a/.github/workflows/scripts/generate-report.sh b/.github/workflows/scripts/generate-report.sh deleted file mode 100644 index 62dfb18c..00000000 --- a/.github/workflows/scripts/generate-report.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# --- Date Calculation --- -START_DATE=$(date -d "last Friday" +%Y-%m-%d) -END_DATE=$(date -d "last Thursday" +%Y-%m-%d) -DATE_RANGE="${START_DATE}..${END_DATE}" - -# --- Report Generation --- -# Print a header row for the CSV -echo "Date Range,Username,PRs Submitted,Issues Closed" - -# Get a list of all repository contributors, filter out bots, and extract the login name -USERNAMES=$(gh repo contributors --repo "${GITHUB_REPO}" --json "login" --jq '.[] | select(.login | contains("[bot]") | not) | .login') - -# Loop through each user and generate a data row -for USER in $USERNAMES; do - # Get metrics using the GitHub CLI - PRS_SUBMITTED=$(gh pr list --author "${USER}" --search "created:${DATE_RANGE}" --repo "${GITHUB_REPO}" --json number --jq 'length') - ISSUES_CLOSED=$(gh issue list --search 'closer:"${USER}" closed:${DATE_RANGE}' --repo "${GITHUB_REPO}" --json number --jq 'length') - - # Print the data as a CSV row - echo "${START_DATE} to ${END_DATE},${USER},${PRS_SUBMITTED},${ISSUES_CLOSED}" -done diff --git a/.github/workflows/weekly-velocity-report.yml b/.github/workflows/weekly-velocity-report.yml deleted file mode 100644 index dc0471bb..00000000 --- a/.github/workflows/weekly-velocity-report.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Test Third-Party Action Access - -on: workflow_dispatch - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: This is an official action that will likely work - uses: actions/checkout@v4 - - - name: This is a very popular, non-official action - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: This is the Google Sheets action we want to use - uses: gautamkrishnar/append-csv-to-google-sheet-action@v2 - with: - # We don't need real inputs, we just want to see if it can be found - sheet-name: 'Test' - csv-text: 'test' - spreadsheet-id: 'test' - google-api-key-base64: 'test' From 59bde4a612df81db901110d396afe1c19f4308a7 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Tue, 5 Aug 2025 17:37:44 -0700 Subject: [PATCH 132/136] fix(core) Fix not resetting when after first get out of completion suggestions (#5635) Co-authored-by: Jacob Richman Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/ui/hooks/useAtCompletion.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index eaa2a5e6..e63a707f 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -127,6 +127,13 @@ export function useAtCompletion(props: UseAtCompletionProps): void { // Reacts to user input (`pattern`) ONLY. useEffect(() => { if (!enabled) { + // reset when first getting out of completion suggestions + if ( + state.status === AtCompletionStatus.READY || + state.status === AtCompletionStatus.ERROR + ) { + dispatch({ type: 'RESET' }); + } return; } if (pattern === null) { From cd7e60e008908819affb81aa73a8e5a8b1e81c06 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Tue, 5 Aug 2025 20:47:28 -0400 Subject: [PATCH 133/136] switch from heads to tags in url path (#5638) --- packages/cli/src/ui/commands/setupGithubCommand.test.ts | 2 +- packages/cli/src/ui/commands/setupGithubCommand.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 7c654149..891c84e7 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -49,7 +49,7 @@ describe('setupGithubCommand', () => { `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`, `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`, `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`, - 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/v0/examples/workflows/', + 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/', ]; for (const substring of expectedSubstrings) { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 9dd12292..e330cfab 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -28,7 +28,7 @@ export const setupGithubCommand: SlashCommand = { } const version = 'v0'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`; + const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; const workflows = [ 'gemini-cli/gemini-cli.yml', From ea96293e1617f116f085bb7322d4933171c64560 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Aug 2025 00:58:42 +0000 Subject: [PATCH 134/136] chore(release): v0.1.18 --- package-lock.json | 10 +++++----- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index b16c4904..0027364d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.1.17", + "version": "0.1.18", "workspaces": [ "packages/*" ], @@ -11670,7 +11670,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.1.17", + "version": "0.1.18", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", @@ -11872,7 +11872,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.1.17", + "version": "0.1.18", "dependencies": { "@google/genai": "1.9.0", "@modelcontextprotocol/sdk": "^1.11.0", @@ -11997,7 +11997,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.1.0", + "version": "0.1.18", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" diff --git a/package.json b/package.json index bb7896c5..cb0dba38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.17", + "version": "0.1.18", "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.1.17" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.18" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index ca64f6f7..866c9059 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.1.17", + "version": "0.1.18", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.17" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.18" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index 6e42a4a9..19125ce1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.1.17", + "version": "0.1.18", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 846a2ec0..fe401c37 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.1.0", + "version": "0.1.18", "private": true, "main": "src/index.ts", "license": "Apache-2.0", From b5514fd05202a5316963a22366c85c0665acda81 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 13 Aug 2025 16:00:26 +0800 Subject: [PATCH 135/136] chore: fix invalid package deps --- package-lock.json | 4132 +++++++++++++---- packages/cli/package.json | 2 +- .../src/ui/hooks/useCommandCompletion.test.ts | 2 +- packages/core/package.json | 2 +- .../src/utils/filesearch/fileSearch.test.ts | 2 +- 5 files changed, 3266 insertions(+), 874 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3073a15f..ac4dbdc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,8 @@ }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -59,8 +61,34 @@ "node": ">=14.13.1" } }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -73,6 +101,8 @@ }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { @@ -85,6 +115,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -95,8 +127,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -105,17 +145,21 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -125,7 +169,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.2", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "dev": true, "license": "MIT", "engines": { @@ -133,7 +179,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -146,6 +194,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -154,6 +204,8 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", "dev": true, "funding": [ { @@ -172,6 +224,8 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -194,6 +248,8 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", "dev": true, "funding": [ { @@ -220,6 +276,8 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -241,6 +299,8 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -257,8 +317,78 @@ "node": ">=18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", "cpu": [ "arm64" ], @@ -272,8 +402,367 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -289,8 +778,23 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", "engines": { @@ -298,7 +802,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -310,28 +816,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -339,7 +827,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.15.1", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -351,6 +841,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -371,17 +863,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -391,27 +876,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.32.0", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { @@ -423,6 +891,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -430,7 +900,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -441,31 +913,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@google/gemini-cli-test-utils": { - "resolved": "packages/test-utils", - "link": true - }, - "node_modules/@google/genai": { - "version": "1.9.0", + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^9.14.2", - "ws": "^8.18.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.13", @@ -477,6 +941,8 @@ }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -493,6 +959,8 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -501,6 +969,8 @@ }, "node_modules/@humanfs/node": { "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -513,6 +983,8 @@ }, "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -525,6 +997,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -537,6 +1011,8 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -549,10 +1025,14 @@ }, "node_modules/@iarna/toml": { "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", "license": "ISC" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -566,27 +1046,10 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", "engines": { @@ -594,7 +1057,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", "dev": true, "license": "MIT", "dependencies": { @@ -605,16 +1070,34 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "license": "MIT", "engines": { @@ -622,12 +1105,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -637,6 +1124,8 @@ }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", "license": "MIT", "funding": { "type": "opencollective", @@ -645,6 +1134,8 @@ }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -659,7 +1150,9 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.4.0", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", + "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -680,7 +1173,9 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.8.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.6.0.tgz", + "integrity": "sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==", "dev": true, "license": "Apache-2.0", "engines": { @@ -696,6 +1191,8 @@ }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", "license": "MIT", "dependencies": { "debug": "^4.1.1" @@ -703,10 +1200,14 @@ }, "node_modules/@kwsites/promise-deferred": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.1", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", + "integrity": "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -728,6 +1229,8 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -740,6 +1243,8 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -748,6 +1253,8 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -760,6 +1267,8 @@ }, "node_modules/@opentelemetry/api": { "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -767,6 +1276,8 @@ }, "node_modules/@opentelemetry/api-logs": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", + "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.0.0" @@ -777,6 +1288,8 @@ }, "node_modules/@opentelemetry/context-async-hooks": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.25.1.tgz", + "integrity": "sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -787,6 +1300,8 @@ }, "node_modules/@opentelemetry/core": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", + "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "1.25.1" @@ -800,6 +1315,8 @@ }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.52.1.tgz", + "integrity": "sha512-sXgcp4fsL3zCo96A0LmFIGYOj2LSEDI6wD7nBYRhuDDxeRsk18NQgqRVlCf4VIyTBZzGu1M7yOtdFukQPgII1A==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", @@ -817,6 +1334,8 @@ }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.52.1.tgz", + "integrity": "sha512-CE0f1IEE1GQj8JWl/BxKvKwx9wBTLR09OpPQHaIs5LGBw3ODu8ek5kcbrHPNsFYh/pWh+pcjbZQoxq3CqvQVnA==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", @@ -837,6 +1356,8 @@ }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.52.1.tgz", + "integrity": "sha512-oAHPOy1sZi58bwqXaucd19F/v7+qE2EuVslQOEeLQT94CDuZJJ4tbWzx8DpYBTrOSzKqqrMtx9+PMxkrcbxOyQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -854,6 +1375,8 @@ }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.52.1.tgz", + "integrity": "sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", @@ -872,6 +1395,8 @@ }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.52.1.tgz", + "integrity": "sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -889,6 +1414,8 @@ }, "node_modules/@opentelemetry/exporter-trace-otlp-proto": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.52.1.tgz", + "integrity": "sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -906,6 +1433,8 @@ }, "node_modules/@opentelemetry/exporter-zipkin": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.25.1.tgz", + "integrity": "sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -922,6 +1451,8 @@ }, "node_modules/@opentelemetry/instrumentation": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", + "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "0.52.1", @@ -940,6 +1471,8 @@ }, "node_modules/@opentelemetry/instrumentation-http": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.52.1.tgz", + "integrity": "sha512-dG/aevWhaP+7OLv4BQQSEKMJv8GyeOp3Wxl31NHqE8xo9/fYMfEljiZphUHIfyg4gnZ9swMyWjfOQs5GUQe54Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -954,8 +1487,34 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@opentelemetry/instrumentation/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", + "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -970,6 +1529,8 @@ }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.52.1.tgz", + "integrity": "sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.7.1", @@ -986,6 +1547,8 @@ }, "node_modules/@opentelemetry/otlp-transformer": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.52.1.tgz", + "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "0.52.1", @@ -1005,6 +1568,8 @@ }, "node_modules/@opentelemetry/propagator-b3": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.25.1.tgz", + "integrity": "sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1" @@ -1018,6 +1583,8 @@ }, "node_modules/@opentelemetry/propagator-jaeger": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.25.1.tgz", + "integrity": "sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1" @@ -1031,6 +1598,8 @@ }, "node_modules/@opentelemetry/resources": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", + "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -1045,6 +1614,8 @@ }, "node_modules/@opentelemetry/sdk-logs": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.52.1.tgz", + "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "0.52.1", @@ -1060,6 +1631,8 @@ }, "node_modules/@opentelemetry/sdk-metrics": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", + "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -1075,6 +1648,8 @@ }, "node_modules/@opentelemetry/sdk-node": { "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.52.1.tgz", + "integrity": "sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api-logs": "0.52.1", @@ -1100,6 +1675,8 @@ }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", + "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "1.25.1", @@ -1115,6 +1692,8 @@ }, "node_modules/@opentelemetry/sdk-trace-node": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.25.1.tgz", + "integrity": "sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/context-async-hooks": "1.25.1", @@ -1131,8 +1710,22 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", + "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1140,6 +1733,8 @@ }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "license": "MIT", "optional": true, "engines": { @@ -1148,6 +1743,8 @@ }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "license": "MIT", "engines": { "node": ">=12.22.0" @@ -1155,6 +1752,8 @@ }, "node_modules/@pnpm/network.ca-file": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "license": "MIT", "dependencies": { "graceful-fs": "4.2.10" @@ -1165,10 +1764,14 @@ }, "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "license": "ISC" }, "node_modules/@pnpm/npm-conf": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", + "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", @@ -1181,22 +1784,32 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1", @@ -1205,22 +1818,32 @@ }, "node_modules/@protobufjs/float": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, "node_modules/@qwen-code/qwen-code": { @@ -1235,8 +1858,38 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", + "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz", + "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", "cpu": [ "arm64" ], @@ -1247,13 +1900,255 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz", + "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz", + "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz", + "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz", + "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz", + "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz", + "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz", + "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz", + "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz", + "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz", + "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, "license": "MIT" }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", "license": "MIT", "dependencies": { "domhandler": "^5.0.3", @@ -1264,104 +2159,24 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.38", + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", "dev": true, "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@types/body-parser": { "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", "dependencies": { @@ -1371,11 +2186,15 @@ }, "node_modules/@types/braces": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", "dev": true, "license": "MIT", "dependencies": { @@ -1384,15 +2203,21 @@ }, "node_modules/@types/command-exists": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/command-exists/-/command-exists-1.2.3.tgz", + "integrity": "sha512-PpbaE2XWLaWYboXD6k70TcXO/OdOyyRFq5TVpmlUELNxdkkmXU9fkImNosmXU1DtsNrqdUgWd/nJQYXgwmtdXQ==", "dev": true, "license": "MIT" }, "node_modules/@types/configstore": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.2.tgz", + "integrity": "sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ==", "license": "MIT" }, "node_modules/@types/connect": { "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", "dependencies": { @@ -1401,6 +2226,8 @@ }, "node_modules/@types/cors": { "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "dev": true, "license": "MIT", "dependencies": { @@ -1409,16 +2236,22 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/diff": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", "dev": true, "license": "MIT" }, "node_modules/@types/dotenv": { "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", "dev": true, "license": "MIT", "dependencies": { @@ -1427,11 +2260,15 @@ }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/express": { "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", "dependencies": { @@ -1442,6 +2279,8 @@ }, "node_modules/@types/express-serve-static-core": { "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1453,6 +2292,8 @@ }, "node_modules/@types/gradient-string": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", "license": "MIT", "dependencies": { "@types/tinycolor2": "*" @@ -1460,6 +2301,8 @@ }, "node_modules/@types/hast": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -1467,30 +2310,42 @@ }, "node_modules/@types/html-to-text": { "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.4.tgz", + "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", "license": "MIT" }, "node_modules/@types/http-errors": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true, "license": "MIT" }, "node_modules/@types/marked": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", "dev": true, "license": "MIT" }, "node_modules/@types/micromatch": { "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", "dev": true, "license": "MIT", "dependencies": { @@ -1499,21 +2354,29 @@ }, "node_modules/@types/mime": { "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true, "license": "MIT" }, "node_modules/@types/mime-types": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", "dev": true, "license": "MIT" }, "node_modules/@types/minimatch": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true, "license": "MIT" }, "node_modules/@types/mock-fs": { "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", + "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", "dev": true, "license": "MIT", "dependencies": { @@ -1521,40 +2384,52 @@ } }, "node_modules/@types/node": { - "version": "24.1.0", + "version": "20.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", + "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, "node_modules/@types/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-dLqxmi5VJRC9XTvc/oaTtk+bDb4RRqxLZPZ3jIpYBHEnDXX8lu02w2yWI6NsPPsELuVK298Z2iR8jgoWKRdUVQ==", "dev": true, "license": "MIT" }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", "dev": true, "license": "MIT" }, "node_modules/@types/qs": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.9", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1562,7 +2437,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.7", + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1571,11 +2448,15 @@ }, "node_modules/@types/semver": { "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "dev": true, "license": "MIT", "dependencies": { @@ -1585,6 +2466,8 @@ }, "node_modules/@types/serve-static": { "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", "dev": true, "license": "MIT", "dependencies": { @@ -1595,23 +2478,33 @@ }, "node_modules/@types/shell-quote": { "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", + "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", "dev": true, "license": "MIT" }, "node_modules/@types/shimmer": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", "license": "MIT" }, "node_modules/@types/tinycolor2": { "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, "node_modules/@types/update-notifier": { "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@types/update-notifier/-/update-notifier-6.0.8.tgz", + "integrity": "sha512-IlDFnfSVfYQD+cKIg63DEXn3RFmd7W1iYtKQsJodcHK9R1yr8aKbKaPKfBxzPpcHCq2DU8zUq4PIPmy19Thjfg==", "license": "MIT", "dependencies": { "@types/configstore": "*", @@ -1620,16 +2513,22 @@ }, "node_modules/@types/uuid": { "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "dev": true, "license": "MIT" }, "node_modules/@types/vscode": { "version": "1.102.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz", + "integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==", "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { @@ -1638,6 +2537,8 @@ }, "node_modules/@types/yargs": { "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "license": "MIT", "dependencies": { @@ -1646,19 +2547,23 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1672,20 +2577,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.35.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4" }, "engines": { @@ -1701,12 +2618,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", "debug": "^4.3.4" }, "engines": { @@ -1721,12 +2640,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1737,7 +2658,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "dev": true, "license": "MIT", "engines": { @@ -1752,13 +2675,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1775,7 +2699,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "dev": true, "license": "MIT", "engines": { @@ -1787,14 +2713,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1813,15 +2741,56 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1836,11 +2805,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.35.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1851,19 +2822,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1896,6 +2858,8 @@ }, "node_modules/@vitest/expect": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { @@ -1911,6 +2875,8 @@ }, "node_modules/@vitest/mocker": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1936,6 +2902,8 @@ }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -1947,6 +2915,8 @@ }, "node_modules/@vitest/runner": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1960,6 +2930,8 @@ }, "node_modules/@vitest/snapshot": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1973,6 +2945,8 @@ }, "node_modules/@vitest/spy": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { @@ -1984,6 +2958,8 @@ }, "node_modules/@vitest/utils": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { @@ -1997,6 +2973,8 @@ }, "node_modules/accepts": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -2008,6 +2986,8 @@ }, "node_modules/acorn": { "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2018,6 +2998,8 @@ }, "node_modules/acorn-import-attributes": { "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -2025,6 +3007,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2032,7 +3016,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", "engines": { "node": ">= 14" @@ -2040,6 +3026,8 @@ }, "node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -2054,6 +3042,8 @@ }, "node_modules/ansi-align": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -2061,6 +3051,8 @@ }, "node_modules/ansi-align/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -2068,17 +3060,14 @@ }, "node_modules/ansi-align/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-align/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2091,6 +3080,8 @@ }, "node_modules/ansi-align/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2101,6 +3092,8 @@ }, "node_modules/ansi-escapes": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -2114,6 +3107,8 @@ }, "node_modules/ansi-regex": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { "node": ">=12" @@ -2123,10 +3118,15 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2134,20 +3134,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -2163,6 +3158,8 @@ }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2184,6 +3181,8 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2203,6 +3202,8 @@ }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2223,6 +3224,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -2240,6 +3243,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2257,6 +3262,8 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -2272,6 +3279,8 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2292,6 +3301,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -2300,6 +3311,8 @@ }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", + "integrity": "sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==", "dev": true, "license": "MIT", "dependencies": { @@ -2308,13 +3321,10 @@ "js-tokens": "^9.0.1" } }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/async-function": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -2323,6 +3333,8 @@ }, "node_modules/atomically": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", "dependencies": { "stubborn-fs": "^1.2.5", "when-exit": "^2.1.1" @@ -2330,6 +3342,8 @@ }, "node_modules/auto-bind": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -2340,6 +3354,8 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2354,10 +3370,14 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -2375,7 +3395,9 @@ "license": "MIT" }, "node_modules/bignumber.js": { - "version": "9.3.1", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", "license": "MIT", "engines": { "node": "*" @@ -2383,6 +3405,8 @@ }, "node_modules/body-parser": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -2401,6 +3425,8 @@ }, "node_modules/boxen": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -2419,34 +3445,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boxen/node_modules/emoji-regex": { - "version": "9.2.2", - "license": "MIT" - }, - "node_modules/boxen/node_modules/string-width": { - "version": "5.1.2", + "node_modules/boxen/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, "engines": { - "node": ">=12" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2457,10 +3482,14 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, "node_modules/bundle-name": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -2474,6 +3503,8 @@ }, "node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2481,6 +3512,8 @@ }, "node_modules/cac": { "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { @@ -2489,6 +3522,8 @@ }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,6 +3541,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2517,6 +3554,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2531,6 +3570,8 @@ }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -2539,6 +3580,8 @@ }, "node_modules/camelcase": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", "license": "MIT", "engines": { "node": ">=14.16" @@ -2549,6 +3592,8 @@ }, "node_modules/cfonts": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/cfonts/-/cfonts-3.3.0.tgz", + "integrity": "sha512-RlVxeEw2FXWI5Bs9LD0/Ef3bsQIc9m6lK/DINN20HIW0Y0YHUO2jjy88cot9YKZITiRTCdWzTfLmTyx47HeSLA==", "license": "GPL-3.0-or-later", "dependencies": { "supports-color": "^8", @@ -2562,7 +3607,9 @@ } }, "node_modules/chai": { - "version": "5.2.1", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -2573,25 +3620,47 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/chalk": { - "version": "5.4.1", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chardet": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "license": "MIT" }, "node_modules/check-error": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", "engines": { @@ -2600,10 +3669,14 @@ }, "node_modules/cjs-module-lexer": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, "node_modules/cli-boxes": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { "node": ">=10" @@ -2614,6 +3687,8 @@ }, "node_modules/cli-cursor": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", "license": "MIT", "dependencies": { "restore-cursor": "^4.0.0" @@ -2627,6 +3702,8 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", "engines": { "node": ">=6" @@ -2637,6 +3714,8 @@ }, "node_modules/cli-truncate": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", @@ -2649,8 +3728,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate/node_modules/slice-ansi": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", @@ -2663,8 +3774,27 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -2677,37 +3807,23 @@ }, "node_modules/cliui/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2720,6 +3836,8 @@ }, "node_modules/cliui/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2730,6 +3848,8 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -2745,6 +3865,8 @@ }, "node_modules/code-excerpt": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", "dependencies": { "convert-to-spaces": "^2.0.1" @@ -2755,6 +3877,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2765,19 +3889,27 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/command-exists": { "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/concurrently": { "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2800,48 +3932,10 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/config-chain": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -2850,10 +3944,14 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/configstore": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.0.0.tgz", + "integrity": "sha512-yk7/5PN5im4qwz0WFZW3PXnzHgPu9mX29Y8uZ3aefe2lBPC1FYttWZRcaW9fKkT0pBCJyuQ2HfbmPVaODi9jcQ==", "license": "BSD-2-Clause", "dependencies": { "atomically": "^2.0.3", @@ -2870,6 +3968,8 @@ }, "node_modules/content-disposition": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -2880,6 +3980,8 @@ }, "node_modules/content-type": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2887,6 +3989,8 @@ }, "node_modules/convert-to-spaces": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -2894,6 +3998,8 @@ }, "node_modules/cookie": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2901,6 +4007,8 @@ }, "node_modules/cookie-signature": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { "node": ">=6.6.0" @@ -2908,6 +4016,8 @@ }, "node_modules/cors": { "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -2919,6 +4029,8 @@ }, "node_modules/cross-env": { "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2936,6 +4048,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2948,6 +4062,8 @@ }, "node_modules/cssstyle": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { @@ -2960,11 +4076,15 @@ }, "node_modules/csstype": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { @@ -2977,6 +4097,8 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2993,6 +4115,8 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3009,6 +4133,8 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3025,6 +4151,8 @@ }, "node_modules/debug": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3039,12 +4167,16 @@ } }, "node_modules/decimal.js": { - "version": "10.6.0", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "dev": true, "license": "MIT" }, "node_modules/deep-eql": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", "engines": { @@ -3053,6 +4185,8 @@ }, "node_modules/deep-extend": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -3060,11 +4194,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3072,6 +4210,8 @@ }, "node_modules/default-browser": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -3086,6 +4226,8 @@ }, "node_modules/default-browser-id": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", "license": "MIT", "engines": { "node": ">=18" @@ -3096,6 +4238,8 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3112,6 +4256,8 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "license": "MIT", "engines": { "node": ">=12" @@ -3122,6 +4268,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -3138,6 +4286,8 @@ }, "node_modules/define-property": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", "license": "MIT", "dependencies": { "is-descriptor": "^1.0.0" @@ -3148,6 +4298,8 @@ }, "node_modules/depd": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3155,6 +4307,8 @@ }, "node_modules/dequal": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" @@ -3162,6 +4316,8 @@ }, "node_modules/devlop": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -3173,6 +4329,8 @@ }, "node_modules/diff": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3180,6 +4338,8 @@ }, "node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3191,12 +4351,16 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -3209,6 +4373,8 @@ }, "node_modules/domelementtype": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -3219,6 +4385,8 @@ }, "node_modules/domhandler": { "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -3232,6 +4400,8 @@ }, "node_modules/domutils": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -3244,6 +4414,8 @@ }, "node_modules/dot-prop": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { "type-fest": "^4.18.2" @@ -3257,6 +4429,8 @@ }, "node_modules/dot-prop/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -3266,7 +4440,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.1", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", + "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3277,6 +4453,8 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3289,10 +4467,14 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -3300,14 +4482,20 @@ }, "node_modules/ee-first": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/emoji-regex": { - "version": "10.4.0", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3315,6 +4503,8 @@ }, "node_modules/entities": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3325,6 +4515,8 @@ }, "node_modules/environment": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "license": "MIT", "engines": { "node": ">=18" @@ -3335,6 +4527,8 @@ }, "node_modules/error-ex": { "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, "license": "MIT", "dependencies": { @@ -3343,6 +4537,8 @@ }, "node_modules/es-abstract": { "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "license": "MIT", "dependencies": { @@ -3410,6 +4606,8 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3417,6 +4615,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3424,6 +4624,8 @@ }, "node_modules/es-iterator-helpers": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", "dev": true, "license": "MIT", "dependencies": { @@ -3450,11 +4652,15 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3465,6 +4671,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -3479,6 +4687,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3490,6 +4700,8 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -3505,7 +4717,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.8", + "version": "1.39.5", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz", + "integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==", "license": "MIT", "workspaces": [ "docs", @@ -3513,7 +4727,9 @@ ] }, "node_modules/esbuild": { - "version": "0.25.8", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3524,36 +4740,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" } }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -3561,6 +4779,8 @@ }, "node_modules/escape-goat": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", "engines": { "node": ">=12" @@ -3571,10 +4791,14 @@ }, "node_modules/escape-html": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -3585,18 +4809,20 @@ } }, "node_modules/eslint": { - "version": "9.32.0", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3644,7 +4870,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.8", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", "bin": { @@ -3659,6 +4887,8 @@ }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3669,6 +4899,8 @@ }, "node_modules/eslint-import-resolver-node/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3677,6 +4909,8 @@ }, "node_modules/eslint-module-utils": { "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -3693,6 +4927,8 @@ }, "node_modules/eslint-module-utils/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3701,6 +4937,8 @@ }, "node_modules/eslint-plugin-import": { "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { @@ -3731,44 +4969,20 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-license-header": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz", + "integrity": "sha512-khTCz6G3JdoQfwrtY4XKl98KW4PpnWUKuFx8v+twIRhJADEyYglMDC0td8It75C1MZ88gcvMusWuUlJsos7gYg==", "dev": true, "license": "MIT", "dependencies": { @@ -3777,6 +4991,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -3808,6 +5024,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -3817,28 +5035,10 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", "dependencies": { @@ -3853,16 +5053,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-scope": { "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3877,56 +5071,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3936,38 +5083,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3982,19 +5101,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esquery": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4006,6 +5116,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4017,6 +5129,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4025,6 +5139,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -4033,6 +5149,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4041,6 +5159,8 @@ }, "node_modules/etag": { "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4048,6 +5168,8 @@ }, "node_modules/eventsource": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -4058,13 +5180,17 @@ }, "node_modules/eventsource-parser": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", "license": "MIT", "engines": { "node": ">=20.0.0" } }, "node_modules/expect-type": { - "version": "1.2.2", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4073,6 +5199,8 @@ }, "node_modules/express": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -4113,6 +5241,8 @@ }, "node_modules/express-rate-limit": { "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", "engines": { "node": ">= 16" @@ -4126,14 +5256,20 @@ }, "node_modules/extend": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -4149,6 +5285,8 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -4160,15 +5298,21 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "funding": [ { "type": "github", @@ -4183,6 +5327,8 @@ }, "node_modules/fastq": { "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -4191,6 +5337,8 @@ }, "node_modules/figures": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" @@ -4204,6 +5352,8 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4215,6 +5365,8 @@ }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4225,6 +5377,8 @@ }, "node_modules/finalhandler": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -4240,6 +5394,8 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -4255,6 +5411,8 @@ }, "node_modules/find-up-simple": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "license": "MIT", "engines": { "node": ">=18" @@ -4265,6 +5423,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -4277,11 +5437,15 @@ }, "node_modules/flatted": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4296,6 +5460,8 @@ }, "node_modules/foreground-child": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4310,6 +5476,8 @@ }, "node_modules/forwarded": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4317,6 +5485,8 @@ }, "node_modules/fresh": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4324,7 +5494,10 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -4336,6 +5509,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4343,6 +5518,8 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4362,14 +5539,32 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -4382,20 +5577,52 @@ "node": ">=14" } }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "license": "Apache-2.0", + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">=14" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gcp-metadata/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/gcp-metadata/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/gcp-metadata/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/get-caller-file": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4403,6 +5630,8 @@ }, "node_modules/get-east-asian-width": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "license": "MIT", "engines": { "node": ">=18" @@ -4413,6 +5642,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4435,6 +5666,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4446,6 +5679,8 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -4462,6 +5697,8 @@ }, "node_modules/glob": { "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4480,6 +5717,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -4489,8 +5728,34 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "license": "MIT", "dependencies": { "ini": "4.1.1" @@ -4504,6 +5769,8 @@ }, "node_modules/globals": { "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { @@ -4515,6 +5782,8 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4530,6 +5799,8 @@ }, "node_modules/google-auth-library": { "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -4543,8 +5814,68 @@ "node": ">=14" } }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/google-auth-library/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/google-auth-library/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/google-auth-library/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -4552,6 +5883,8 @@ }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4562,10 +5895,14 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/gradient-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-2.0.2.tgz", + "integrity": "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -4575,50 +5912,17 @@ "node": ">=10" } }, - "node_modules/gradient-string/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/gradient-string/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/gradient-string/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/graphemer": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/gtoken": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", "dependencies": { "gaxios": "^6.0.0", @@ -4628,8 +5932,68 @@ "node": ">=14.0.0" } }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gtoken/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/gtoken/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/gtoken/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -4641,6 +6005,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -4648,6 +6014,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -4659,6 +6027,8 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4673,6 +6043,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4683,6 +6055,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -4697,6 +6071,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4707,18 +6083,29 @@ }, "node_modules/highlight.js": { "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" } }, "node_modules/hosted-git-info": { - "version": "2.8.9", - "dev": true, - "license": "ISC" + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4730,11 +6117,15 @@ }, "node_modules/html-escaper": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/html-to-text": { "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", "license": "MIT", "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", @@ -4749,6 +6140,8 @@ }, "node_modules/htmlparser2": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -4766,6 +6159,8 @@ }, "node_modules/http-errors": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "license": "MIT", "dependencies": { "depd": "2.0.0", @@ -4780,6 +6175,8 @@ }, "node_modules/http-errors/node_modules/statuses": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4787,6 +6184,8 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { @@ -4799,6 +6198,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -4810,6 +6211,8 @@ }, "node_modules/hyperdyperid": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", "dev": true, "license": "MIT", "engines": { @@ -4818,6 +6221,8 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4827,7 +6232,10 @@ } }, "node_modules/ignore": { - "version": "7.0.5", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -4835,6 +6243,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4850,6 +6260,8 @@ }, "node_modules/import-in-the-middle": { "version": "1.14.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz", + "integrity": "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==", "license": "Apache-2.0", "dependencies": { "acorn": "^8.14.0", @@ -4860,6 +6272,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -4868,6 +6282,8 @@ }, "node_modules/indent-string": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", "engines": { "node": ">=12" @@ -4878,6 +6294,8 @@ }, "node_modules/index-to-position": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.1.0.tgz", + "integrity": "sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==", "license": "MIT", "engines": { "node": ">=18" @@ -4888,17 +6306,23 @@ }, "node_modules/inherits": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/ink": { - "version": "6.1.0", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.0.1.tgz", + "integrity": "sha512-vhhFrCodTHZAPPSdMYzLEbeI0Ug37R9j6yA0kLKok9kSK53lQtj/RJhEQJUjq6OwT4N33nxqSRd/7yXhEhVPIw==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", @@ -4945,6 +6369,8 @@ }, "node_modules/ink-big-text": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ink-big-text/-/ink-big-text-2.0.0.tgz", + "integrity": "sha512-Juzqv+rIOLGuhMJiE50VtS6dg6olWfzFdL7wsU/EARSL5Eaa5JNXMogMBm9AkjgzO2Y3UwWCOh87jbhSn8aNdw==", "license": "MIT", "dependencies": { "cfonts": "^3.1.1", @@ -4963,6 +6389,8 @@ }, "node_modules/ink-gradient": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ink-gradient/-/ink-gradient-3.0.0.tgz", + "integrity": "sha512-OVyPBovBxE1tFcBhSamb+P1puqDP6pG3xFe2W9NiLgwUZd9RbcjBeR7twLbliUT9navrUstEf1ZcPKKvx71BsQ==", "license": "MIT", "dependencies": { "@types/gradient-string": "^1.1.2", @@ -4982,6 +6410,8 @@ }, "node_modules/ink-link": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ink-link/-/ink-link-4.1.0.tgz", + "integrity": "sha512-3nNyJXum0FJIKAXBK8qat2jEOM41nJ1J60NRivwgK9Xh92R5UMN/k4vbz0A9xFzhJVrlf4BQEmmxMgXkCE1Jeg==", "license": "MIT", "dependencies": { "prop-types": "^15.8.1", @@ -4999,6 +6429,8 @@ }, "node_modules/ink-select-input": { "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", "license": "MIT", "dependencies": { "figures": "^6.1.0", @@ -5014,6 +6446,8 @@ }, "node_modules/ink-spinner": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", "license": "MIT", "dependencies": { "cli-spinners": "^2.7.0" @@ -5028,6 +6462,8 @@ }, "node_modules/ink-testing-library": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", "dev": true, "license": "MIT", "engines": { @@ -5042,12 +6478,63 @@ } } }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, "node_modules/ink/node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/ink/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ink/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -5058,6 +6545,8 @@ }, "node_modules/ink/node_modules/widest-line": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -5071,6 +6560,8 @@ }, "node_modules/ink/node_modules/wrap-ansi": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -5086,6 +6577,8 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -5099,6 +6592,8 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -5106,6 +6601,8 @@ }, "node_modules/is-accessor-descriptor": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", "license": "MIT", "dependencies": { "hasown": "^2.0.0" @@ -5116,6 +6613,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -5132,11 +6631,15 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5155,6 +6658,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5169,6 +6674,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -5184,10 +6691,14 @@ }, "node_modules/is-buffer": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "license": "MIT" }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -5199,6 +6710,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -5212,6 +6725,8 @@ }, "node_modules/is-data-descriptor": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", "license": "MIT", "dependencies": { "hasown": "^2.0.0" @@ -5222,6 +6737,8 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -5238,6 +6755,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -5253,6 +6772,8 @@ }, "node_modules/is-descriptor": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", "license": "MIT", "dependencies": { "is-accessor-descriptor": "^1.0.1", @@ -5264,6 +6785,8 @@ }, "node_modules/is-docker": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "license": "MIT", "bin": { "is-docker": "cli.js" @@ -5277,6 +6800,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -5285,6 +6810,8 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -5298,17 +6825,18 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/is-generator-function": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5326,6 +6854,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -5337,6 +6867,8 @@ }, "node_modules/is-in-ci": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -5350,6 +6882,8 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -5366,6 +6900,8 @@ }, "node_modules/is-installed-globally": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "license": "MIT", "dependencies": { "global-directory": "^4.0.1", @@ -5380,6 +6916,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -5391,6 +6929,8 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -5402,6 +6942,8 @@ }, "node_modules/is-npm": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", + "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -5412,6 +6954,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5419,6 +6963,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -5434,6 +6980,8 @@ }, "node_modules/is-path-inside": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "license": "MIT", "engines": { "node": ">=12" @@ -5444,15 +6992,21 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-promise": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -5470,6 +7024,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -5481,6 +7037,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -5495,6 +7053,8 @@ }, "node_modules/is-stream": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" @@ -5505,6 +7065,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -5520,6 +7082,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5536,6 +7100,8 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5550,6 +7116,8 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -5560,6 +7128,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -5571,6 +7141,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -5585,6 +7157,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5600,6 +7174,8 @@ }, "node_modules/is-wsl": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -5613,15 +7189,21 @@ }, "node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5630,6 +7212,8 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5643,6 +7227,8 @@ }, "node_modules/istanbul-lib-report/node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -5654,6 +7240,8 @@ }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5667,6 +7255,8 @@ }, "node_modules/istanbul-reports": { "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5679,6 +7269,8 @@ }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5695,6 +7287,8 @@ }, "node_modules/jackspeak": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -5707,11 +7301,16 @@ } }, "node_modules/js-tokens": { - "version": "4.0.0", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -5723,6 +7322,8 @@ }, "node_modules/jsdom": { "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { @@ -5761,6 +7362,8 @@ }, "node_modules/json": { "version": "11.0.0", + "resolved": "https://registry.npmjs.org/json/-/json-11.0.0.tgz", + "integrity": "sha512-N/ITv3Yw9Za8cGxuQqSqrq6RHnlaHWZkAFavcfpH/R52522c26EbihMxnY7A1chxfXJ4d+cEFIsyTgfi9GihrA==", "dev": true, "bin": { "json": "lib/json.js" @@ -5771,6 +7374,8 @@ }, "node_modules/json-bigint": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -5778,25 +7383,35 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-better-errors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", "dependencies": { @@ -5808,6 +7423,8 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5822,6 +7439,8 @@ }, "node_modules/jwa": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -5831,6 +7450,8 @@ }, "node_modules/jws": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "license": "MIT", "dependencies": { "jwa": "^2.0.0", @@ -5839,6 +7460,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5847,6 +7470,8 @@ }, "node_modules/kind-of": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", "license": "MIT", "dependencies": { "is-buffer": "^1.1.5" @@ -5856,7 +7481,9 @@ } }, "node_modules/ky": { - "version": "1.8.2", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", + "integrity": "sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw==", "license": "MIT", "engines": { "node": ">=18" @@ -5867,6 +7494,8 @@ }, "node_modules/latest-version": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "license": "MIT", "dependencies": { "package-json": "^10.0.0" @@ -5880,6 +7509,8 @@ }, "node_modules/leac": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" @@ -5887,6 +7518,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5899,6 +7532,8 @@ }, "node_modules/load-json-file": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5911,8 +7546,24 @@ "node": ">=4" } }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -5927,23 +7578,33 @@ }, "node_modules/lodash": { "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true, "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, "node_modules/long": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5952,13 +7613,23 @@ "loose-envify": "cli.js" } }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/loupe": { - "version": "3.2.0", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", "dev": true, "license": "MIT" }, "node_modules/lowlight": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -5972,10 +7643,14 @@ }, "node_modules/lru-cache": { "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, "node_modules/lz-string": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "peer": true, @@ -5985,6 +7660,8 @@ }, "node_modules/magic-string": { "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { @@ -5993,6 +7670,8 @@ }, "node_modules/magicast": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6003,6 +7682,8 @@ }, "node_modules/make-dir": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -6017,6 +7698,8 @@ }, "node_modules/make-dir/node_modules/semver": { "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -6028,6 +7711,8 @@ }, "node_modules/marked": { "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -6038,6 +7723,8 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6045,13 +7732,17 @@ }, "node_modules/media-typer": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/memfs": { - "version": "4.28.1", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.2.tgz", + "integrity": "sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6070,6 +7761,8 @@ }, "node_modules/memorystream": { "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", "dev": true, "engines": { "node": ">= 0.10.0" @@ -6077,6 +7770,8 @@ }, "node_modules/merge-descriptors": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { "node": ">=18" @@ -6087,6 +7782,8 @@ }, "node_modules/merge2": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -6095,6 +7792,8 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -6106,6 +7805,8 @@ }, "node_modules/mime-db": { "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6113,6 +7814,8 @@ }, "node_modules/mime-types": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -6123,26 +7826,30 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/minimatch": { - "version": "9.0.5", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6150,6 +7857,8 @@ }, "node_modules/minipass": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -6157,6 +7866,8 @@ }, "node_modules/mock-fs": { "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", "dev": true, "license": "MIT", "engines": { @@ -6165,14 +7876,20 @@ }, "node_modules/module-details-from-path": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -6190,11 +7907,15 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -6202,64 +7923,41 @@ }, "node_modules/nice-try": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/normalize-package-data": { - "version": "2.5.0", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/npm-run-all": { "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6284,6 +7982,8 @@ }, "node_modules/npm-run-all/node_modules/ansi-styles": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", "dependencies": { @@ -6293,17 +7993,10 @@ "node": ">=4" } }, - "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "1.1.12", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6317,6 +8010,8 @@ }, "node_modules/npm-run-all/node_modules/color-convert": { "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", "dependencies": { @@ -6325,11 +8020,15 @@ }, "node_modules/npm-run-all/node_modules/color-name": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, "license": "MIT" }, "node_modules/npm-run-all/node_modules/cross-spawn": { "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6345,6 +8044,8 @@ }, "node_modules/npm-run-all/node_modules/escape-string-regexp": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", "engines": { @@ -6353,33 +8054,63 @@ }, "node_modules/npm-run-all/node_modules/has-flag": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/npm-run-all/node_modules/minimatch": { - "version": "3.1.2", + "node_modules/npm-run-all/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true, - "license": "ISC", + "license": "ISC" + }, + "node_modules/npm-run-all/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" } }, "node_modules/npm-run-all/node_modules/path-key": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", "dev": true, "license": "MIT", "engines": { "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/npm-run-all/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "license": "ISC", "bin": { @@ -6388,6 +8119,8 @@ }, "node_modules/npm-run-all/node_modules/shebang-command": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", "dev": true, "license": "MIT", "dependencies": { @@ -6399,6 +8132,8 @@ }, "node_modules/npm-run-all/node_modules/shebang-regex": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", "dev": true, "license": "MIT", "engines": { @@ -6407,6 +8142,8 @@ }, "node_modules/npm-run-all/node_modules/supports-color": { "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { @@ -6418,6 +8155,8 @@ }, "node_modules/npm-run-all/node_modules/which": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6428,12 +8167,16 @@ } }, "node_modules/nwsapi": { - "version": "2.2.21", + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6441,6 +8184,8 @@ }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6451,6 +8196,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -6459,6 +8206,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -6478,6 +8227,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -6492,6 +8243,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6509,6 +8262,8 @@ }, "node_modules/object.groupby": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6522,6 +8277,8 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -6539,6 +8296,8 @@ }, "node_modules/on-finished": { "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -6549,6 +8308,8 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" @@ -6556,6 +8317,8 @@ }, "node_modules/onetime": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -6568,13 +8331,15 @@ } }, "node_modules/open": { - "version": "10.2.0", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "is-wsl": "^3.1.0" }, "engines": { "node": ">=18" @@ -6584,7 +8349,9 @@ } }, "node_modules/openai": { - "version": "5.11.0", + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", + "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -6604,6 +8371,8 @@ }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -6620,6 +8389,8 @@ }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6636,6 +8407,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6650,6 +8423,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -6664,6 +8439,8 @@ }, "node_modules/package-json": { "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "license": "MIT", "dependencies": { "ky": "^1.2.0", @@ -6680,10 +8457,26 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-json/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -6694,19 +8487,38 @@ } }, "node_modules/parse-json": { - "version": "4.0.0", - "dev": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse5": { "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -6718,6 +8530,8 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -6729,6 +8543,8 @@ }, "node_modules/parseley": { "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", "license": "MIT", "dependencies": { "leac": "^0.6.0", @@ -6740,6 +8556,8 @@ }, "node_modules/parseurl": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -6747,6 +8565,8 @@ }, "node_modules/patch-console": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -6754,6 +8574,8 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -6762,6 +8584,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -6769,10 +8593,14 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -6787,6 +8615,8 @@ }, "node_modules/path-to-regexp": { "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "license": "MIT", "engines": { "node": ">=16" @@ -6794,6 +8624,8 @@ }, "node_modules/path-type": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", "dev": true, "license": "MIT", "dependencies": { @@ -6805,11 +8637,15 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -6818,6 +8654,8 @@ }, "node_modules/peberminta": { "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" @@ -6825,10 +8663,14 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -6839,6 +8681,8 @@ }, "node_modules/pidtree": { "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", "dev": true, "license": "MIT", "bin": { @@ -6850,6 +8694,8 @@ }, "node_modules/pify": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", "dev": true, "license": "MIT", "engines": { @@ -6858,6 +8704,8 @@ }, "node_modules/pkce-challenge": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -6865,6 +8713,8 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -6873,6 +8723,8 @@ }, "node_modules/postcss": { "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -6900,6 +8752,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -6907,7 +8761,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.1.tgz", + "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", "bin": { @@ -6921,11 +8777,13 @@ } }, "node_modules/pretty-format": { - "version": "30.0.5", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" }, @@ -6935,6 +8793,8 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -6944,8 +8804,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6953,16 +8822,16 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, "node_modules/proto-list": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, "node_modules/protobufjs": { "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6985,6 +8854,8 @@ }, "node_modules/proxy-addr": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -6996,6 +8867,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" @@ -7003,6 +8876,8 @@ }, "node_modules/pupa": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", + "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -7016,12 +8891,16 @@ }, "node_modules/qrcode-terminal": { "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" } }, "node_modules/qs": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -7035,6 +8914,8 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -7058,6 +8939,8 @@ }, "node_modules/range-parser": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7065,6 +8948,8 @@ }, "node_modules/raw-body": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "license": "MIT", "dependencies": { "bytes": "3.1.2", @@ -7078,6 +8963,8 @@ }, "node_modules/rc": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -7091,17 +8978,23 @@ }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react": { - "version": "19.1.1", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7109,6 +9002,8 @@ }, "node_modules/react-devtools-core": { "version": "4.28.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.28.5.tgz", + "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -7118,6 +9013,8 @@ }, "node_modules/react-devtools-core/node_modules/ws": { "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "devOptional": true, "license": "MIT", "engines": { @@ -7137,28 +9034,35 @@ } }, "node_modules/react-dom": { - "version": "19.1.1", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.1.0" } }, "node_modules/react-dom/node_modules/scheduler": { "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "dev": true, "license": "MIT" }, "node_modules/react-is": { - "version": "18.3.1", - "dev": true, + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/react-reconciler": { "version": "0.32.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz", + "integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==", "license": "MIT", "dependencies": { "scheduler": "^0.26.0" @@ -7172,10 +9076,14 @@ }, "node_modules/react-reconciler/node_modules/scheduler": { "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, "node_modules/read-package-up": { "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", "license": "MIT", "dependencies": { "find-up-simple": "^1.0.0", @@ -7189,45 +9097,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/hosted-git-info": { - "version": "7.0.2", - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-up/node_modules/normalize-package-data": { - "version": "6.0.2", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-up/node_modules/parse-json": { - "version": "8.3.0", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/read-pkg": { + "node_modules/read-pkg": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.3", @@ -7243,8 +9128,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { + "node_modules/read-pkg/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -7253,21 +9140,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -7289,6 +9165,8 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -7308,6 +9186,8 @@ }, "node_modules/registry-auth-token": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.0.tgz", + "integrity": "sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==", "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^2.1.0" @@ -7318,6 +9198,8 @@ }, "node_modules/registry-url": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", "license": "MIT", "dependencies": { "rc": "1.2.8" @@ -7331,6 +9213,8 @@ }, "node_modules/require-directory": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7338,6 +9222,8 @@ }, "node_modules/require-from-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7345,6 +9231,8 @@ }, "node_modules/require-in-the-middle": { "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -7357,6 +9245,8 @@ }, "node_modules/requireindex": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", "dev": true, "license": "MIT", "engines": { @@ -7365,6 +9255,8 @@ }, "node_modules/resolve": { "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -7383,6 +9275,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -7391,6 +9285,8 @@ }, "node_modules/restore-cursor": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -7405,10 +9301,14 @@ }, "node_modules/restore-cursor/node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/reusify": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -7417,7 +9317,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", + "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", "dev": true, "license": "MIT", "dependencies": { @@ -7431,31 +9333,33 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.44.0", + "@rollup/rollup-android-arm64": "4.44.0", + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-freebsd-arm64": "4.44.0", + "@rollup/rollup-freebsd-x64": "4.44.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", + "@rollup/rollup-linux-arm-musleabihf": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-musl": "4.44.0", + "@rollup/rollup-linux-s390x-gnu": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-ia32-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0", "fsevents": "~2.3.2" } }, "node_modules/router": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -7470,11 +9374,15 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, "node_modules/run-applescript": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", "license": "MIT", "engines": { "node": ">=18" @@ -7485,6 +9393,8 @@ }, "node_modules/run-parallel": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -7507,6 +9417,8 @@ }, "node_modules/rxjs": { "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7515,6 +9427,8 @@ }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7533,6 +9447,8 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -7551,6 +9467,8 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -7566,6 +9484,8 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -7582,10 +9502,14 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -7597,6 +9521,8 @@ }, "node_modules/scheduler": { "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -7604,6 +9530,8 @@ }, "node_modules/selderee": { "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", "license": "MIT", "dependencies": { "parseley": "^0.12.0" @@ -7613,17 +9541,19 @@ } }, "node_modules/semver": { - "version": "7.7.2", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -7644,6 +9574,8 @@ }, "node_modules/serve-static": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -7657,6 +9589,8 @@ }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -7673,6 +9607,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7687,6 +9623,8 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -7700,10 +9638,14 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7714,6 +9656,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -7721,6 +9665,8 @@ }, "node_modules/shell-quote": { "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7731,10 +9677,14 @@ }, "node_modules/shimmer": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", "license": "BSD-2-Clause" }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7752,6 +9702,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7766,6 +9718,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7782,6 +9736,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7799,11 +9755,15 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -7814,6 +9774,8 @@ }, "node_modules/simple-git": { "version": "3.28.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", + "integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", @@ -7827,6 +9789,8 @@ }, "node_modules/slice-ansi": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -7839,8 +9803,22 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.0.0" @@ -7854,6 +9832,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7862,6 +9842,8 @@ }, "node_modules/spdx-correct": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -7870,10 +9852,14 @@ }, "node_modules/spdx-exceptions": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -7882,10 +9868,14 @@ }, "node_modules/spdx-license-ids": { "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "license": "CC0-1.0" }, "node_modules/stack-utils": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -7896,6 +9886,8 @@ }, "node_modules/stack-utils/node_modules/escape-string-regexp": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "engines": { "node": ">=8" @@ -7903,11 +9895,15 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/statuses": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -7915,11 +9911,15 @@ }, "node_modules/std-env": { "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7931,15 +9931,17 @@ } }, "node_modules/string-width": { - "version": "7.2.0", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7948,6 +9950,8 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7960,6 +9964,8 @@ }, "node_modules/string-width-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -7967,17 +9973,14 @@ }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7988,6 +9991,8 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { @@ -8014,6 +10019,8 @@ }, "node_modules/string.prototype.padend": { "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8031,6 +10038,8 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -8040,6 +10049,8 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -8060,6 +10071,8 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8077,6 +10090,8 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -8093,6 +10108,8 @@ }, "node_modules/strip-ansi": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -8107,6 +10124,8 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8117,6 +10136,8 @@ }, "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -8124,6 +10145,8 @@ }, "node_modules/strip-bom": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", "engines": { @@ -8132,6 +10155,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "license": "MIT", "engines": { "node": ">=8" @@ -8142,6 +10167,8 @@ }, "node_modules/strip-literal": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", "dev": true, "license": "MIT", "dependencies": { @@ -8151,16 +10178,15 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/stubborn-fs": { - "version": "1.2.5" + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, "node_modules/supports-color": { "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8174,6 +10200,8 @@ }, "node_modules/supports-hyperlinks": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0", @@ -8185,6 +10213,8 @@ }, "node_modules/supports-hyperlinks/node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8195,6 +10225,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -8205,11 +10237,15 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/terminal-link": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-3.0.0.tgz", + "integrity": "sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==", "license": "MIT", "dependencies": { "ansi-escapes": "^5.0.0", @@ -8224,6 +10260,8 @@ }, "node_modules/terminal-link/node_modules/ansi-escapes": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", + "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", "license": "MIT", "dependencies": { "type-fest": "^1.0.2" @@ -8237,6 +10275,8 @@ }, "node_modules/terminal-link/node_modules/type-fest": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -8247,6 +10287,8 @@ }, "node_modules/test-exclude": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "license": "ISC", "dependencies": { @@ -8258,8 +10300,36 @@ "node": ">=18" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thingies": { "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", "dev": true, "license": "Unlicense", "engines": { @@ -8270,25 +10340,35 @@ } }, "node_modules/tiktoken": { - "version": "1.0.21", + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz", + "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinycolor2": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8304,6 +10384,8 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8316,7 +10398,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -8328,6 +10412,8 @@ }, "node_modules/tinygradient": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", "license": "MIT", "dependencies": { "@types/tinycolor2": "^1.4.0", @@ -8336,6 +10422,8 @@ }, "node_modules/tinypool": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -8344,6 +10432,8 @@ }, "node_modules/tinyrainbow": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -8352,6 +10442,8 @@ }, "node_modules/tinyspy": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "dev": true, "license": "MIT", "engines": { @@ -8360,6 +10452,8 @@ }, "node_modules/tldts": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8371,11 +10465,15 @@ }, "node_modules/tldts-core": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8386,6 +10484,8 @@ }, "node_modules/to-rotated": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", "license": "MIT", "engines": { "node": ">=18" @@ -8396,6 +10496,8 @@ }, "node_modules/toidentifier": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { "node": ">=0.6" @@ -8403,6 +10505,8 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8414,6 +10518,8 @@ }, "node_modules/tr46": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { @@ -8425,6 +10531,8 @@ }, "node_modules/tree-dump": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", + "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8440,6 +10548,8 @@ }, "node_modules/tree-kill": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", "bin": { @@ -8448,6 +10558,8 @@ }, "node_modules/ts-api-utils": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { @@ -8459,6 +10571,8 @@ }, "node_modules/tsconfig-paths": { "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "license": "MIT", "dependencies": { @@ -8470,11 +10584,15 @@ }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -8486,6 +10604,8 @@ }, "node_modules/type-fest": { "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=12.20" @@ -8496,6 +10616,8 @@ }, "node_modules/type-is": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -8508,6 +10630,8 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -8521,6 +10645,8 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -8539,6 +10665,8 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8559,6 +10687,8 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -8578,6 +10708,8 @@ }, "node_modules/typescript": { "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8589,14 +10721,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", + "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.35.0", + "@typescript-eslint/parser": "8.35.0", + "@typescript-eslint/utils": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8612,6 +10745,8 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -8628,18 +10763,24 @@ } }, "node_modules/undici": { - "version": "7.13.0", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.8.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unicorn-magic": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "license": "MIT", "engines": { "node": ">=18" @@ -8650,6 +10791,8 @@ }, "node_modules/unpipe": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8657,6 +10800,8 @@ }, "node_modules/update-notifier": { "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", "license": "BSD-2-Clause", "dependencies": { "boxen": "^8.0.1", @@ -8677,8 +10822,22 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, + "node_modules/update-notifier/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/update-notifier/node_modules/boxen": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -8699,6 +10858,8 @@ }, "node_modules/update-notifier/node_modules/camelcase": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", "license": "MIT", "engines": { "node": ">=16" @@ -8707,8 +10868,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/update-notifier/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/update-notifier/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -8719,6 +10929,8 @@ }, "node_modules/update-notifier/node_modules/widest-line": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -8732,6 +10944,8 @@ }, "node_modules/update-notifier/node_modules/wrap-ansi": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -8747,6 +10961,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -8754,6 +10970,8 @@ }, "node_modules/uuid": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -8765,6 +10983,8 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -8773,19 +10993,23 @@ }, "node_modules/vary": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/vite": { - "version": "7.0.6", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", - "picomatch": "^4.0.3", + "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" @@ -8853,6 +11077,8 @@ }, "node_modules/vite-node": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { @@ -8874,6 +11100,8 @@ }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8886,7 +11114,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -8898,6 +11128,8 @@ }, "node_modules/vitest": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { @@ -8968,7 +11200,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { @@ -8980,6 +11214,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -8991,6 +11227,8 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8999,6 +11237,8 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9010,6 +11250,8 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -9018,6 +11260,8 @@ }, "node_modules/whatwg-url": { "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { @@ -9030,10 +11274,14 @@ }, "node_modules/when-exit": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", + "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", "license": "MIT" }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9047,6 +11295,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -9065,6 +11315,8 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9091,6 +11343,8 @@ }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -9108,6 +11362,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dev": true, "license": "MIT", "dependencies": { @@ -9128,6 +11384,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -9143,6 +11401,8 @@ }, "node_modules/widest-line": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", "license": "MIT", "dependencies": { "string-width": "^5.0.1" @@ -9154,27 +11414,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/widest-line/node_modules/emoji-regex": { - "version": "9.2.2", - "license": "MIT" - }, - "node_modules/widest-line/node_modules/string-width": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/window-size": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-1.1.1.tgz", + "integrity": "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA==", "license": "MIT", "dependencies": { "define-property": "^1.0.0", @@ -9189,6 +11432,8 @@ }, "node_modules/window-size/node_modules/is-number": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", "license": "MIT", "dependencies": { "kind-of": "^3.0.2" @@ -9199,6 +11444,8 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -9207,6 +11454,8 @@ }, "node_modules/wrap-ansi": { "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -9223,6 +11472,8 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -9238,37 +11489,23 @@ }, "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9281,6 +11518,8 @@ }, "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9289,31 +11528,28 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/ws": { "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9331,21 +11567,10 @@ } } }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xdg-basedir": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "license": "MIT", "engines": { "node": ">=12" @@ -9356,6 +11581,8 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9364,11 +11591,15 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/y18n": { "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "license": "ISC", "engines": { "node": ">=10" @@ -9376,6 +11607,8 @@ }, "node_modules/yargs": { "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -9392,6 +11625,8 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", "engines": { "node": ">=12" @@ -9399,6 +11634,8 @@ }, "node_modules/yargs/node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -9406,17 +11643,14 @@ }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9429,6 +11663,8 @@ }, "node_modules/yargs/node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9439,6 +11675,8 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -9450,10 +11688,14 @@ }, "node_modules/yoga-layout": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, "node_modules/zod": { "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -9461,6 +11703,8 @@ }, "node_modules/zod-to-json-schema": { "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", "license": "ISC", "peerDependencies": { "zod": "^3.24.1" @@ -9505,7 +11749,7 @@ }, "devDependencies": { "@babel/runtime": "^7.27.6", - "@google/gemini-cli-test-utils": "file:../test-utils", + "@qwen-code/qwen-code-test-utils": "file:../test-utils", "@testing-library/react": "^16.3.0", "@types/command-exists": "^1.2.3", "@types/diff": "^7.0.2", @@ -9527,19 +11771,149 @@ "node": ">=20" } }, - "packages/cli/node_modules/@types/node": { - "version": "20.19.9", + "packages/cli/node_modules/@google/genai": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", + "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "packages/cli/node_modules/@testing-library/dom": { + "version": "10.4.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "packages/cli/node_modules/@testing-library/react": { + "version": "16.3.0", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "packages/cli/node_modules/undici-types": { - "version": "6.21.0", + "packages/cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "packages/cli/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/cli/node_modules/aria-query": { + "version": "5.3.0", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "packages/cli/node_modules/emoji-regex": { + "version": "10.4.0", "license": "MIT" }, + "packages/cli/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "packages/cli/node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/core": { "name": "@qwen-code/qwen-code-core", "version": "0.0.5", @@ -9576,7 +11950,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "@google/gemini-cli-test-utils": "file:../test-utils", + "@qwen-code/qwen-code-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/micromatch": "^4.0.8", @@ -9590,8 +11964,31 @@ "node": ">=20" } }, + "packages/core/node_modules/@google/genai": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz", + "integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/core/node_modules/ajv": { "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -9618,8 +12015,17 @@ } } }, + "packages/core/node_modules/ignore": { + "version": "7.0.5", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "packages/core/node_modules/json-schema-traverse": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "packages/core/node_modules/picomatch": { @@ -9637,7 +12043,6 @@ "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", "version": "0.1.18", - "dev": true, "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -9672,19 +12077,6 @@ "engines": { "vscode": "^1.101.0" } - }, - "packages/vscode-ide-companion/node_modules/@types/node": { - "version": "20.19.9", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "packages/vscode-ide-companion/node_modules/undici-types": { - "version": "6.21.0", - "dev": true, - "license": "MIT" } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 611fed11..c2f57378 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -76,7 +76,7 @@ "react-dom": "^19.1.0", "typescript": "^5.3.3", "vitest": "^3.1.1", - "@google/gemini-cli-test-utils": "file:../test-utils" + "@qwen-code/qwen-code-test-utils": "file:../test-utils" }, "engines": { "node": ">=20" diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index a3c96935..ac5aca9d 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useCommandCompletion } from './useCommandCompletion.js'; import { CommandContext } from '../commands/types.js'; -import { Config } from '@google/gemini-cli-core'; +import { Config } from '@qwen-code/qwen-code-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useEffect } from 'react'; import { Suggestion } from '../components/SuggestionsDisplay.js'; diff --git a/packages/core/package.json b/packages/core/package.json index bbed17ea..5406e022 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,7 +52,7 @@ "ws": "^8.18.0" }, "devDependencies": { - "@google/gemini-cli-test-utils": "file:../test-utils", + "@qwen-code/qwen-code-test-utils": "file:../test-utils", "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/micromatch": "^4.0.8", diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index b804d623..3a7200cd 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -9,7 +9,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as cache from './crawlCache.js'; import { FileSearch, AbortError, filter } from './fileSearch.js'; -import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; +import { createTmpDir, cleanupTmpDir } from '@qwen-code/qwen-code-test-utils'; type FileSearchWithPrivateMethods = FileSearch & { performCrawl: () => Promise; From 290ccdbe2115049dba0a2a22233dea8663c72965 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 13 Aug 2025 16:10:04 +0800 Subject: [PATCH 136/136] chore: stick openai sdk version to 5.11.0 --- package-lock.json | 44 +++++++++++++++++++------------------- packages/core/package.json | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac4dbdc5..26e6ba21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8348,27 +8348,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "5.12.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", - "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11940,7 +11919,7 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", - "openai": "^5.7.0", + "openai": "5.11.0", "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", @@ -12028,6 +12007,27 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "packages/core/node_modules/openai": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", + "integrity": "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "packages/core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index 5406e022..b373d17e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,7 +42,7 @@ "marked": "^15.0.12", "micromatch": "^4.0.8", "open": "^10.1.2", - "openai": "^5.7.0", + "openai": "5.11.0", "picomatch": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.28.0",