diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index dc49390c7e..8f065438e2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -710,10 +710,14 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -733,10 +737,14 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8e988917e5..aed7595389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -286,6 +286,11 @@ async function exploreAction( await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, onClose: () => context.ui.removeComponent(), extensionManager, }), @@ -533,7 +538,11 @@ async function installAction( } } -async function linkAction(context: CommandContext, args: string) { +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { @@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) { source: sourceFilepath, type: 'link', }; - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx index 2da019d485..239f728472 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -32,13 +32,20 @@ const mockExtension: RegistryExtension = { licenseKey: 'Apache-2.0', }; +const linkableExtension: RegistryExtension = { + ...mockExtension, + url: '/local/path/to/extension', +}; + describe('ExtensionDetails', () => { let mockOnBack: ReturnType; let mockOnInstall: ReturnType; + let mockOnLink: ReturnType; beforeEach(() => { mockOnBack = vi.fn(); mockOnInstall = vi.fn(); + mockOnLink = vi.fn(); }); const renderDetails = async (isInstalled = false) => @@ -47,6 +54,7 @@ describe('ExtensionDetails', () => { extension={mockExtension} onBack={mockOnBack} onInstall={mockOnInstall} + onLink={mockOnLink} isInstalled={isInstalled} />, ); @@ -117,4 +125,47 @@ describe('ExtensionDetails', () => { expect(mockOnInstall).not.toHaveBeenCalled(); vi.useRealTimers(); }); + + it('should call onLink when "l" is pressed and is linkable', async () => { + const { stdin, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + await React.act(async () => { + stdin.write('l'); + }); + await waitFor(() => { + expect(mockOnLink).toHaveBeenCalled(); + }); + }); + + it('should NOT show "Link" button for GitHub extensions', async () => { + const { lastFrame, waitUntilReady } = await renderDetails(false); + await waitUntilReady(); + await waitFor(() => { + expect(lastFrame()).not.toContain('[L] Link'); + }); + }); + + it('should show "Link" button for local extensions', async () => { + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + await waitFor(() => { + expect(lastFrame()).toContain('[L] Link'); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx index 7ee38c0e54..82a6c42b78 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -19,6 +19,9 @@ export interface ExtensionDetailsProps { onInstall: ( requestConsentOverride: (consent: string) => Promise, ) => void | Promise; + onLink: ( + requestConsentOverride: (consent: string) => Promise, + ) => void | Promise; isInstalled: boolean; } @@ -26,6 +29,7 @@ export function ExtensionDetails({ extension, onBack, onInstall, + onLink, isInstalled, }: ExtensionDetailsProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); @@ -35,6 +39,11 @@ export function ExtensionDetails({ } | null>(null); const [isInstalling, setIsInstalling] = useState(false); + const isLinkable = + !extension.url.startsWith('http') && + !extension.url.startsWith('git@') && + !extension.url.startsWith('sso://'); + useKeypress( (key) => { if (consentRequest) { @@ -56,6 +65,7 @@ export function ExtensionDetails({ onBack(); return true; } + if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) { setIsInstalling(true); void onInstall( @@ -66,6 +76,16 @@ export function ExtensionDetails({ ); return true; } + if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) { + setIsInstalling(true); + void onLink( + (prompt: string) => + new Promise((resolve) => { + setConsentRequest({ prompt, resolve }); + }), + ); + return true; + } return false; }, { isActive: true, priority: true }, @@ -230,8 +250,11 @@ export function ExtensionDetails({ understand the permissions it requires and the actions it may perform. - - [{'Enter'}] Install + + + [{'Enter'}] Install + + {isLinkable && [L] Link} )} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 0539437fc3..60b0deec4a 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -29,6 +29,10 @@ export interface ExtensionRegistryViewProps { extension: RegistryExtension, requestConsentOverride?: (consent: string) => Promise, ) => void | Promise; + onLink?: ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } @@ -39,6 +43,7 @@ interface ExtensionItem extends GenericListItem { export function ExtensionRegistryView({ onSelect, + onLink, onClose, extensionManager, }: ExtensionRegistryViewProps): React.JSX.Element { @@ -96,6 +101,22 @@ export function ExtensionRegistryView({ [onSelect, extensionManager], ); + const handleLink = useCallback( + async ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => { + await onLink?.(extension, requestConsentOverride); + + // Refresh installed extensions list + setInstalledExtensions(extensionManager.getExtensions()); + + // Go back to the search page (list view) + setSelectedExtension(null); + }, + [onLink, extensionManager], + ); + const renderItem = useCallback( (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { const isInstalled = installedExtensions.some( @@ -260,6 +281,9 @@ export function ExtensionRegistryView({ onInstall={async (requestConsentOverride) => { await handleInstall(selectedExtension, requestConsentOverride); }} + onLink={async (requestConsentOverride) => { + await handleLink(selectedExtension, requestConsentOverride); + }} isInstalled={installedExtensions.some( (e) => e.name === selectedExtension.extensionName, )}