Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> {
if (
this.settings.security?.allowedExtensions &&
Expand Down Expand Up @@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader {
(result.failureReason === 'no release data' &&
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await this.requestConsent(
(await (requestConsentOverride ?? this.requestConsent)(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.

Would you like to attempt to install via "git clone" instead?`,
Expand Down Expand Up @@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`,

await maybeRequestConsentOrFail(
newExtensionConfig,
this.requestConsent,
requestConsentOverride ?? this.requestConsent,
newHasHooks,
previousExtensionConfig,
previousHasHooks,
Expand Down
38 changes: 25 additions & 13 deletions packages/cli/src/ui/commands/extensionsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,14 +475,18 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: extension.url });

// Call onSelect
component.props.onSelect?.(extension);
await component.props.onSelect?.(extension);

await waitFor(() => {
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: extension.url,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: extension.url,
type: 'git',
},
undefined,
undefined,
);
});
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);

Expand Down Expand Up @@ -622,10 +626,14 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`,
Expand All @@ -647,10 +655,14 @@ describe('extensionsCommand', () => {

await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
Expand Down
17 changes: 12 additions & 5 deletions packages/cli/src/ui/commands/extensionsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,9 @@ async function exploreAction(
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
onSelect: async (extension, requestConsentOverride) => {
debugLogger.log(`Selected extension: ${extension.extensionName}`);
void installAction(context, extension.url);
await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
Expand Down Expand Up @@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) {
}
}

async function installAction(context: CommandContext, args: string) {
async function installAction(
context: CommandContext,
args: string,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
Expand Down Expand Up @@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) {

try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
const extension = await extensionLoader.installOrUpdateExtension(
installMetadata,
undefined,
requestConsentOverride,
);
context.ui.addItem({
type: MessageType.INFO,
text: `Extension "${extension.name}" installed successfully.`,
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/ui/components/shared/SearchableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
onSearch?: (query: string) => void;
/** Whether to reset selection to the top when items change (e.g. after search) */
resetSelectionOnItemsChange?: boolean;
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
isFocused?: boolean;
}

/**
Expand All @@ -85,6 +87,7 @@ export function SearchableList<T extends GenericListItem>({
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
isFocused = true,
}: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
Expand All @@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems,
onSelect: handleSelectValue,
isFocused: true,
isFocused,
showNumbers: false,
wrapAround: true,
priority: true,
Expand Down Expand Up @@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
}
return false;
},
{ isActive: true },
{ isActive: isFocused },
);

const visibleItems = filteredItems.slice(
Expand Down Expand Up @@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
focus={isFocused}
/>
</Box>
)}
Expand Down
123 changes: 123 additions & 0 deletions packages/cli/src/ui/components/views/ExtensionDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';

const mockExtension: RegistryExtension = {
id: 'ext1',
extensionName: 'Test Extension',
extensionDescription: 'A test extension description',
fullName: 'author/test-extension',
extensionVersion: '1.2.3',
rank: 1,
stars: 123,
url: 'https://github.com/author/test-extension',
repoDescription: 'Repo description',
avatarUrl: '',
lastUpdated: '2023-10-27',
hasMCP: true,
hasContext: true,
hasHooks: true,
hasSkills: true,
hasCustomCommands: true,
isGoogleOwned: true,
licenseKey: 'Apache-2.0',
};

describe('ExtensionDetails', () => {
let mockOnBack: ReturnType<typeof vi.fn>;
let mockOnInstall: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockOnBack = vi.fn();
mockOnInstall = vi.fn();
});

const renderDetails = (isInstalled = false) =>
render(
<KeypressProvider>
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>
</KeypressProvider>,
);

it('should render extension details correctly', async () => {
const { lastFrame } = renderDetails();
await waitFor(() => {
expect(lastFrame()).toContain('Test Extension');
expect(lastFrame()).toContain('v1.2.3');
expect(lastFrame()).toContain('123');
expect(lastFrame()).toContain('[G]');
expect(lastFrame()).toContain('author/test-extension');
expect(lastFrame()).toContain('A test extension description');
expect(lastFrame()).toContain('MCP');
expect(lastFrame()).toContain('Context file');
expect(lastFrame()).toContain('Hooks');
expect(lastFrame()).toContain('Skills');
expect(lastFrame()).toContain('Commands');
});
});

it('should show install prompt when not installed', async () => {
const { lastFrame } = renderDetails(false);
await waitFor(() => {
expect(lastFrame()).toContain('[Enter] Install');
expect(lastFrame()).not.toContain('Already Installed');
});
});

it('should show already installed message when installed', async () => {
const { lastFrame } = renderDetails(true);
await waitFor(() => {
expect(lastFrame()).toContain('Already Installed');
expect(lastFrame()).not.toContain('[Enter] Install');
});
});

it('should call onBack when Escape is pressed', async () => {
const { stdin } = renderDetails();
await React.act(async () => {
stdin.write('\x1b'); // Escape
});
await waitFor(() => {
expect(mockOnBack).toHaveBeenCalled();
});
});

it('should call onInstall when Enter is pressed and not installed', async () => {
const { stdin } = renderDetails(false);
await React.act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalled();
});
});

it('should NOT call onInstall when Enter is pressed and already installed', async () => {
vi.useFakeTimers();
const { stdin } = renderDetails(true);
await React.act(async () => {
stdin.write('\r'); // Enter
});
// Advance timers to trigger the keypress flush
await React.act(async () => {
vi.runAllTimers();
});
expect(mockOnInstall).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
Loading
Loading