Make extensions install work

This commit is contained in:
Christine Betts
2026-03-02 18:13:25 -05:00
parent 13a7cbdee1
commit 1f8d38eb64
6 changed files with 149 additions and 52 deletions

View File

@@ -153,6 +153,7 @@ export class ExtensionManager extends ExtensionLoader {
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> {
if (
this.settings.security?.allowedExtensions &&
@@ -243,7 +244,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?`,
@@ -301,7 +302,7 @@ Would you like to attempt to install via "git clone" instead?`,
await maybeRequestConsentOrFail(
newExtensionConfig,
this.requestConsent,
requestConsentOverride ?? this.requestConsent,
newHasHooks,
previousExtensionConfig,
previousHasHooks,

View File

@@ -560,10 +560,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}"...`,
@@ -585,10 +589,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}`,

View File

@@ -279,8 +279,8 @@ async function exploreAction(
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: async (extension) => {
await installAction(context, extension.url);
onSelect: async (extension, requestConsentOverride) => {
await installAction(context, extension.url, requestConsentOverride);
},
onClose: () => context.ui.removeComponent(),
extensionManager,
@@ -456,7 +456,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(
@@ -503,8 +507,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.`,

View File

@@ -14,24 +14,52 @@ import { theme } from '../../semantic-colors.js';
export interface ExtensionDetailsProps {
extension: RegistryExtension;
onBack: () => void;
onInstall: () => void;
onInstall: (
requestConsentOverride: (consent: string) => Promise<boolean>,
) => void;
isInstalled: boolean;
}
import { useState } from 'react';
export function ExtensionDetails({
extension,
onBack,
onInstall,
isInstalled,
}: ExtensionDetailsProps): React.JSX.Element {
const [consentRequest, setConsentRequest] = useState<{
prompt: string;
resolve: (value: boolean) => void;
} | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
useKeypress(
(key) => {
if (consentRequest) {
if (keyMatchers[Command.ESCAPE](key)) {
consentRequest.resolve(false);
setConsentRequest(null);
setIsInstalling(false);
return true;
}
if (keyMatchers[Command.RETURN](key)) {
consentRequest.resolve(true);
setConsentRequest(null);
return true;
}
return false;
}
if (keyMatchers[Command.ESCAPE](key)) {
onBack();
return true;
}
if (keyMatchers[Command.RETURN](key) && !isInstalled) {
onInstall();
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
setIsInstalling(true);
onInstall((prompt: string) => new Promise((resolve) => {
setConsentRequest({ prompt, resolve });
}));
return true;
}
return false;
@@ -39,6 +67,47 @@ export function ExtensionDetails({
{ isActive: true, priority: true },
);
if (consentRequest) {
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.status.warning}
>
<Box marginBottom={1}>
<Text color={theme.text.primary}>{consentRequest.prompt}</Text>
</Box>
<Box flexGrow={1} />
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
<Text color={theme.text.secondary}>[Esc] Cancel</Text>
<Text color={theme.text.primary}>[Enter] Accept</Text>
</Box>
</Box>
);
}
if (isInstalling) {
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
justifyContent="center"
alignItems="center"
>
<Text color={theme.text.primary}>
Installing {extension.extensionName}...
</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
@@ -83,35 +152,33 @@ export function ExtensionDetails({
{/* Features List */}
<Box flexDirection="row" marginBottom={1}>
{extension.hasMCP && (
<Box marginRight={1}>
<Text color={theme.text.primary}>MCP </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasContext && (
<Box marginRight={1}>
<Text color={theme.status.error}>Context file </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasHooks && (
<Box marginRight={1}>
<Text color={theme.status.warning}>Hooks </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasSkills && (
<Box marginRight={1}>
<Text color={theme.status.success}>Skills </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasCustomCommands && (
<Box marginRight={1}>
<Text color={theme.text.primary}>Commands</Text>
</Box>
)}
{[
extension.hasMCP && { label: 'MCP', color: theme.text.primary },
extension.hasContext && {
label: 'Context file',
color: theme.status.error,
},
extension.hasHooks && { label: 'Hooks', color: theme.status.warning },
extension.hasSkills && {
label: 'Skills',
color: theme.status.success,
},
extension.hasCustomCommands && {
label: 'Commands',
color: theme.text.primary,
},
]
.filter((f): f is { label: string; color: string } => !!f)
.map((feature, index, array) => (
<Box key={feature.label} flexDirection="row">
<Text color={feature.color}>{feature.label} </Text>
{index < array.length - 1 && (
<Box marginRight={1}>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
</Box>
))}
</Box>
{/* Details about MCP / Context */}

View File

@@ -227,7 +227,10 @@ describe('ExtensionRegistryView', () => {
});
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith(mockExtensions[0]);
expect(mockOnSelect).toHaveBeenCalledWith(
mockExtensions[0],
expect.any(Function),
);
});
});
});

View File

@@ -26,7 +26,10 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionDetails } from './ExtensionDetails.js';
interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void | Promise<void>;
onSelect?: (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onClose?: () => void;
extensionManager: ExtensionManager;
}
@@ -76,10 +79,16 @@ export function ExtensionRegistryView({
}, []);
const handleInstall = useCallback(
async (extension: RegistryExtension) => {
await onSelect?.(extension);
async (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => {
await onSelect?.(extension, requestConsentOverride);
// Refresh installed extensions list
setInstalledExtensions(extensionManager.getExtensions());
// Go back to the search page (list view)
setSelectedExtension(null);
},
[onSelect, extensionManager],
@@ -246,7 +255,9 @@ export function ExtensionRegistryView({
<ExtensionDetails
extension={selectedExtension}
onBack={handleBack}
onInstall={() => handleInstall(selectedExtension)}
onInstall={(requestConsentOverride) =>
handleInstall(selectedExtension, requestConsentOverride)
}
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}