[feat]: Add /extensions restart command (#12739)

This commit is contained in:
Jacob MacDonald
2025-11-07 15:17:23 -08:00
committed by GitHub
parent fdb6088603
commit bafbcbbe8b
9 changed files with 457 additions and 10 deletions
@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { GeminiCLIExtension } from '@google/gemini-cli-core'; import type {
ExtensionLoader,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { import {
@@ -13,12 +16,21 @@ import {
extensionsCommand, extensionsCommand,
} from './extensionsCommand.js'; } from './extensionsCommand.js';
import { type CommandContext, type SlashCommand } from './types.js'; import { type CommandContext, type SlashCommand } from './types.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockedFunction,
} from 'vitest';
import { type ExtensionUpdateAction } from '../state/extensions.js'; import { type ExtensionUpdateAction } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js'; import { ExtensionManager } from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js';
import open from 'open'; import open from 'open';
vi.mock('open', () => ({ vi.mock('open', () => ({
default: vi.fn(), default: vi.fn(),
})); }));
@@ -572,4 +584,178 @@ describe('extensionsCommand', () => {
); );
}); });
}); });
describe('restart', () => {
let restartAction: SlashCommand['action'];
let mockRestartExtension: MockedFunction<
typeof ExtensionLoader.prototype.restartExtension
>;
beforeEach(() => {
restartAction = extensionsCommand().subCommands?.find(
(c) => c.name === 'restart',
)?.action;
expect(restartAction).not.toBeNull();
mockRestartExtension = vi.fn();
mockContext.services.config!.getExtensionLoader = vi
.fn()
.mockImplementation(() => ({
getExtensions: mockGetExtensions,
restartExtension: mockRestartExtension,
}));
mockContext.invocation!.name = 'restart';
});
it('restarts all active extensions when --all is provided', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
{ name: 'ext2', isActive: true },
{ name: 'ext3', isActive: false },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
await restartAction!(mockContext, '--all');
expect(mockRestartExtension).toHaveBeenCalledTimes(2);
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Restarting 2 extensions...',
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: '2 extensions restarted successfully.',
}),
expect.any(Number),
);
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
type: 'RESTARTED',
payload: { name: 'ext1' },
});
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
type: 'RESTARTED',
payload: { name: 'ext2' },
});
});
it('restarts only specified active extensions', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: false },
{ name: 'ext2', isActive: true },
{ name: 'ext3', isActive: true },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
await restartAction!(mockContext, 'ext1 ext3');
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]);
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
type: 'RESTARTED',
payload: { name: 'ext3' },
});
});
it('shows an error if no extension loader is available', async () => {
mockContext.services.config!.getExtensionLoader = vi.fn();
await restartAction!(mockContext, '--all');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: "Extensions are not yet loaded, can't restart yet",
}),
expect.any(Number),
);
expect(mockRestartExtension).not.toHaveBeenCalled();
});
it('shows usage error for no arguments', async () => {
await restartAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Usage: /extensions restart <extension-names>|--all',
}),
expect.any(Number),
);
expect(mockRestartExtension).not.toHaveBeenCalled();
});
it('handles errors during extension restart', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
mockRestartExtension.mockRejectedValue(new Error('Failed to restart'));
await restartAction!(mockContext, '--all');
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
}),
expect.any(Number),
);
});
it('shows a warning if an extension is not found', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
await restartAction!(mockContext, 'ext1 ext2');
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.WARNING,
text: 'Extension(s) not found or not active: ext2',
}),
expect.any(Number),
);
});
it('does not restart any extensions if none are found', async () => {
const mockExtensions = [
{ name: 'ext1', isActive: true },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
await restartAction!(mockContext, 'ext2 ext3');
expect(mockRestartExtension).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.WARNING,
text: 'Extension(s) not found or not active: ext2, ext3',
}),
expect.any(Number),
);
});
it('should suggest only enabled extension names for the restart command', async () => {
mockContext.invocation!.name = 'restart';
const mockExtensions = [
{ name: 'ext1', isActive: true },
{ name: 'ext2', isActive: false },
] as GeminiCLIExtension[];
mockGetExtensions.mockReturnValue(mockExtensions);
const suggestions = completeExtensions(mockContext, 'ext');
expect(suggestions).toEqual(['ext1']);
});
});
}); });
@@ -7,7 +7,11 @@
import { debugLogger, listExtensions } from '@google/gemini-cli-core'; import { debugLogger, listExtensions } from '@google/gemini-cli-core';
import type { ExtensionUpdateInfo } from '../../config/extension.js'; import type { ExtensionUpdateInfo } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';
import { MessageType, type HistoryItemExtensionsList } from '../types.js'; import {
MessageType,
type HistoryItemExtensionsList,
type HistoryItemInfo,
} from '../types.js';
import { import {
type CommandContext, type CommandContext,
type SlashCommand, type SlashCommand,
@@ -17,6 +21,7 @@ import open from 'open';
import process from 'node:process'; import process from 'node:process';
import { ExtensionManager } from '../../config/extension-manager.js'; import { ExtensionManager } from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js';
import { theme } from '../semantic-colors.js';
async function listAction(context: CommandContext) { async function listAction(context: CommandContext) {
const historyItem: HistoryItemExtensionsList = { const historyItem: HistoryItemExtensionsList = {
@@ -116,6 +121,118 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
return updateComplete.then((_) => {}); return updateComplete.then((_) => {});
} }
async function restartAction(
context: CommandContext,
args: string,
): Promise<void> {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!extensionLoader) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: "Extensions are not yet loaded, can't restart yet",
},
Date.now(),
);
return;
}
const restartArgs = args.split(' ').filter((value) => value.length > 0);
const all = restartArgs.length === 1 && restartArgs[0] === '--all';
const names = all ? null : restartArgs;
if (!all && names?.length === 0) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Usage: /extensions restart <extension-names>|--all',
},
Date.now(),
);
return Promise.resolve();
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((extension) => extension.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((extension) =>
names.includes(extension.name),
);
if (names.length !== extensionsToRestart.length) {
const notFound = names.filter(
(name) =>
!extensionsToRestart.some((extension) => extension.name === name),
);
if (notFound.length > 0) {
context.ui.addItem(
{
type: MessageType.WARNING,
text: `Extension(s) not found or not active: ${notFound.join(
', ',
)}`,
},
Date.now(),
);
}
}
}
if (extensionsToRestart.length === 0) {
// We will have logged a different message above already.
return;
}
const s = extensionsToRestart.length > 1 ? 's' : '';
const restartingMessage = {
type: MessageType.INFO,
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
color: theme.text.primary,
};
context.ui.addItem(restartingMessage, Date.now());
const results = await Promise.allSettled(
extensionsToRestart.map(async (extension) => {
if (extension.isActive) {
await extensionLoader.restartExtension(extension);
context.ui.dispatchExtensionStateUpdate({
type: 'RESTARTED',
payload: {
name: extension.name,
},
});
}
}),
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (failures.length > 0) {
const errorMessages = failures
.map((failure, index) => {
const extensionName = extensionsToRestart[index].name;
return `${extensionName}: ${getErrorMessage(failure.reason)}`;
})
.join('\n ');
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to restart some extensions:\n ${errorMessages}`,
},
Date.now(),
);
} else {
const infoItem: HistoryItemInfo = {
type: MessageType.INFO,
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
icon: ' ',
color: theme.text.primary,
};
context.ui.addItem(infoItem, Date.now());
}
}
async function exploreAction(context: CommandContext) { async function exploreAction(context: CommandContext) {
const extensionsUrl = 'https://geminicli.com/extensions/'; const extensionsUrl = 'https://geminicli.com/extensions/';
@@ -284,10 +401,14 @@ export function completeExtensions(
partialArg: string, partialArg: string,
) { ) {
let extensions = context.services.config?.getExtensions() ?? []; let extensions = context.services.config?.getExtensions() ?? [];
if (context.invocation?.name === 'enable') { if (context.invocation?.name === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive); extensions = extensions.filter((ext) => !ext.isActive);
} }
if (context.invocation?.name === 'disable') { if (
context.invocation?.name === 'disable' ||
context.invocation?.name === 'restart'
) {
extensions = extensions.filter((ext) => ext.isActive); extensions = extensions.filter((ext) => ext.isActive);
} }
const extensionNames = extensions.map((ext) => ext.name); const extensionNames = extensions.map((ext) => ext.name);
@@ -351,6 +472,14 @@ const exploreExtensionsCommand: SlashCommand = {
action: exploreAction, action: exploreAction,
}; };
const restartCommand: SlashCommand = {
name: 'restart',
description: 'Restart all extensions',
kind: CommandKind.BUILT_IN,
action: restartAction,
completion: completeExtensions,
};
export function extensionsCommand( export function extensionsCommand(
enableExtensionReloading?: boolean, enableExtensionReloading?: boolean,
): SlashCommand { ): SlashCommand {
@@ -365,6 +494,7 @@ export function extensionsCommand(
listExtensionsCommand, listExtensionsCommand,
updateExtensionsCommand, updateExtensionsCommand,
exploreExtensionsCommand, exploreExtensionsCommand,
restartCommand,
...conditionalCommands, ...conditionalCommands,
], ],
action: (context, args) => action: (context, args) =>
@@ -86,7 +86,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
/> />
)} )}
{itemForDisplay.type === 'info' && ( {itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} /> <InfoMessage
text={itemForDisplay.text}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
/>
)} )}
{itemForDisplay.type === 'warning' && ( {itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} /> <WarningMessage text={itemForDisplay.text} />
@@ -11,21 +11,28 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps { interface InfoMessageProps {
text: string; text: string;
icon?: string;
color?: string;
} }
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => { export const InfoMessage: React.FC<InfoMessageProps> = ({
const prefix = ' '; text,
icon,
color,
}) => {
color ??= theme.status.warning;
const prefix = icon ?? ' ';
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
return ( return (
<Box flexDirection="row" marginTop={1}> <Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}> <Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text> <Text color={color}>{prefix}</Text>
</Box> </Box>
<Box flexGrow={1} flexDirection="column"> <Box flexGrow={1} flexDirection="column">
{text.split('\n').map((line, index) => ( {text.split('\n').map((line, index) => (
<Text wrap="wrap" key={index}> <Text wrap="wrap" key={index}>
<RenderInline text={line} defaultColor={theme.status.warning} /> <RenderInline text={line} defaultColor={color} />
</Text> </Text>
))} ))}
</Box> </Box>
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
extensionUpdatesReducer,
type ExtensionUpdatesState,
ExtensionUpdateState,
} from './extensions.js';
describe('extensionUpdatesReducer', () => {
it('should handle RESTARTED action', () => {
const initialState: ExtensionUpdatesState = {
extensionStatuses: new Map([
[
'ext1',
{
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
lastUpdateTime: 0,
lastUpdateCheck: 0,
notified: true,
},
],
]),
batchChecksInProgress: 0,
scheduledUpdate: null,
};
const action = {
type: 'RESTARTED' as const,
payload: { name: 'ext1' },
};
const newState = extensionUpdatesReducer(initialState, action);
const expectedStatus = {
status: ExtensionUpdateState.UPDATED,
lastUpdateTime: 0,
lastUpdateCheck: 0,
notified: true,
};
expect(newState.extensionStatuses.get('ext1')).toEqual(expectedStatus);
});
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
const initialState: ExtensionUpdatesState = {
extensionStatuses: new Map([
[
'ext1',
{
status: ExtensionUpdateState.UPDATED,
lastUpdateTime: 0,
lastUpdateCheck: 0,
notified: true,
},
],
]),
batchChecksInProgress: 0,
scheduledUpdate: null,
};
const action = {
type: 'RESTARTED' as const,
payload: { name: 'ext1' },
};
const newState = extensionUpdatesReducer(initialState, action);
expect(newState).toEqual(initialState);
});
});
+16 -1
View File
@@ -63,7 +63,8 @@ export type ExtensionUpdateAction =
| { type: 'BATCH_CHECK_START' } | { type: 'BATCH_CHECK_START' }
| { type: 'BATCH_CHECK_END' } | { type: 'BATCH_CHECK_END' }
| { type: 'SCHEDULE_UPDATE'; payload: ScheduleUpdateArgs } | { type: 'SCHEDULE_UPDATE'; payload: ScheduleUpdateArgs }
| { type: 'CLEAR_SCHEDULED_UPDATE' }; | { type: 'CLEAR_SCHEDULED_UPDATE' }
| { type: 'RESTARTED'; payload: { name: string } };
export function extensionUpdatesReducer( export function extensionUpdatesReducer(
state: ExtensionUpdatesState, state: ExtensionUpdatesState,
@@ -125,6 +126,20 @@ export function extensionUpdatesReducer(
...state, ...state,
scheduledUpdate: null, scheduledUpdate: null,
}; };
case 'RESTARTED': {
const existing = state.extensionStatuses.get(action.payload.name);
if (existing?.status !== ExtensionUpdateState.UPDATED_NEEDS_RESTART) {
return state;
}
const newStatuses = new Map(state.extensionStatuses);
newStatuses.set(action.payload.name, {
...existing,
status: ExtensionUpdateState.UPDATED,
});
return { ...state, extensionStatuses: newStatuses };
}
default: default:
checkExhaustive(action); checkExhaustive(action);
} }
+2
View File
@@ -103,6 +103,8 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemInfo = HistoryItemBase & { export type HistoryItemInfo = HistoryItemBase & {
type: 'info'; type: 'info';
text: string; text: string;
icon?: string;
color?: string;
}; };
export type HistoryItemError = HistoryItemBase & { export type HistoryItemError = HistoryItemBase & {
@@ -169,4 +169,27 @@ describe('SimpleExtensionLoader', () => {
}, },
); );
}); });
describe('restartExtension', () => {
it('should stop and then start the extension', async () => {
const loader = new TestingSimpleExtensionLoader([activeExtension]);
vi.spyOn(loader, 'stopExtension');
vi.spyOn(loader, 'startExtension');
await loader.start(mockConfig);
await loader.restartExtension(activeExtension);
expect(loader.stopExtension).toHaveBeenCalledWith(activeExtension);
expect(loader.startExtension).toHaveBeenCalledWith(activeExtension);
});
});
}); });
// Adding these overrides allows us to access the protected members.
class TestingSimpleExtensionLoader extends SimpleExtensionLoader {
override async startExtension(extension: GeminiCLIExtension): Promise<void> {
await super.startExtension(extension);
}
override async stopExtension(extension: GeminiCLIExtension): Promise<void> {
await super.stopExtension(extension);
}
}
@@ -200,6 +200,11 @@ export abstract class ExtensionLoader {
} }
return; return;
} }
async restartExtension(extension: GeminiCLIExtension): Promise<void> {
await this.stopExtension(extension);
await this.startExtension(extension);
}
} }
export interface ExtensionEvents { export interface ExtensionEvents {