From fa93b56243287f8f18f18aaf02e2be8d5449e7a4 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Wed, 5 Nov 2025 11:36:07 -0800 Subject: [PATCH] [Extension Reloading]: Update custom commands, add enable/disable command (#12547) --- packages/cli/src/config/extension-manager.ts | 31 +- packages/cli/src/config/settings.ts | 52 ++- packages/cli/src/nonInteractiveCli.test.ts | 2 +- .../src/services/BuiltinCommandLoader.test.ts | 4 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 +- packages/cli/src/ui/AppContainer.tsx | 8 +- packages/cli/src/ui/auth/AuthDialog.tsx | 7 +- .../src/ui/commands/extensionsCommand.test.ts | 352 +++++++++++++----- .../cli/src/ui/commands/extensionsCommand.ts | 218 +++++++++-- packages/cli/src/ui/commands/types.ts | 2 +- .../ui/components/EditorSettingsDialog.tsx | 20 +- .../cli/src/ui/components/SettingsDialog.tsx | 12 +- .../cli/src/ui/components/ThemeDialog.tsx | 13 +- .../ui/components/shared/ScopeSelector.tsx | 8 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 8 +- .../ui/hooks/slashCommandProcessor.test.tsx | 23 ++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 23 +- .../src/ui/hooks/useEditorSettings.test.tsx | 10 +- .../cli/src/ui/hooks/useEditorSettings.ts | 9 +- packages/cli/src/ui/hooks/useThemeCommand.ts | 9 +- packages/cli/src/utils/dialogScopeUtils.ts | 20 +- packages/cli/src/utils/settingsUtils.ts | 4 +- .../src/validateNonInterActiveAuth.test.ts | 2 +- packages/core/src/utils/extensionLoader.ts | 12 +- 24 files changed, 664 insertions(+), 187 deletions(-) diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index dda9b25c6e..5111c28c0b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -616,15 +616,21 @@ export class ExtensionManager extends ExtensionLoader { throw new Error(`Extension with name ${name} does not exist.`); } - const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); - this.extensionEnablementManager.disable(name, true, scopePath); - extension.isActive = false; - await this.maybeStopExtension(extension); + if (scope !== SettingScope.Session) { + const scopePath = + scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + this.extensionEnablementManager.disable(name, true, scopePath); + } logExtensionDisable( this.telemetryConfig, new ExtensionDisableEvent(hashValue(name), extension.id, scope), ); + if (!this.config || this.config.getEnableExtensionReloading()) { + // Only toggle the isActive state if we are actually going to disable it + // in the current session, or we haven't been initialized yet. + extension.isActive = false; + } + await this.maybeStopExtension(extension); } /** @@ -644,14 +650,21 @@ export class ExtensionManager extends ExtensionLoader { if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } - const scopePath = - scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); - this.extensionEnablementManager.enable(name, true, scopePath); + + if (scope !== SettingScope.Session) { + const scopePath = + scope === SettingScope.Workspace ? this.workspaceDir : os.homedir(); + this.extensionEnablementManager.enable(name, true, scopePath); + } logExtensionEnable( this.telemetryConfig, new ExtensionEnableEvent(hashValue(name), extension.id, scope), ); - extension.isActive = true; + if (!this.config || this.config.getEnableExtensionReloading()) { + // Only toggle the isActive state if we are actually going to disable it + // in the current session, or we haven't been initialized yet. + extension.isActive = true; + } await this.maybeStartExtension(extension); } } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index cfdbf7e44e..3a3295becc 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -157,6 +157,38 @@ export enum SettingScope { Workspace = 'Workspace', System = 'System', SystemDefaults = 'SystemDefaults', + // Note that this scope is not supported in the settings dialog at this time, + // it is only supported for extensions. + Session = 'Session', +} + +/** + * A type representing the settings scopes that are supported for LoadedSettings. + */ +export type LoadableSettingScope = + | SettingScope.User + | SettingScope.Workspace + | SettingScope.System + | SettingScope.SystemDefaults; + +/** + * The actual values of the loadable settings scopes. + */ +const _loadableSettingScopes = [ + SettingScope.User, + SettingScope.Workspace, + SettingScope.System, + SettingScope.SystemDefaults, +]; + +/** + * A type guard function that checks if `scope` is a loadable settings scope, + * and allows promotion to the `LoadableSettingsScope` type based on the result. + */ +export function isLoadableSettingScope( + scope: SettingScope, +): scope is LoadableSettingScope { + return _loadableSettingScopes.includes(scope); } export interface CheckpointingSettings { @@ -398,14 +430,14 @@ export class LoadedSettings { user: SettingsFile, workspace: SettingsFile, isTrusted: boolean, - migratedInMemorScopes: Set, + migratedInMemoryScopes: Set, ) { this.system = system; this.systemDefaults = systemDefaults; this.user = user; this.workspace = workspace; this.isTrusted = isTrusted; - this.migratedInMemorScopes = migratedInMemorScopes; + this.migratedInMemoryScopes = migratedInMemoryScopes; this._merged = this.computeMergedSettings(); } @@ -414,7 +446,7 @@ export class LoadedSettings { readonly user: SettingsFile; readonly workspace: SettingsFile; readonly isTrusted: boolean; - readonly migratedInMemorScopes: Set; + readonly migratedInMemoryScopes: Set; private _merged: Settings; @@ -432,7 +464,7 @@ export class LoadedSettings { ); } - forScope(scope: SettingScope): SettingsFile { + forScope(scope: LoadableSettingScope): SettingsFile { switch (scope) { case SettingScope.User: return this.user; @@ -447,7 +479,7 @@ export class LoadedSettings { } } - setValue(scope: SettingScope, key: string, value: unknown): void { + setValue(scope: LoadableSettingScope, key: string, value: unknown): void { const settingsFile = this.forScope(scope); setNestedProperty(settingsFile.settings, key, value); setNestedProperty(settingsFile.originalSettings, key, value); @@ -563,7 +595,7 @@ export function loadSettings( const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); const systemDefaultsPath = getSystemDefaultsPath(); - const migratedInMemorScopes = new Set(); + const migratedInMemoryScopes = new Set(); // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); @@ -625,7 +657,7 @@ export function loadSettings( ); } } else { - migratedInMemorScopes.add(scope); + migratedInMemoryScopes.add(scope); } settingsObject = migratedSettings; } @@ -703,7 +735,7 @@ export function loadSettings( isTrusted, ); - // loadEnviroment depends on settings so we have to create a temp version of + // loadEnvironment depends on settings so we have to create a temp version of // the settings to avoid a cycle loadEnvironment(tempMergedSettings); @@ -744,7 +776,7 @@ export function loadSettings( rawJson: workspaceResult.rawJson, }, isTrusted, - migratedInMemorScopes, + migratedInMemoryScopes, ); } @@ -752,7 +784,7 @@ export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, extensionManager: ExtensionManager, ): void { - const processScope = (scope: SettingScope) => { + const processScope = (scope: LoadableSettingScope) => { const settings = loadedSettings.forScope(scope).settings; if (settings.extensions?.disabled) { debugLogger.log( diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index d740f08e56..eea524256c 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -184,7 +184,7 @@ describe('runNonInteractive', () => { }, }, isTrusted: true, - migratedInMemorScopes: new Set(), + migratedInMemoryScopes: new Set(), forScope: vi.fn(), computeMergedSettings: vi.fn(), } as unknown as LoadedSettings; diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 3ae6c6639a..49792cb081 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -66,7 +66,7 @@ vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ - extensionsCommand: {}, + extensionsCommand: () => ({}), })); vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} })); vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} })); @@ -97,6 +97,7 @@ describe('BuiltinCommandLoader', () => { getFolderTrust: vi.fn().mockReturnValue(true), getUseModelRouter: () => false, getEnableMessageBusIntegration: () => false, + getEnableExtensionReloading: () => false, } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -222,6 +223,7 @@ describe('BuiltinCommandLoader profile', () => { getUseModelRouter: () => false, getCheckpointingEnabled: () => false, getEnableMessageBusIntegration: () => false, + getEnableExtensionReloading: () => false, } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 010cf60aa5..6e6c0a407f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -67,7 +67,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, - extensionsCommand, + extensionsCommand(this.config?.getEnableExtensionReloading()), helpCommand, await ideCommand(), initCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7d54a452b3..cb6ac4ba21 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -76,7 +76,11 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; -import { type LoadedSettings, SettingScope } from '../config/settings.js'; +import { + type LoadableSettingScope, + type LoadedSettings, + SettingScope, +} from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; @@ -396,7 +400,7 @@ export const AppContainer = (props: AppContainerProps) => { // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( - async (authType: AuthType | undefined, scope: SettingScope) => { + async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index c024dd255e..61ea01764d 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -9,7 +9,10 @@ import { useCallback } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; -import type { LoadedSettings } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { AuthType, @@ -99,7 +102,7 @@ export function AuthDialog({ } const onSelect = useCallback( - async (authType: AuthType | undefined, scope: SettingScope) => { + async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { await clearCachedCredentialFile(); diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index c10b3896d1..db947b3968 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -7,10 +7,16 @@ import type { GeminiCLIExtension } from '@google/gemini-cli-core'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; -import { extensionsCommand } from './extensionsCommand.js'; -import { type CommandContext } from './types.js'; +import { + completeExtensions, + completeExtensionsAndScopes, + extensionsCommand, +} from './extensionsCommand.js'; +import { type CommandContext, type SlashCommand } from './types.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { SettingScope } from '../../config/settings.js'; import open from 'open'; vi.mock('open', () => ({ @@ -22,20 +28,72 @@ vi.mock('../../config/extensions/update.js', () => ({ checkForAllExtensionUpdates: vi.fn(), })); +const mockDisableExtension = vi.fn(); +const mockEnableExtension = vi.fn(); const mockGetExtensions = vi.fn(); +const inactiveExt: GeminiCLIExtension = { + name: 'ext-one', + id: 'ext-one-id', + version: '1.0.0', + isActive: false, // should suggest disabled extensions + path: '/test/dir/ext-one', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; +const activeExt: GeminiCLIExtension = { + name: 'ext-two', + id: 'ext-two-id', + version: '1.0.0', + isActive: true, // should not suggest enabled extensions + path: '/test/dir/ext-two', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; +const allExt: GeminiCLIExtension = { + name: 'all-ext', + id: 'all-ext-id', + version: '1.0.0', + isActive: true, + path: '/test/dir/all-ext', + contextFiles: [], + installMetadata: { + type: 'git', + autoUpdate: false, + source: 'https://github.com/some/extension.git', + }, +}; + describe('extensionsCommand', () => { let mockContext: CommandContext; const mockDispatchExtensionState = vi.fn(); beforeEach(() => { vi.resetAllMocks(); - mockGetExtensions.mockReturnValue([]); + + mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]); vi.mocked(open).mockClear(); mockContext = createMockCommandContext({ services: { config: { getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockImplementation(() => { + const actual = Object.create(ExtensionManager.prototype); + Object.assign(actual, { + enableExtension: mockEnableExtension, + disableExtension: mockDisableExtension, + getExtensions: mockGetExtensions, + }); + return actual; + }), getWorkingDir: () => '/test/dir', }, }, @@ -52,8 +110,9 @@ describe('extensionsCommand', () => { describe('list', () => { it('should add an EXTENSIONS_LIST item to the UI', async () => { - if (!extensionsCommand.action) throw new Error('Action not defined'); - await extensionsCommand.action(mockContext, ''); + const command = extensionsCommand(); + if (!command.action) throw new Error('Action not defined'); + await command.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -65,8 +124,68 @@ describe('extensionsCommand', () => { }); }); + describe('completeExtensions', () => { + it.each([ + { + description: 'should return matching extension names', + partialArg: 'ext', + expected: ['ext-one', 'ext-two'], + }, + { + description: 'should return --all when partialArg matches', + partialArg: '--al', + expected: ['--all'], + }, + { + description: + 'should return both extension names and --all when both match', + partialArg: 'all', + expected: ['--all', 'all-ext'], + }, + { + description: 'should return an empty array if no matches', + partialArg: 'nomatch', + expected: [], + }, + { + description: + 'should suggest only disabled extension names for the enable command', + partialArg: 'ext', + expected: ['ext-one'], + command: 'enable', + }, + { + description: + 'should suggest only enabled extension names for the disable command', + partialArg: 'ext', + expected: ['ext-two'], + command: 'disable', + }, + ])('$description', async ({ partialArg, expected, command }) => { + if (command) { + mockContext.invocation!.name = command; + } + const suggestions = completeExtensions(mockContext, partialArg); + expect(suggestions).toEqual(expected); + }); + }); + + describe('completeExtensionsAndScopes', () => { + it('expands the list of suggestions with --scope args', () => { + const suggestions = completeExtensionsAndScopes(mockContext, 'ext'); + expect(suggestions).toEqual([ + 'ext-one --scope user', + 'ext-one --scope workspace', + 'ext-one --scope session', + 'ext-two --scope user', + 'ext-two --scope workspace', + 'ext-two --scope session', + ]); + }); + }); + describe('update', () => { - const updateAction = extensionsCommand.subCommands?.find( + const updateAction = extensionsCommand().subCommands?.find( (cmd) => cmd.name === 'update', )?.action; @@ -230,92 +349,10 @@ describe('extensionsCommand', () => { expect.any(Number), ); }); - - describe('completion', () => { - const updateCompletion = extensionsCommand.subCommands?.find( - (cmd) => cmd.name === 'update', - )?.completion; - - if (!updateCompletion) { - throw new Error('Update completion not found'); - } - - const extensionOne: GeminiCLIExtension = { - name: 'ext-one', - id: 'ext-one-id', - version: '1.0.0', - isActive: true, - path: '/test/dir/ext-one', - contextFiles: [], - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - const extensionTwo: GeminiCLIExtension = { - name: 'another-ext', - id: 'another-ext-id', - version: '1.0.0', - isActive: true, - path: '/test/dir/another-ext', - contextFiles: [], - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - const allExt: GeminiCLIExtension = { - name: 'all-ext', - id: 'all-ext-id', - version: '1.0.0', - isActive: true, - path: '/test/dir/all-ext', - contextFiles: [], - installMetadata: { - type: 'git', - autoUpdate: false, - source: 'https://github.com/some/extension.git', - }, - }; - - it.each([ - { - description: 'should return matching extension names', - extensions: [extensionOne, extensionTwo], - partialArg: 'ext', - expected: ['ext-one'], - }, - { - description: 'should return --all when partialArg matches', - extensions: [], - partialArg: '--al', - expected: ['--all'], - }, - { - description: - 'should return both extension names and --all when both match', - extensions: [allExt], - partialArg: 'all', - expected: ['--all', 'all-ext'], - }, - { - description: 'should return an empty array if no matches', - extensions: [extensionOne], - partialArg: 'nomatch', - expected: [], - }, - ])('$description', async ({ extensions, partialArg, expected }) => { - mockGetExtensions.mockReturnValue(extensions); - const suggestions = await updateCompletion(mockContext, partialArg); - expect(suggestions).toEqual(expected); - }); - }); }); describe('explore', () => { - const exploreAction = extensionsCommand.subCommands?.find( + const exploreAction = extensionsCommand().subCommands?.find( (cmd) => cmd.name === 'explore', )?.action; @@ -398,4 +435,141 @@ describe('extensionsCommand', () => { ); }); }); + + describe('when enableExtensionReloading is true', () => { + it('should include enable and disable subcommands', () => { + const command = extensionsCommand(true); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).toContain('enable'); + expect(subCommandNames).toContain('disable'); + }); + }); + + describe('when enableExtensionReloading is false', () => { + it('should not include enable and disable subcommands', () => { + const command = extensionsCommand(false); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).not.toContain('enable'); + expect(subCommandNames).not.toContain('disable'); + }); + }); + + describe('when enableExtensionReloading is not provided', () => { + it('should not include enable and disable subcommands by default', () => { + const command = extensionsCommand(); + const subCommandNames = command.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).not.toContain('enable'); + expect(subCommandNames).not.toContain('disable'); + }); + }); + + describe('enable', () => { + let enableAction: SlashCommand['action']; + + beforeEach(() => { + enableAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'enable', + )?.action; + + expect(enableAction).not.toBeNull(); + + mockContext.invocation!.name = 'enable'; + }); + + it('should show usage if no extension name is provided', async () => { + await enableAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions enable [--scope=]', + }, + expect.any(Number), + ); + }); + + it('should call enableExtension with the provided scope', async () => { + await enableAction!(mockContext, `${inactiveExt.name} --scope=user`); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.User, + ); + + await enableAction!(mockContext, `${inactiveExt.name} --scope workspace`); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.Workspace, + ); + }); + + it('should support --all', async () => { + mockGetExtensions.mockReturnValue([ + inactiveExt, + { ...inactiveExt, name: 'another-inactive-ext' }, + ]); + await enableAction!(mockContext, '--all --scope session'); + expect(mockEnableExtension).toHaveBeenCalledWith( + inactiveExt.name, + SettingScope.Session, + ); + expect(mockEnableExtension).toHaveBeenCalledWith( + 'another-inactive-ext', + SettingScope.Session, + ); + }); + }); + + describe('disable', () => { + let disableAction: SlashCommand['action']; + + beforeEach(() => { + disableAction = extensionsCommand(true).subCommands?.find( + (cmd) => cmd.name === 'disable', + )?.action; + + expect(disableAction).not.toBeNull(); + + mockContext.invocation!.name = 'disable'; + }); + + it('should show usage if no extension name is provided', async () => { + await disableAction!(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions disable [--scope=]', + }, + expect.any(Number), + ); + }); + + it('should call disableExtension with the provided scope', async () => { + await disableAction!(mockContext, `${activeExt.name} --scope=user`); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.User, + ); + + await disableAction!(mockContext, `${activeExt.name} --scope workspace`); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.Workspace, + ); + }); + + it('should support --all', async () => { + mockGetExtensions.mockReturnValue([ + activeExt, + { ...activeExt, name: 'another-active-ext' }, + ]); + await disableAction!(mockContext, '--all --scope session'); + expect(mockDisableExtension).toHaveBeenCalledWith( + activeExt.name, + SettingScope.Session, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'another-active-ext', + SettingScope.Session, + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 45ea3e47b6..2cb823543a 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { listExtensions } from '@google/gemini-cli-core'; +import { debugLogger, listExtensions } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; import { getErrorMessage } from '../../utils/errors.js'; import { MessageType, type HistoryItemExtensionsList } from '../types.js'; @@ -15,6 +15,8 @@ import { } from './types.js'; import open from 'open'; import process from 'node:process'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { SettingScope } from '../../config/settings.js'; async function listAction(context: CommandContext) { const historyItem: HistoryItemExtensionsList = { @@ -159,6 +161,158 @@ async function exploreAction(context: CommandContext) { } } +function getEnableDisableContext( + context: CommandContext, + argumentsString: string, +): { + extensionManager: ExtensionManager; + names: string[]; + scope: SettingScope; +} | null { + const extensionLoader = context.services.config?.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return null; + } + const parts = argumentsString.split(' '); + const name = parts[0]; + if ( + name === '' || + !( + (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= + (parts.length === 3 && parts[1] === '--scope') // --scope + ) + ) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Usage: /extensions ${context.invocation?.name} [--scope=]`, + }, + Date.now(), + ); + return null; + } + let scope: SettingScope; + // Transform `--scope=` to `--scope `. + if (parts.length === 2) { + parts.push(...parts[1].split('=')); + parts.splice(1, 1); + } + switch (parts[2].toLowerCase()) { + case 'workspace': + scope = SettingScope.Workspace; + break; + case 'user': + scope = SettingScope.User; + break; + case 'session': + scope = SettingScope.Session; + break; + default: + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Unsupported scope ${parts[2]}, should be one of "user", "workspace", or "session"`, + }, + Date.now(), + ); + debugLogger.error(); + return null; + } + let names: string[] = []; + if (name === '--all') { + let extensions = extensionLoader.getExtensions(); + if (context.invocation?.name === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if (context.invocation?.name === 'disable') { + extensions = extensions.filter((ext) => ext.isActive); + } + names = extensions.map((ext) => ext.name); + } else { + names = [name]; + } + + return { + extensionManager: extensionLoader, + names, + scope, + }; +} + +async function disableAction(context: CommandContext, args: string) { + const enableContext = getEnableDisableContext(context, args); + if (!enableContext) return; + + const { names, scope, extensionManager } = enableContext; + for (const name of names) { + await extensionManager.disableExtension(name, scope); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Extension "${name}" disabled for the scope "${scope}"`, + }, + Date.now(), + ); + } +} + +async function enableAction(context: CommandContext, args: string) { + const enableContext = getEnableDisableContext(context, args); + if (!enableContext) return; + + const { names, scope, extensionManager } = enableContext; + for (const name of names) { + await extensionManager.enableExtension(name, scope); + context.ui.addItem( + { + type: MessageType.INFO, + text: `Extension "${name}" enabled for the scope "${scope}"`, + }, + Date.now(), + ); + } +} + +/** + * Exported for testing. + */ +export function completeExtensions( + context: CommandContext, + partialArg: string, +) { + let extensions = context.services.config?.getExtensions() ?? []; + if (context.invocation?.name === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if (context.invocation?.name === 'disable') { + extensions = extensions.filter((ext) => ext.isActive); + } + const extensionNames = extensions.map((ext) => ext.name); + const suggestions = extensionNames.filter((name) => + name.startsWith(partialArg), + ); + + if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { + suggestions.unshift('--all'); + } + + return suggestions; +} + +export function completeExtensionsAndScopes( + context: CommandContext, + partialArg: string, +) { + return completeExtensions(context, partialArg).flatMap((s) => [ + `${s} --scope user`, + `${s} --scope workspace`, + `${s} --scope session`, + ]); +} + const listExtensionsCommand: SlashCommand = { name: 'list', description: 'List active extensions', @@ -171,21 +325,23 @@ const updateExtensionsCommand: SlashCommand = { description: 'Update extensions. Usage: update |--all', kind: CommandKind.BUILT_IN, action: updateAction, - completion: async (context, partialArg) => { - const extensions = context.services.config - ? listExtensions(context.services.config) - : []; - const extensionNames = extensions.map((ext) => ext.name); - const suggestions = extensionNames.filter((name) => - name.startsWith(partialArg), - ); + completion: completeExtensions, +}; - if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { - suggestions.unshift('--all'); - } +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an extension', + kind: CommandKind.BUILT_IN, + action: disableAction, + completion: completeExtensionsAndScopes, +}; - return suggestions; - }, +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable an extension', + kind: CommandKind.BUILT_IN, + action: enableAction, + completion: completeExtensionsAndScopes, }; const exploreExtensionsCommand: SlashCommand = { @@ -195,16 +351,24 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; -export const extensionsCommand: SlashCommand = { - name: 'extensions', - description: 'Manage extensions', - kind: CommandKind.BUILT_IN, - subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - exploreExtensionsCommand, - ], - action: (context, args) => - // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), -}; +export function extensionsCommand( + enableExtensionReloading?: boolean, +): SlashCommand { + const conditionalCommands = enableExtensionReloading + ? [disableCommand, enableCommand] + : []; + return { + name: 'extensions', + description: 'Manage extensions', + kind: CommandKind.BUILT_IN, + subCommands: [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + ...conditionalCommands, + ], + action: (context, args) => + // Default to list if no subcommand is provided + listExtensionsCommand.action!(context, args), + }; +} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 44080cbf61..99e514fbba 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -211,7 +211,7 @@ export interface SlashCommand { completion?: ( context: CommandContext, partialArg: string, - ) => Promise; + ) => Promise | string[]; subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index 3e70207bcb..55434fdf9d 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -14,14 +14,20 @@ import { type EditorDisplay, } from '../editors/editorSettingsManager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; -import type { LoadedSettings } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import type { EditorType } from '@google/gemini-cli-core'; import { isEditorAvailable } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; interface EditorDialogProps { - onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void; + onSelect: ( + editorType: EditorType | undefined, + scope: LoadableSettingScope, + ) => void; settings: LoadedSettings; onExit: () => void; } @@ -31,7 +37,7 @@ export function EditorSettingsDialog({ settings, onExit, }: EditorDialogProps): React.JSX.Element { - const [selectedScope, setSelectedScope] = useState( + const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( @@ -64,7 +70,11 @@ export function EditorSettingsDialog({ editorIndex = 0; } - const scopeItems = [ + const scopeItems: Array<{ + label: string; + value: LoadableSettingScope; + key: string; + }> = [ { label: 'User Settings', value: SettingScope.User, @@ -85,7 +95,7 @@ export function EditorSettingsDialog({ onSelect(editorType, selectedScope); }; - const handleScopeSelect = (scope: SettingScope) => { + const handleScopeSelect = (scope: LoadableSettingScope) => { setSelectedScope(scope); setFocusedSection('editor'); }; diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b0f107c7a5..4f74afd12e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,7 +7,11 @@ import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import type { LoadedSettings, Settings } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, + Settings, +} from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeItems, @@ -63,7 +67,7 @@ export function SettingsDialog({ 'settings', ); // Scope selector state (User by default) - const [selectedScope, setSelectedScope] = useState( + const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); // Active indices @@ -358,11 +362,11 @@ export function SettingsDialog({ key: item.value, })); - const handleScopeHighlight = (scope: SettingScope) => { + const handleScopeHighlight = (scope: LoadableSettingScope) => { setSelectedScope(scope); }; - const handleScopeSelect = (scope: SettingScope) => { + const handleScopeSelect = (scope: LoadableSettingScope) => { handleScopeHighlight(scope); setFocusSection('settings'); }; diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index f6f35ed8f5..611c9f9a71 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -12,7 +12,10 @@ import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; -import type { LoadedSettings } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -20,7 +23,7 @@ import { ScopeSelector } from './shared/ScopeSelector.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ - onSelect: (themeName: string, scope: SettingScope) => void; + onSelect: (themeName: string, scope: LoadableSettingScope) => void; /** Callback function when the dialog is cancelled */ onCancel: () => void; @@ -41,7 +44,7 @@ export function ThemeDialog({ availableTerminalHeight, terminalWidth, }: ThemeDialogProps): React.JSX.Element { - const [selectedScope, setSelectedScope] = useState( + const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); @@ -97,12 +100,12 @@ export function ThemeDialog({ onHighlight(themeName); }; - const handleScopeHighlight = useCallback((scope: SettingScope) => { + const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); }, []); const handleScopeSelect = useCallback( - (scope: SettingScope) => { + (scope: LoadableSettingScope) => { onSelect(highlightedThemeName, scope); }, [onSelect, highlightedThemeName], diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx index 30aa1e403f..6ba19ddf6f 100644 --- a/packages/cli/src/ui/components/shared/ScopeSelector.tsx +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -6,19 +6,19 @@ import type React from 'react'; import { Box, Text } from 'ink'; -import type { SettingScope } from '../../../config/settings.js'; +import type { LoadableSettingScope } from '../../../config/settings.js'; import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; import { RadioButtonSelect } from './RadioButtonSelect.js'; interface ScopeSelectorProps { /** Callback function when a scope is selected */ - onSelect: (scope: SettingScope) => void; + onSelect: (scope: LoadableSettingScope) => void; /** Callback function when a scope is highlighted */ - onHighlight: (scope: SettingScope) => void; + onHighlight: (scope: LoadableSettingScope) => void; /** Whether the component is focused */ isFocused: boolean; /** The initial scope to select */ - initialScope: SettingScope; + initialScope: LoadableSettingScope; } export function ScopeSelector({ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 31a0ec2a34..4e2cf4a5e6 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -9,22 +9,22 @@ import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; import { type AuthType, type EditorType } from '@google/gemini-cli-core'; -import { type SettingScope } from '../../config/settings.js'; +import { type LoadableSettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; export interface UIActions { - handleThemeSelect: (themeName: string, scope: SettingScope) => void; + handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void; closeThemeDialog: () => void; handleThemeHighlight: (themeName: string | undefined) => void; handleAuthSelect: ( authType: AuthType | undefined, - scope: SettingScope, + scope: LoadableSettingScope, ) => void; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; handleEditorSelect: ( editorType: EditorType | undefined, - scope: SettingScope, + scope: LoadableSettingScope, ) => void; exitEditorDialog: () => void; exitPrivacyNotice: () => void; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index b8b0081872..7fa0db1852 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -26,6 +26,7 @@ import { ToolConfirmationOutcome, makeFakeConfig, } from '@google/gemini-cli-core'; +import { appEvents } from '../../utils/events.js'; const { logSlashCommand } = vi.hoisted(() => ({ logSlashCommand: vi.fn(), @@ -1076,4 +1077,26 @@ describe('useSlashCommandProcessor', () => { expect(logSlashCommand).not.toHaveBeenCalled(); }); }); + + it('should reload commands on extension events', async () => { + const result = await setupProcessorHook(); + await waitFor(() => expect(result.current.slashCommands).toEqual([])); + + // Create a new command and make that the result of the fileLoadCommands + // (which is where extension commands come from) + const newCommand = createTestCommand({ + name: 'someNewCommand', + action: vi.fn(), + }); + mockFileLoadCommands.mockResolvedValue([newCommand]); + + // We should not see a change until we fire an event. + await waitFor(() => expect(result.current.slashCommands).toEqual([])); + await act(() => { + appEvents.emit('extensionsStarting'); + }); + await waitFor(() => + expect(result.current.slashCommands).toEqual([newCommand]), + ); + }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index ec5ee1609a..fe8be20012 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -8,7 +8,11 @@ import { useCallback, useMemo, useEffect, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import type { Config } from '@google/gemini-cli-core'; +import type { + Config, + ExtensionsStartingEvent, + ExtensionsStoppingEvent, +} from '@google/gemini-cli-core'; import { GitService, Logger, @@ -39,6 +43,7 @@ import { type ExtensionUpdateAction, type ExtensionUpdateStatus, } from '../state/extensions.js'; +import { appEvents } from '../../utils/events.js'; interface SlashCommandProcessorActions { openAuthDialog: () => void; @@ -249,11 +254,27 @@ export const useSlashCommandProcessor = ( ideClient.addStatusChangeListener(listener); })(); + // TODO: Ideally this would happen more directly inside the ExtensionLoader, + // but the CommandService today is not conducive to that since it isn't a + // long lived service but instead gets fully re-created based on reload + // events within this hook. + const extensionEventListener = ( + _event: ExtensionsStartingEvent | ExtensionsStoppingEvent, + ) => { + // We only care once at least one extension has completed + // starting/stopping + reloadCommands(); + }; + appEvents.on('extensionsStarting', extensionEventListener); + appEvents.on('extensionsStopping', extensionEventListener); + return () => { (async () => { const ideClient = await IdeClient.getInstance(); ideClient.removeStatusChangeListener(listener); })(); + appEvents.off('extensionsStarting', extensionEventListener); + appEvents.off('extensionsStopping', extensionEventListener); }; }, [config, reloadCommands]); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 3797198a8e..db46856c7d 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -16,7 +16,10 @@ import { import { act } from 'react'; import { render } from '../../test-utils/render.js'; import { useEditorSettings } from './useEditorSettings.js'; -import type { LoadedSettings } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { MessageType, type HistoryItem } from '../types.js'; import { @@ -186,7 +189,10 @@ describe('useEditorSettings', () => { render(); const editorType: EditorType = 'vscode'; - const scopes = [SettingScope.User, SettingScope.Workspace]; + const scopes: LoadableSettingScope[] = [ + SettingScope.User, + SettingScope.Workspace, + ]; scopes.forEach((scope) => { act(() => { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index 7c0e35c2b5..075de1bc71 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -5,7 +5,10 @@ */ import { useState, useCallback } from 'react'; -import type { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; import { type HistoryItem, MessageType } from '../types.js'; import type { EditorType } from '@google/gemini-cli-core'; import { @@ -18,7 +21,7 @@ interface UseEditorSettingsReturn { openEditorDialog: () => void; handleEditorSelect: ( editorType: EditorType | undefined, - scope: SettingScope, + scope: LoadableSettingScope, ) => void; exitEditorDialog: () => void; } @@ -35,7 +38,7 @@ export const useEditorSettings = ( }, []); const handleEditorSelect = useCallback( - (editorType: EditorType | undefined, scope: SettingScope) => { + (editorType: EditorType | undefined, scope: LoadableSettingScope) => { if ( editorType && (!checkHasEditorType(editorType) || diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 46cf0e5d85..72133e9b11 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -6,7 +6,10 @@ import { useState, useCallback } from 'react'; import { themeManager } from '../themes/theme-manager.js'; -import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting +import type { + LoadableSettingScope, + LoadedSettings, +} from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting import { type HistoryItem, MessageType } from '../types.js'; import process from 'node:process'; @@ -14,7 +17,7 @@ interface UseThemeCommandReturn { isThemeDialogOpen: boolean; openThemeDialog: () => void; closeThemeDialog: () => void; - handleThemeSelect: (themeName: string, scope: SettingScope) => void; + handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void; handleThemeHighlight: (themeName: string | undefined) => void; } @@ -68,7 +71,7 @@ export const useThemeCommand = ( }, [applyTheme, loadedSettings]); const handleThemeSelect = useCallback( - (themeName: string, scope: SettingScope) => { + (themeName: string, scope: LoadableSettingScope) => { try { // Merge user and workspace custom themes (workspace takes precedence) const mergedCustomThemes = { diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index fd4cbbd4fc..ccf93b6a68 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope } from '../config/settings.js'; +import type { + LoadableSettingScope, + LoadedSettings, +} from '../config/settings.js'; +import { isLoadableSettingScope, SettingScope } from '../config/settings.js'; import { settingExistsInScope } from './settingsUtils.js'; /** @@ -20,7 +23,10 @@ export const SCOPE_LABELS = { /** * Helper function to get scope items for radio button selects */ -export function getScopeItems() { +export function getScopeItems(): Array<{ + label: string; + value: LoadableSettingScope; +}> { return [ { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, { @@ -36,12 +42,12 @@ export function getScopeItems() { */ export function getScopeMessageForSetting( settingKey: string, - selectedScope: SettingScope, + selectedScope: LoadableSettingScope, settings: LoadedSettings, ): string { - const otherScopes = Object.values(SettingScope).filter( - (scope) => scope !== selectedScope, - ); + const otherScopes = Object.values(SettingScope) + .filter(isLoadableSettingScope) + .filter((scope) => scope !== selectedScope); const modifiedInOtherScopes = otherScopes.filter((scope) => { const scopeSettings = settings.forScope(scope).settings; diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index a9a429370a..7ec5fd5885 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -6,8 +6,8 @@ import type { Settings, - SettingScope, LoadedSettings, + LoadableSettingScope, } from '../config/settings.js'; import type { SettingDefinition, @@ -391,7 +391,7 @@ export function saveModifiedSettings( modifiedSettings: Set, pendingSettings: Settings, loadedSettings: LoadedSettings, - scope: SettingScope, + scope: LoadableSettingScope, ): void { modifiedSettings.forEach((settingKey) => { const path = settingKey.split('.'); diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 475e079bdf..e9f8c7c8ae 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -69,7 +69,7 @@ describe('validateNonInterActiveAuth', () => { }, }, isTrusted: true, - migratedInMemorScopes: new Set(), + migratedInMemoryScopes: new Set(), forScope: vi.fn(), computeMergedSettings: vi.fn(), } as unknown as LoadedSettings; diff --git a/packages/core/src/utils/extensionLoader.ts b/packages/core/src/utils/extensionLoader.ts index b65f227143..f47ff95015 100644 --- a/packages/core/src/utils/extensionLoader.ts +++ b/packages/core/src/utils/extensionLoader.ts @@ -64,9 +64,12 @@ export abstract class ExtensionLoader { }); try { await this.config.getMcpClientManager()!.startExtension(extension); - // TODO: Move all extension features here, including at least: + // TODO: Update custom command updating away from the event based system + // and call directly into a custom command manager here. See the + // useSlashCommandProcessor hook which responds to events fired here today. + + // TODO: Move all enablement of extension features here, including at least: // - context file loading - // - custom command loading // - excluded tool configuration } finally { this.startCompletedCount++; @@ -116,9 +119,12 @@ export abstract class ExtensionLoader { try { await this.config.getMcpClientManager()!.stopExtension(extension); + // TODO: Update custom command updating away from the event based system + // and call directly into a custom command manager here. See the + // useSlashCommandProcessor hook which responds to events fired here today. + // TODO: Remove all extension features here, including at least: // - context files - // - custom commands // - excluded tools } finally { this.stopCompletedCount++;