diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 56152cd6e1..a655353dce 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -153,6 +153,7 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { 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, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index cc862b6c42..dae31fe765 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -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}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 201915baef..d77371a374 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -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, +) { 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.`, diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx index d2dd0d5720..2e2c075ac5 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -14,24 +14,52 @@ import { theme } from '../../semantic-colors.js'; export interface ExtensionDetailsProps { extension: RegistryExtension; onBack: () => void; - onInstall: () => void; + onInstall: ( + requestConsentOverride: (consent: string) => Promise, + ) => 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 ( + + + {consentRequest.prompt} + + + + [Esc] Cancel + [Enter] Accept + + + ); + } + + if (isInstalling) { + return ( + + + Installing {extension.extensionName}... + + + ); + } + return ( - {extension.hasMCP && ( - - MCP - | - - )} - {extension.hasContext && ( - - Context file - | - - )} - {extension.hasHooks && ( - - Hooks - | - - )} - {extension.hasSkills && ( - - Skills - | - - )} - {extension.hasCustomCommands && ( - - Commands - - )} + {[ + 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) => ( + + {feature.label} + {index < array.length - 1 && ( + + | + + )} + + ))} {/* Details about MCP / Context */} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 7af402802b..d4eca0859d 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -227,7 +227,10 @@ describe('ExtensionRegistryView', () => { }); await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockExtensions[0]); + expect(mockOnSelect).toHaveBeenCalledWith( + mockExtensions[0], + expect.any(Function), + ); }); }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 59a747abad..e4ff751fae 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -26,7 +26,10 @@ import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionDetails } from './ExtensionDetails.js'; interface ExtensionRegistryViewProps { - onSelect?: (extension: RegistryExtension) => void | Promise; + onSelect?: ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => void | Promise; 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, + ) => { + 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({ handleInstall(selectedExtension)} + onInstall={(requestConsentOverride) => + handleInstall(selectedExtension, requestConsentOverride) + } isInstalled={installedExtensions.some( (e) => e.name === selectedExtension.extensionName, )}