Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
119 changes: 119 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,119 @@
/**
* @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 () => {
const { stdin } = renderDetails(true);
await React.act(async () => {
stdin.write('\r'); // Enter
});
// Wait a bit to ensure it's not called
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockOnInstall).not.toHaveBeenCalled();
});
});
Loading
Loading