From 3f7cbda4f1553dc6c85047ae80e7ec6cb542586e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 17:36:47 +0800 Subject: [PATCH 01/48] feat(extensions): multi-tab /extensions dialog (Discover/Installed/Marketplaces) Upgrade the /extensions management dialog from a linear wizard into a multi-tab interactive dialog aligned with Claude Code's /plugin command. UI (packages/cli): - Discover: pull installable plugins from configured marketplaces, multi-select (Space), batch install (i) with Global/Project/Local scope, open homepage, per-plugin details. - Installed: plugins + standalone MCP servers grouped by Favorites/Local/User/Project/Disabled; Space toggles enable/disable, f toggles favorite, Enter opens details with an action menu (toggle/favorite/mark-for-update/update/uninstall). - Marketplaces: add/list/view/remove marketplace sources (owner/repo, SSH, HTTP JSON, local path). - Tabbed shell with Tab/arrow switching and a focus-lock contract so a tab owns Escape while in a sub-view. Core (packages/core): - ExtensionPreferencesStore: favorites + per-extension scope intent. - MarketplaceRegistryStore + discoverPlugins(): persistent marketplace source registry and cross-source discovery. - loadMarketplaceConfigFromSource() in marketplace.ts (GitHub/local/HTTP-JSON). - ExtensionManager methods for marketplaces, discovery, favorites and scopes; preference cleanup on uninstall. Scope mapping: Global -> User; Project/Local -> workspace-scoped enablement (install then re-scope so the choice actually restricts where it is active). The Errors tab is intentionally deferred per the spec. Tests: 19 core unit tests (preferences/registry/discovery) and 11 tabbed dialog integration tests; existing extension suites updated. typecheck, lint and i18n checks pass. --- .../ExtensionsManagerDialog.test.tsx | 360 ++++++++--- .../extensions/ExtensionsManagerDialog.tsx | 603 ++++-------------- .../src/ui/components/extensions/TabBar.tsx | 61 ++ .../ExtensionsManagerDialog.test.tsx.snap | 45 -- .../cli/src/ui/components/extensions/index.ts | 8 +- .../extensions/tabs/DiscoverTab.tsx | 395 ++++++++++++ .../extensions/tabs/InstalledTab.tsx | 567 ++++++++++++++++ .../extensions/tabs/MarketplacesTab.tsx | 333 ++++++++++ .../cli/src/ui/components/extensions/types.ts | 67 +- .../extensions/views/McpDetailView.tsx | 60 ++ .../extensions/views/PluginDetailView.tsx | 143 +++++ .../core/src/extension/extensionManager.ts | 109 +++- .../extension/extensionPreferences.test.ts | 89 +++ .../src/extension/extensionPreferences.ts | 134 ++++ packages/core/src/extension/index.ts | 2 + packages/core/src/extension/marketplace.ts | 78 +++ .../src/extension/marketplaceRegistry.test.ts | 200 ++++++ .../core/src/extension/marketplaceRegistry.ts | 234 +++++++ 18 files changed, 2867 insertions(+), 621 deletions(-) create mode 100644 packages/cli/src/ui/components/extensions/TabBar.tsx delete mode 100644 packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx create mode 100644 packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx create mode 100644 packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx create mode 100644 packages/cli/src/ui/components/extensions/views/McpDetailView.tsx create mode 100644 packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx create mode 100644 packages/core/src/extension/extensionPreferences.test.ts create mode 100644 packages/core/src/extension/extensionPreferences.ts create mode 100644 packages/core/src/extension/marketplaceRegistry.test.ts create mode 100644 packages/core/src/extension/marketplaceRegistry.ts diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 22f11e16b96..00303ea953d 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -5,29 +5,32 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; +import { EXTENSIONS_TABS } from './types.js'; import { UIStateContext } from '../../contexts/UIStateContext.js'; import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { SettingsContext } from '../../contexts/SettingsContext.js'; +import { ShellFocusContext } from '../../contexts/ShellFocusContext.js'; +import { LoadedSettings } from '../../../config/settings.js'; import type { UIState } from '../../contexts/UIStateContext.js'; -import type { Config, Extension } from '@qwen-code/qwen-code-core'; -import { ExtensionUpdateState } from '../../state/extensions.js'; - -const createMockExtension = ( - name: string, - isActive = true, - version = '1.0.0', -): Extension => +import type { + Config, + Extension, + DiscoveredPlugin, + MarketplaceSource, +} from '@qwen-code/qwen-code-core'; +import type { ExtensionUpdateState } from '../../state/extensions.js'; + +const mockExtension = (name: string, isActive = true): Extension => ({ id: name, name, - version, + version: '1.0.0', path: `/home/user/.qwen/extensions/${name}`, isActive, - installMetadata: { - type: 'git', - source: `github:user/${name}`, - }, + installMetadata: { type: 'git', source: `github:user/${name}` }, mcpServers: {}, commands: [], skills: [], @@ -37,117 +40,274 @@ const createMockExtension = ( contextFiles: [], }) as unknown as Extension; -const createMockConfig = (extensions: Extension[] = []): Config => +interface ManagerOverrides { + extensions?: Extension[]; + discovered?: DiscoveredPlugin[]; + marketplaces?: MarketplaceSource[]; + favorites?: string[]; + scopes?: Record; +} + +const createManager = (o: ManagerOverrides = {}) => { + const extensions = o.extensions ?? []; + return { + refreshCache: vi.fn().mockResolvedValue(undefined), + getLoadedExtensions: vi.fn(() => extensions), + getFavorites: vi.fn(() => o.favorites ?? []), + getExtensionScopes: vi.fn(() => o.scopes ?? {}), + getMarketplaces: vi.fn(() => o.marketplaces ?? []), + discoverPlugins: vi.fn().mockResolvedValue(o.discovered ?? []), + toggleFavorite: vi.fn(() => true), + setExtensionScope: vi.fn(), + enableExtension: vi.fn().mockResolvedValue(undefined), + disableExtension: vi.fn().mockResolvedValue(undefined), + uninstallExtension: vi.fn().mockResolvedValue(undefined), + checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined), + updateExtension: vi.fn().mockResolvedValue(undefined), + addMarketplace: vi.fn(), + removeMarketplace: vi.fn(() => true), + loadMarketplace: vi.fn().mockResolvedValue(null), + }; +}; + +const createConfig = (manager: ReturnType): Config => ({ - getExtensions: () => extensions, - getExtensionManager: () => ({ - getLoadedExtensions: () => extensions, - refreshCache: vi.fn().mockResolvedValue(undefined), - checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined), - disableExtension: vi.fn().mockResolvedValue(undefined), - enableExtension: vi.fn().mockResolvedValue(undefined), - uninstallExtension: vi.fn().mockResolvedValue(undefined), - updateExtension: vi.fn().mockResolvedValue(undefined), - }), - getLoadedExtensions: () => extensions, + getExtensionManager: () => manager, + getMcpServers: () => ({}), + getToolRegistry: () => undefined, + isMcpServerDisabled: () => false, + getExcludedMcpServers: () => [], + setExcludedMcpServers: vi.fn(), }) as unknown as Config; -const createMockUIState = ( +const createUIState = ( extensionsUpdateState = new Map(), -): UIState => - ({ - extensionsUpdateState, - }) as unknown as UIState; +): UIState => ({ extensionsUpdateState }) as unknown as UIState; -describe('ExtensionsManagerDialog Snapshots', () => { - const baseProps = { - onClose: vi.fn(), - config: createMockConfig(), - }; +const mockSettings = new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + true, + new Set(), +); + +const renderDialog = ( + config: Config, + opts: { + onClose?: () => void; + initialTab?: (typeof EXTENSIONS_TABS)[keyof typeof EXTENSIONS_TABS]; + uiState?: UIState; + } = {}, +) => + render( + + + + + + + + + , + ); +describe('ExtensionsManagerDialog (tabbed)', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - vi.restoreAllMocks(); + it('renders the tab bar with all three tabs', () => { + const { lastFrame } = renderDialog(createConfig(createManager())); + const frame = lastFrame(); + expect(frame).toContain('Discover'); + expect(frame).toContain('Installed'); + expect(frame).toContain('Marketplaces'); }); - it('should render empty state when no extensions installed', () => { - const uiState = createMockUIState(); - const { lastFrame } = render( - - - - - , + it('shows discovered plugins on the Discover tab', async () => { + const discovered: DiscoveredPlugin[] = [ + { + marketplaceName: 'Skills', + name: 'pdf', + description: 'PDF tools', + installSource: 'anthropics/skills:pdf', + installed: false, + }, + { + marketplaceName: 'Skills', + name: 'docx', + installSource: 'anthropics/skills:docx', + installed: true, + }, + ]; + const { lastFrame } = renderDialog( + createConfig(createManager({ discovered })), ); + await waitFor(() => { + expect(lastFrame()).toContain('pdf'); + }); + expect(lastFrame()).toContain('docx'); + expect(lastFrame()).toContain('installed'); + }); - expect(lastFrame()).toMatchSnapshot(); + it('prompts to add a marketplace when none discovered', async () => { + const { lastFrame } = renderDialog(createConfig(createManager())); + await waitFor(() => { + expect(lastFrame()).toContain('No plugins discovered'); + }); }); - it('should render extension list with extensions', () => { - const extensions = [ - createMockExtension('test-extension', true), - createMockExtension('another-extension', false), - ]; - const uiState = createMockUIState( - new Map([ - ['test-extension', ExtensionUpdateState.UP_TO_DATE], - ['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE], - ]), - ); - const { lastFrame } = render( - - - - - , + it('groups installed plugins by scope on the Installed tab', async () => { + const config = createConfig( + createManager({ + extensions: [ + mockExtension('alpha', true), + mockExtension('beta', false), + ], + scopes: { alpha: 'user' }, + }), ); + const { lastFrame } = renderDialog(config, { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + const frame = lastFrame(); + expect(frame).toContain('User'); + expect(frame).toContain('Disabled'); + expect(frame).toContain('beta'); + }); - expect(lastFrame()).toMatchSnapshot(); + it('toggles favorite when pressing f on the Installed tab', async () => { + const manager = createManager({ + extensions: [mockExtension('alpha', true)], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + stdin.write('f'); + await waitFor(() => { + expect(manager.toggleFavorite).toHaveBeenCalledWith('alpha'); + }); }); - it('should render with update available status', () => { - const extensions = [createMockExtension('outdated-extension', true)]; - const uiState = createMockUIState( - new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]), + it('moves a plugin into the Favorites group after favoriting (regroup/reload)', async () => { + const favorites: string[] = []; + const manager = createManager({ + extensions: [mockExtension('alpha', true), mockExtension('beta', true)], + }); + manager.getFavorites = vi.fn(() => [...favorites]); + manager.toggleFavorite = vi.fn((name: string) => { + const i = favorites.indexOf(name); + if (i >= 0) { + favorites.splice(i, 1); + return false; + } + favorites.push(name); + return true; + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + // Initially there is no Favorites group. + expect(lastFrame()).not.toContain('Favorites'); + stdin.write('f'); // favorite the selected item (alpha, first in the list) + await waitFor(() => { + expect(lastFrame()).toContain('Favorites'); + }); + expect(manager.toggleFavorite).toHaveBeenCalledWith('alpha'); + // The list re-rendered cleanly (no stuck/empty frame) and still shows beta. + expect(lastFrame()).toContain('beta'); + }); + + it('shows the add-marketplace row on the Marketplaces tab', async () => { + const config = createConfig( + createManager({ + marketplaces: [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + ], + }), ); - const { lastFrame } = render( - - - - - , + const { lastFrame } = renderDialog(config, { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('Add new marketplace'); + }); + expect(lastFrame()).toContain('Skills'); + }); + + it('switches tabs with the Tab key', async () => { + const config = createConfig( + createManager({ extensions: [mockExtension('alpha', true)] }), ); + const { stdin, lastFrame } = renderDialog(config); + // Starts on Discover. + await waitFor(() => { + expect(lastFrame()).toContain('No plugins discovered'); + }); + stdin.write('\t'); // -> Installed + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + }); - expect(lastFrame()).toMatchSnapshot(); + it('closes on Escape from a tab root', async () => { + const onClose = vi.fn(); + const { stdin } = renderDialog(createConfig(createManager()), { onClose }); + await waitFor(() => {}); + stdin.write('\x1b'); // escape + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); }); - it('should render with checking status', () => { - const extensions = [createMockExtension('checking-extension', true)]; - const uiState = createMockUIState( - new Map([ - ['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES], - ]), - ); - const { lastFrame } = render( - - - - - , - ); + it('does not close on Escape while a tab sub-view is open', async () => { + const onClose = vi.fn(); + const manager = createManager(); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + onClose, + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('Add new marketplace'); + }); + stdin.write('\r'); // Enter on the add row -> opens the add sub-view (locks tabs) + await waitFor(() => { + expect(lastFrame()).toContain('Add a marketplace source'); + }); + stdin.write('\x1b'); // Escape should return to the list, not close the dialog + await waitFor(() => { + expect(lastFrame()).toContain('Add new marketplace'); + }); + expect(onClose).not.toHaveBeenCalled(); + }); - expect(lastFrame()).toMatchSnapshot(); + it('opens the add-marketplace input view on Enter', async () => { + const { stdin, lastFrame } = renderDialog(createConfig(createManager()), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('Add new marketplace'); + }); + stdin.write('\r'); // Enter on add row + await waitFor(() => { + expect(lastFrame()).toContain('Add a marketplace source'); + }); + // The supported source formats are surfaced to guide the user. + expect(lastFrame()).toContain('owner/repo'); }); }); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index c06f9c85ce7..61da40c43e3 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -4,508 +4,120 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { Box, Text } from 'ink'; -import { - ExtensionListStep, - ExtensionDetailStep, - ActionSelectionStep, - UninstallConfirmStep, - ScopeSelectStep, -} from './steps/index.js'; -import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js'; import { theme } from '../../semantic-colors.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { t } from '../../../i18n/index.js'; -import type { Extension, Config } from '@qwen-code/qwen-code-core'; -import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core'; -import { ExtensionUpdateState } from '../../state/extensions.js'; -import { getErrorMessage } from '../../../utils/errors.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; - -interface ExtensionsManagerDialogProps { - onClose: () => void; - config: Config | null; +import { t } from '../../../i18n/index.js'; +import { + EXTENSIONS_TABS, + type ExtensionsTab, + type ExtensionsTabDef, + type ExtensionsManagerDialogProps, +} from './types.js'; +import { TabBar } from './TabBar.js'; +import { DiscoverTab } from './tabs/DiscoverTab.js'; +import { InstalledTab } from './tabs/InstalledTab.js'; +import { MarketplacesTab } from './tabs/MarketplacesTab.js'; + +export interface StatusMessage { + type: 'info' | 'success' | 'error'; + text: string; } -const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG'); +const TABS: ExtensionsTabDef[] = [ + { id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' }, + { id: EXTENSIONS_TABS.INSTALLED, label: 'Installed' }, + { id: EXTENSIONS_TABS.MARKETPLACES, label: 'Marketplaces' }, +]; + +// Literal t() calls keep the footer hints extractable for translation. +function footerHint(tab: ExtensionsTab): string { + switch (tab) { + case EXTENSIONS_TABS.DISCOVER: + return t( + '↑↓ navigate · Space select · i install · Enter details · Esc close', + ); + case EXTENSIONS_TABS.INSTALLED: + return t( + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', + ); + case EXTENSIONS_TABS.MARKETPLACES: + return t('↑↓ navigate · Enter open · d remove · Esc close'); + default: + return ''; + } +} export function ExtensionsManagerDialog({ onClose, config, + initialTab, }: ExtensionsManagerDialogProps) { const { extensionsUpdateState } = useUIState(); - - const [extensions, setExtensions] = useState([]); - const [selectedExtensionIndex, setSelectedExtensionIndex] = - useState(-1); - const [navigationStack, setNavigationStack] = useState([ - MANAGEMENT_STEPS.EXTENSION_LIST, - ]); - const [updateInProgress, setUpdateInProgress] = useState(false); - const [updateError, setUpdateError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); const { columns } = useTerminalSize(); const boxWidth = columns - 4; - // Load extensions - const loadExtensions = useCallback(async () => { - if (!config) return; - - const extensionManager = config.getExtensionManager(); - if (!extensionManager) { - debugLogger.error('ExtensionManager not available'); - return; - } - - try { - await extensionManager.refreshCache(); - const loadedExtensions = extensionManager.getLoadedExtensions(); - setExtensions(loadedExtensions); - } catch (error) { - debugLogger.error('Failed to load extensions:', error); - } - }, [config]); - - // Initial load - useEffect(() => { - loadExtensions(); - }, [loadExtensions]); - - // Memoized selected extension - const selectedExtension = useMemo( - () => - selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null, - [extensions, selectedExtensionIndex], - ); - - // Check if update is available for selected extension - const hasUpdateAvailable = useMemo(() => { - if (!selectedExtension) return false; - const state = extensionsUpdateState.get(selectedExtension.name); - return state === ExtensionUpdateState.UPDATE_AVAILABLE; - }, [selectedExtension, extensionsUpdateState]); - - // Helper to get current step - const getCurrentStep = useCallback( - () => - navigationStack[navigationStack.length - 1] || - MANAGEMENT_STEPS.EXTENSION_LIST, - [navigationStack], + const [activeTab, setActiveTab] = useState( + initialTab ?? EXTENSIONS_TABS.DISCOVER, ); - - const handleSelectExtension = useCallback((extensionIndex: number) => { - setSelectedExtensionIndex(extensionIndex); - setSuccessMessage(null); // Clear success message when navigating - setErrorMessage(null); // Clear error message when navigating - setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]); + const [tabLocked, setTabLocked] = useState(false); + const [status, setStatus] = useState(null); + // Bumped to force tabs to re-load when a cross-tab change happens + // (e.g. installing from Discover should refresh Installed). + const [reloadSignal, setReloadSignal] = useState(0); + + const cycleTab = useCallback((direction: 1 | -1) => { + setStatus(null); + setActiveTab((current) => { + const index = TABS.findIndex((tab) => tab.id === current); + const next = (index + direction + TABS.length) % TABS.length; + return TABS[next].id; + }); }, []); - const handleNavigateToStep = useCallback((step: string) => { - setNavigationStack((prev) => [...prev, step]); + const bumpReload = useCallback(() => { + setReloadSignal((value) => value + 1); }, []); - const handleNavigateBack = useCallback(() => { - setNavigationStack((prev) => { - if (prev.length <= 1) { - return prev; - } - return prev.slice(0, -1); - }); - // Clear messages when navigating back - setErrorMessage(null); + const handleLockChange = useCallback((locked: boolean) => { + setTabLocked(locked); }, []); - const handleUpdateExtension = useCallback(async () => { - if (!config || !selectedExtension) return; - - setUpdateInProgress(true); - setUpdateError(null); - - try { - const extensionManager = config.getExtensionManager(); - if (!extensionManager) { - throw new Error('ExtensionManager not available'); - } - - const state = extensionsUpdateState.get(selectedExtension.name); - if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { - throw new Error('No update available'); - } - - // Use the extension manager to update - await extensionManager.updateExtension( - selectedExtension, - ExtensionUpdateState.UPDATE_AVAILABLE, - (name, newState) => { - debugLogger.debug(`Update state for ${name}:`, newState); - }, - ); - - // Reload extensions after update to get new version info - await loadExtensions(); - - // Trigger a re-check of update status for all extensions - await extensionManager.checkForAllExtensionUpdates((name, newState) => { - debugLogger.debug(`Recheck update state for ${name}:`, newState); - }); - - // Show success message - setSuccessMessage( - t('Extension "{{name}}" updated successfully.', { - name: selectedExtension.name, - }), - ); - - // Go back to action selection - handleNavigateBack(); - } catch (error) { - debugLogger.error('Failed to update extension:', error); - setUpdateError( - error instanceof Error ? error.message : 'Unknown error occurred', - ); - } finally { - setUpdateInProgress(false); - } - }, [ - config, - selectedExtension, - extensionsUpdateState, - loadExtensions, - handleNavigateBack, - ]); - - const handleActionSelect = useCallback( - (action: ExtensionAction) => { - switch (action) { - case 'view': - handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL); - break; - case 'update': - handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS); - handleUpdateExtension(); - break; - case 'disable': - handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); - break; - case 'enable': - handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT); - break; - case 'uninstall': - handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION); - break; - default: - break; - } - }, - [handleNavigateToStep, handleUpdateExtension], - ); - - // Unified handler for toggling extension state (enable/disable) - const handleToggleExtensionState = useCallback( - async (scope: 'user' | 'workspace', newState: boolean) => { - if (!config || !selectedExtension) return; - - try { - const extensionManager = config.getExtensionManager(); - if (!extensionManager) { - throw new Error('ExtensionManager not available'); - } - - const settingScope = - scope === 'user' ? SettingScope.User : SettingScope.Workspace; - - if (newState) { - await extensionManager.enableExtension( - selectedExtension.name, - settingScope, - ); - } else { - await extensionManager.disableExtension( - selectedExtension.name, - settingScope, - ); - } - - // Update local state - setExtensions((prev) => - prev.map((ext) => - ext.name === selectedExtension.name - ? { ...ext, isActive: newState } - : ext, - ), - ); - - // Show success message - const actionKey = newState ? 'enabled' : 'disabled'; - setSuccessMessage( - t(`Extension "{{name}}" ${actionKey} successfully.`, { - name: selectedExtension.name, - }), - ); - setErrorMessage(null); - - // Go back to extension list to show success message - setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); - } catch (error) { - debugLogger.error( - `Failed to ${newState ? 'enable' : 'disable'} extension:`, - error, - ); - setErrorMessage( - t('Failed to {{action}} extension "{{name}}": {{error}}', { - action: newState ? 'enable' : 'disable', - name: selectedExtension.name, - error: getErrorMessage(error), - }), - ); - setSuccessMessage(null); - } - }, - [config, selectedExtension], - ); - - const handleDisableExtension = useCallback( - async (scope: 'user' | 'workspace') => { - await handleToggleExtensionState(scope, false); - }, - [handleToggleExtensionState], - ); - - const handleEnableExtension = useCallback( - async (scope: 'user' | 'workspace') => { - await handleToggleExtensionState(scope, true); - }, - [handleToggleExtensionState], - ); - - const handleUninstallExtension = useCallback( - async (extension: Extension) => { - if (!config) return; - - try { - const extensionManager = config.getExtensionManager(); - if (!extensionManager) { - throw new Error('ExtensionManager not available'); - } - - await extensionManager.uninstallExtension(extension.name, false); - - // Reload extensions - await loadExtensions(); - - // Navigate back to extension list - setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); - setSelectedExtensionIndex(-1); - } catch (error) { - debugLogger.error('Failed to uninstall extension:', error); - throw error; - } - }, - [config, loadExtensions], - ); - - // Centralized ESC key handling + // Tab switching + close. Inactive while a tab owns a sub-view (locked). useKeypress( (key) => { - if (key.name !== 'escape') { - return; - } - - const currentStep = getCurrentStep(); - // If there's a success message, clear it first instead of closing - if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { - setSuccessMessage(null); - return; - } - if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + if (key.name === 'tab') { + cycleTab(key.shift ? -1 : 1); + } else if (key.name === 'right') { + cycleTab(1); + } else if (key.name === 'left') { + cycleTab(-1); + } else if (key.name === 'escape') { onClose(); - } else { - handleNavigateBack(); } }, - { isActive: true }, + { isActive: !tabLocked }, ); - const renderStepHeader = useCallback(() => { - const currentStep = getCurrentStep(); - const getStepHeaderText = () => { - switch (currentStep) { - case MANAGEMENT_STEPS.EXTENSION_LIST: - return t('Manage Extensions'); - case MANAGEMENT_STEPS.ACTION_SELECTION: - return selectedExtension?.name || t('Choose Action'); - case MANAGEMENT_STEPS.EXTENSION_DETAIL: - return t('Extension Details'); - case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: - return t('Disable Extension'); - case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: - return t('Enable Extension'); - case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: - return t('Uninstall Extension'); - case MANAGEMENT_STEPS.UPDATE_PROGRESS: - return t('Update Extension'); - default: - return t('Unknown Step'); - } - }; - + if (!config) { return ( - - - {getStepHeaderText()} - - - ); - }, [getCurrentStep, selectedExtension]); - - const renderStepFooter = useCallback(() => { - const currentStep = getCurrentStep(); - const getNavigationInstructions = () => { - if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { - if (extensions.length === 0 || successMessage) { - return t('Esc to close'); - } - return t('↑↓ to navigate · Enter to select · Esc to close'); - } - - if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) { - return t('Esc to go back'); - } - - if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) { - return t('Y/Enter to confirm · N/Esc to cancel'); - } - - if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { - return updateInProgress ? t('Updating...') : ''; - } - - return t('↑↓ to navigate · Enter to select · Esc to go back'); - }; - - return ( - - {getNavigationInstructions()} + + + + {t('Extensions are not available in this environment.')} + + ); - }, [getCurrentStep, extensions.length, updateInProgress, successMessage]); - - const renderStepContent = useCallback(() => { - const currentStep = getCurrentStep(); - - // Show error message if present (only on extension list step) - if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { - return ( - - {errorMessage} - - ); - } - - // Show success message if present (only on extension list step) - if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { - return ( - - {successMessage} - - ); - } - - if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { - return ( - - {t('Update failed:')} - {updateError} - - ); - } - - switch (currentStep) { - case MANAGEMENT_STEPS.EXTENSION_LIST: - return ( - - ); - case MANAGEMENT_STEPS.ACTION_SELECTION: - return ( - - ); - case MANAGEMENT_STEPS.EXTENSION_DETAIL: - return ; - case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: - return ( - - ); - case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: - return ( - - ); - case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: - return ( - - ); - case MANAGEMENT_STEPS.UPDATE_PROGRESS: - return ( - - - {updateInProgress - ? t('Updating {{name}}...', { - name: selectedExtension?.name || '', - }) - : t('Update complete!')} - - - ); - default: - return ( - - - {t('Invalid step: {{step}}', { step: currentStep })} - - - ); - } - }, [ - getCurrentStep, - extensions, - extensionsUpdateState, - selectedExtension, - hasUpdateAvailable, - updateInProgress, - updateError, - successMessage, - errorMessage, - handleSelectExtension, - handleNavigateToStep, - handleNavigateBack, - handleActionSelect, - handleDisableExtension, - handleEnableExtension, - handleUninstallExtension, - ]); + } return ( @@ -518,9 +130,56 @@ export function ExtensionsManagerDialog({ width={boxWidth} gap={1} > - {renderStepHeader()} - {renderStepContent()} - {renderStepFooter()} + + + + {activeTab === EXTENSIONS_TABS.DISCOVER && ( + + )} + {activeTab === EXTENSIONS_TABS.INSTALLED && ( + + )} + {activeTab === EXTENSIONS_TABS.MARKETPLACES && ( + + )} + + + {status && ( + + {status.text} + + )} + + {footerHint(activeTab)} ); diff --git a/packages/cli/src/ui/components/extensions/TabBar.tsx b/packages/cli/src/ui/components/extensions/TabBar.tsx new file mode 100644 index 00000000000..99728e5d30f --- /dev/null +++ b/packages/cli/src/ui/components/extensions/TabBar.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { t } from '../../../i18n/index.js'; +import { + EXTENSIONS_TABS, + type ExtensionsTab, + type ExtensionsTabDef, +} from './types.js'; + +interface TabBarProps { + tabs: ExtensionsTabDef[]; + activeTab: ExtensionsTab; + /** When false, the "tab to cycle" hint is dimmed to signal it is locked. */ + canSwitch: boolean; +} + +// Literal t() calls keep the labels extractable for translation. +function tabLabel(id: ExtensionsTab): string { + switch (id) { + case EXTENSIONS_TABS.DISCOVER: + return t('Discover'); + case EXTENSIONS_TABS.INSTALLED: + return t('Installed'); + case EXTENSIONS_TABS.MARKETPLACES: + return t('Marketplaces'); + default: + return id; + } +} + +export const TabBar = ({ tabs, activeTab, canSwitch }: TabBarProps) => ( + + {tabs.map((tab) => { + const isActive = tab.id === activeTab; + return ( + + {isActive ? ( + + {` ${tabLabel(tab.id)} `} + + ) : ( + {` ${tabLabel(tab.id)} `} + )} + + ); + })} + + {t('(Tab / ←→ to switch)')} + + +); diff --git a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap deleted file mode 100644 index e1aeaa43b3f..00000000000 --- a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = ` -"┌──────────────────────────────────────────────────────────────────────────┐ -│ Manage Extensions │ -│ │ -│ No extensions installed. │ -│ Use '/extensions install' to install your first extension. │ -│ │ -│ Esc to close │ -└──────────────────────────────────────────────────────────────────────────┘" -`; - -exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = ` -"┌──────────────────────────────────────────────────────────────────────────┐ -│ Manage Extensions │ -│ │ -│ No extensions installed. │ -│ Use '/extensions install' to install your first extension. │ -│ │ -│ Esc to close │ -└──────────────────────────────────────────────────────────────────────────┘" -`; - -exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = ` -"┌──────────────────────────────────────────────────────────────────────────┐ -│ Manage Extensions │ -│ │ -│ No extensions installed. │ -│ Use '/extensions install' to install your first extension. │ -│ │ -│ Esc to close │ -└──────────────────────────────────────────────────────────────────────────┘" -`; - -exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = ` -"┌──────────────────────────────────────────────────────────────────────────┐ -│ Manage Extensions │ -│ │ -│ No extensions installed. │ -│ Use '/extensions install' to install your first extension. │ -│ │ -│ Esc to close │ -└──────────────────────────────────────────────────────────────────────────┘" -`; diff --git a/packages/cli/src/ui/components/extensions/index.ts b/packages/cli/src/ui/components/extensions/index.ts index e368898afbb..b776edd2c45 100644 --- a/packages/cli/src/ui/components/extensions/index.ts +++ b/packages/cli/src/ui/components/extensions/index.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; +export { + ExtensionsManagerDialog, + type StatusMessage, +} from './ExtensionsManagerDialog.js'; export type { ExtensionsManagerDialogProps } from './types.js'; -export { MANAGEMENT_STEPS } from './types.js'; +export { MANAGEMENT_STEPS, EXTENSIONS_TABS } from './types.js'; +export type { ExtensionsTab } from './types.js'; diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx new file mode 100644 index 00000000000..55799809873 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -0,0 +1,395 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import open from 'open'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../../keyMatchers.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import { + type Config, + type DiscoveredPlugin, + type ExtensionScope, + SettingScope, + parseInstallSource, + redactUrlCredentials, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import { getErrorMessage } from '../../../../utils/errors.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +const debugLogger = createDebugLogger('DISCOVER_TAB'); + +type DiscoverView = 'list' | 'detail' | 'scope-select'; + +interface DiscoverTabProps { + config: Config; + isActive: boolean; + onLockChange: (locked: boolean) => void; + onStatus: (status: StatusMessage | null) => void; + onInstalled: () => void; + reloadSignal: number; +} + +// Built per-render so the literal t() labels stay extractable and localize. +function scopeItems(): Array<{ + key: string; + label: string; + value: ExtensionScope; +}> { + return [ + { key: 'user', label: t('Global (User Scope)'), value: 'user' }, + { + key: 'project', + label: t('Project (All Collaborators)'), + value: 'project', + }, + { key: 'local', label: t('Local (Only You)'), value: 'local' }, + ]; +} + +export const DiscoverTab = ({ + config, + isActive, + onLockChange, + onStatus, + onInstalled, + reloadSignal, +}: DiscoverTabProps) => { + const [plugins, setPlugins] = useState([]); + const [cursor, setCursor] = useState(0); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [view, setView] = useState('list'); + // Where Esc from the scope-select view should return to. + const [scopeReturnView, setScopeReturnView] = useState<'list' | 'detail'>( + 'list', + ); + const [loading, setLoading] = useState(true); + const [installing, setInstalling] = useState(false); + + const extensionManager = config.getExtensionManager(); + + const keyOf = (p: DiscoveredPlugin) => `${p.marketplaceName}/${p.name}`; + + const load = useCallback(async () => { + if (!extensionManager) { + setLoading(false); + return; + } + setLoading(true); + try { + const discovered = await extensionManager.discoverPlugins(); + setPlugins(discovered); + setCursor((prev) => (prev < discovered.length ? prev : 0)); + } catch (error) { + debugLogger.error('Failed to discover plugins:', error); + onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + setLoading(false); + } + }, [extensionManager, onStatus]); + + useEffect(() => { + load(); + }, [load, reloadSignal]); + + const goToList = useCallback(() => { + setView('list'); + onLockChange(false); + }, [onLockChange]); + + const selected = plugins[cursor] ?? null; + + // Plugins queued for installation when the scope is chosen. + const pendingInstall = useCallback((): DiscoveredPlugin[] => { + const chosen = plugins.filter( + (p) => selectedKeys.has(keyOf(p)) && !p.installed, + ); + if (chosen.length > 0) return chosen; + if (selected && !selected.installed) return [selected]; + return []; + }, [plugins, selectedKeys, selected]); + + const beginInstall = useCallback( + (from: 'list' | 'detail') => { + if (pendingInstall().length === 0) { + onStatus({ type: 'info', text: t('No installable plugins selected.') }); + return; + } + setScopeReturnView(from); + setView('scope-select'); + onLockChange(true); + }, + [pendingInstall, onLockChange, onStatus], + ); + + const installWithScope = useCallback( + async (scope: ExtensionScope) => { + if (!extensionManager) return; + const targets = pendingInstall(); + setInstalling(true); + let installed = 0; + const errors: string[] = []; + for (const plugin of targets) { + try { + const metadata = await parseInstallSource(plugin.installSource); + const ext = await extensionManager.installExtension(metadata); + extensionManager.setExtensionScope(ext.name, scope); + // installExtension auto-enables at User (global) scope. For a + // workspace-scoped choice, re-scope enablement to this workspace + // only: disable the global enable and enable for the workspace path. + if (scope !== 'user') { + await extensionManager.disableExtension( + ext.name, + SettingScope.User, + ); + await extensionManager.enableExtension( + ext.name, + SettingScope.Workspace, + ); + } + installed++; + } catch (error) { + errors.push( + `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, + ); + } + } + setInstalling(false); + setSelectedKeys(new Set()); + if (errors.length === 0) { + onStatus({ + type: 'success', + text: t('Installed {{count}} plugin(s).', { + count: String(installed), + }), + }); + } else { + onStatus({ + type: 'error', + text: t('Installed {{ok}}, failed {{fail}}: {{detail}}', { + ok: String(installed), + fail: String(errors.length), + detail: errors.join('; '), + }), + }); + } + await load(); + onInstalled(); + goToList(); + }, + [extensionManager, pendingInstall, onStatus, load, onInstalled, goToList], + ); + + const openHomepage = useCallback( + async (plugin: DiscoveredPlugin) => { + if (!plugin.homepage) { + onStatus({ type: 'info', text: t('No homepage available.') }); + return; + } + if (process.env['NODE_ENV'] === 'test') { + onStatus({ + type: 'info', + text: t('Would open: {{url}}', { url: plugin.homepage }), + }); + return; + } + try { + await open(plugin.homepage); + } catch { + onStatus({ + type: 'error', + text: t('Failed to open {{url}}', { url: plugin.homepage }), + }); + } + }, + [onStatus], + ); + + // List keyboard. + useKeypress( + (key) => { + if (plugins.length === 0) return; + if (keyMatchers[Command.SELECTION_UP](key)) { + setCursor((prev) => (prev > 0 ? prev - 1 : plugins.length - 1)); + } else if (keyMatchers[Command.SELECTION_DOWN](key)) { + setCursor((prev) => (prev < plugins.length - 1 ? prev + 1 : 0)); + } else if (key.name === 'space' || key.sequence === ' ') { + if (!selected || selected.installed) return; + setSelectedKeys((prev) => { + const next = new Set(prev); + const k = keyOf(selected); + if (next.has(k)) next.delete(k); + else next.add(k); + return next; + }); + } else if (key.sequence === 'i' && !key.ctrl && !key.meta) { + beginInstall('list'); + } else if (key.name === 'return') { + if (selected) { + onStatus(null); + setView('detail'); + onLockChange(true); + } + } + }, + { isActive: isActive && view === 'list' }, + ); + + // Detail keyboard (Open homepage / install / back). + useKeypress( + (key) => { + if (key.name === 'escape') { + goToList(); + } else if (key.sequence === 'h' && !key.ctrl && !key.meta) { + if (selected) void openHomepage(selected); + } else if (key.sequence === 'i' && !key.ctrl && !key.meta) { + if (selected && !selected.installed) { + beginInstall('detail'); + } + } + }, + { isActive: isActive && view === 'detail' }, + ); + + // Scope-select escape returns to wherever it was opened from. + useKeypress( + (key) => { + if (key.name === 'escape' && !installing) { + if (scopeReturnView === 'detail') { + setView('detail'); + } else { + goToList(); + } + } + }, + { isActive: isActive && view === 'scope-select' }, + ); + + if (loading) { + return ( + {t('Discovering plugins...')} + ); + } + + if (view === 'scope-select') { + const count = pendingInstall().length; + return ( + + + {t('Install {{count}} plugin(s) to which scope?', { + count: String(count), + })} + + {installing ? ( + {t('Installing...')} + ) : ( + void installWithScope(scope)} + /> + )} + + ); + } + + if (view === 'detail' && selected) { + return ( + + + + {selected.name} + + + {t('from {{marketplace}}', { + marketplace: selected.marketplaceName, + })} + + + {selected.description ? {selected.description} : null} + {selected.version ? ( + + {t('Version: {{v}}', { v: selected.version })} + + ) : null} + {selected.author ? ( + + {t('Author: {{a}}', { a: selected.author })} + + ) : null} + {selected.homepage ? ( + {selected.homepage} + ) : null} + + {selected.installed + ? t('Already installed.') + : t('i to install · h to open homepage · Esc to go back')} + + + ); + } + + if (plugins.length === 0) { + return ( + + {t('No plugins discovered.')} + + {t('Add a marketplace in the Marketplaces tab to discover plugins.')} + + + ); + } + + return ( + + + {t('{{count}} plugin(s) available', { count: String(plugins.length) })} + + + {plugins.map((plugin, index) => { + const isSelected = index === cursor; + const isChecked = selectedKeys.has(keyOf(plugin)); + return ( + + + + {isSelected ? '●' : ' '} + + + + + {plugin.installed ? '[✓]' : isChecked ? '[•]' : '[ ]'} + + + + + {plugin.name} + + + + {plugin.marketplaceName} + {plugin.installed ? ` (${t('installed')})` : ''} + + + ); + })} + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx new file mode 100644 index 00000000000..86558cdd7aa --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -0,0 +1,567 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../../keyMatchers.js'; +import { t } from '../../../../i18n/index.js'; +import { + type Config, + type Extension, + type ExtensionScope, + SettingScope, + getMCPServerStatus, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import { + loadSettings, + SettingScope as CliSettingScope, +} from '../../../../config/settings.js'; +import { getErrorMessage } from '../../../../utils/errors.js'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; +import type { + InstalledItem, + InstalledGroup, + InstalledMcpInfo, +} from '../types.js'; +import { + PluginDetailView, + type PluginDetailAction, +} from '../views/PluginDetailView.js'; +import { McpDetailView } from '../views/McpDetailView.js'; +import { UninstallConfirmStep } from '../steps/UninstallConfirmStep.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +const debugLogger = createDebugLogger('INSTALLED_TAB'); + +const GROUP_ORDER: InstalledGroup[] = [ + 'favorites', + 'local', + 'user', + 'project', + 'disabled', +]; + +// Localized group/scope label. Literal t() calls keep the strings extractable. +const groupLabel = (group: InstalledGroup): string => { + switch (group) { + case 'favorites': + return t('Favorites'); + case 'local': + return t('Local'); + case 'user': + return t('User'); + case 'project': + return t('Project'); + case 'disabled': + return t('Disabled'); + default: + return group; + } +}; + +type InstalledView = + | 'list' + | 'plugin-detail' + | 'mcp-detail' + | 'uninstall-confirm'; + +interface InstalledTabProps { + config: Config; + isActive: boolean; + onLockChange: (locked: boolean) => void; + onStatus: (status: StatusMessage | null) => void; + extensionsUpdateState: Map; + reloadSignal: number; +} + +function groupFor( + isActive: boolean, + isFavorite: boolean, + scope: InstalledGroup | ExtensionScope, +): InstalledGroup { + if (!isActive) return 'disabled'; + if (isFavorite) return 'favorites'; + if (scope === 'project') return 'project'; + if (scope === 'local') return 'local'; + return 'user'; +} + +export const InstalledTab = ({ + config, + isActive, + onLockChange, + onStatus, + extensionsUpdateState, + reloadSignal, +}: InstalledTabProps) => { + const [items, setItems] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [view, setView] = useState('list'); + const [loading, setLoading] = useState(true); + // Tracks the currently-selected item's stable key so that after a reload + // re-sorts the list (e.g. favorite/enable/disable moves an item to another + // group) the cursor — and any open detail view — stays on the SAME item + // rather than whatever now sits at the old index. + const selectedKeyRef = useRef(null); + + const extensionManager = config.getExtensionManager(); + + const load = useCallback(async () => { + if (!extensionManager) { + setLoading(false); + return; + } + setLoading(true); + try { + await extensionManager.refreshCache(); + const extensions = extensionManager.getLoadedExtensions(); + const favorites = new Set(extensionManager.getFavorites()); + const scopes = extensionManager.getExtensionScopes(); + + const pluginItems: InstalledItem[] = extensions.map((ext: Extension) => { + const isFavorite = favorites.has(ext.name); + const scope: ExtensionScope = scopes[ext.name] ?? 'user'; + return { + kind: 'plugin', + key: `plugin:${ext.name}`, + name: ext.name, + extension: ext, + isActive: ext.isActive, + isFavorite, + scope, + group: groupFor(ext.isActive, isFavorite, scope), + }; + }); + + // Standalone MCP servers (those owned by extensions are surfaced as the + // plugin's components, so they are excluded here). + const mcpItems: InstalledItem[] = []; + const mcpServers = config.getMcpServers() ?? {}; + const standaloneNames = Object.keys(mcpServers).filter( + (name) => !mcpServers[name].extensionName, + ); + // Only touch settings/tool registry when there are standalone MCP servers. + const workspaceMcp = standaloneNames.length + ? loadSettings().forScope(CliSettingScope.Workspace).settings.mcpServers + : undefined; + const toolRegistry = standaloneNames.length + ? config.getToolRegistry() + : undefined; + for (const [name, serverConfig] of Object.entries(mcpServers)) { + if (serverConfig.extensionName) continue; + const scope: InstalledMcpInfo['scope'] = workspaceMcp?.[name] + ? 'project' + : 'user'; + const isDisabled = config.isMcpServerDisabled(name); + const isFavorite = favorites.has(name); + const toolCount = + toolRegistry + ?.getAllTools() + .filter( + (tool) => (tool as { serverName?: string }).serverName === name, + ).length ?? 0; + const transport = serverConfig.command + ? 'stdio' + : serverConfig.httpUrl + ? 'http' + : serverConfig.url + ? 'sse' + : 'unknown'; + const mcp: InstalledMcpInfo = { + name, + status: getMCPServerStatus(name), + scope, + isDisabled, + transport, + toolCount, + }; + mcpItems.push({ + kind: 'mcp', + key: `mcp:${name}`, + name, + mcp, + isActive: !isDisabled, + isFavorite, + group: groupFor(!isDisabled, isFavorite, scope), + }); + } + + const all = [...pluginItems, ...mcpItems]; + // Stable sort by group order then name. + all.sort((a, b) => { + const ga = GROUP_ORDER.indexOf(a.group); + const gb = GROUP_ORDER.indexOf(b.group); + if (ga !== gb) return ga - gb; + return a.name.localeCompare(b.name); + }); + setItems(all); + // Re-point the cursor at the same item by key (it may have moved groups). + const prevKey = selectedKeyRef.current; + setSelectedIndex((prev) => { + if (prevKey) { + const idx = all.findIndex((it) => it.key === prevKey); + if (idx >= 0) return idx; + } + return prev < all.length ? prev : 0; + }); + } catch (error) { + debugLogger.error('Failed to load installed items:', error); + } finally { + setLoading(false); + } + }, [config, extensionManager]); + + useEffect(() => { + load(); + }, [load, reloadSignal]); + + const selectedItem = items[selectedIndex] ?? null; + + // Keep the stable-key ref in sync with the current selection. + useEffect(() => { + selectedKeyRef.current = selectedItem?.key ?? null; + }, [selectedItem]); + + const goToList = useCallback(() => { + setView('list'); + onLockChange(false); + }, [onLockChange]); + + const enterDetail = useCallback( + (item: InstalledItem) => { + onStatus(null); + setView(item.kind === 'plugin' ? 'plugin-detail' : 'mcp-detail'); + onLockChange(true); + }, + [onLockChange, onStatus], + ); + + const togglePlugin = useCallback( + async (item: Extract) => { + if (!extensionManager) return; + const scope = + item.scope === 'user' ? SettingScope.User : SettingScope.Workspace; + try { + if (item.isActive) { + await extensionManager.disableExtension(item.name, scope); + } else { + await extensionManager.enableExtension(item.name, scope); + } + onStatus({ + type: 'success', + text: t('"{{name}}" {{state}}.', { + name: item.name, + state: item.isActive ? t('disabled') : t('enabled'), + }), + }); + await load(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, + [extensionManager, load, onStatus], + ); + + const toggleMcp = useCallback( + async (item: Extract) => { + const toolRegistry = config.getToolRegistry(); + try { + const settings = loadSettings(); + const targetScope = + item.mcp.scope === 'project' + ? CliSettingScope.Workspace + : CliSettingScope.User; + if (item.isActive) { + // Disable: add to excluded + disconnect. + const excluded = + settings.forScope(targetScope).settings.mcp?.excluded ?? []; + if (!excluded.includes(item.name)) { + settings.setValue(targetScope, 'mcp.excluded', [ + ...excluded, + item.name, + ]); + } + await toolRegistry?.disableMcpServer(item.name); + } else { + // Enable: remove from excluded in both scopes + rediscover. + for (const scope of [ + CliSettingScope.User, + CliSettingScope.Workspace, + ]) { + const excluded = + settings.forScope(scope).settings.mcp?.excluded ?? []; + if (excluded.includes(item.name)) { + settings.setValue( + scope, + 'mcp.excluded', + excluded.filter((n: string) => n !== item.name), + ); + } + } + const runtimeExcluded = config.getExcludedMcpServers() ?? []; + config.setExcludedMcpServers( + runtimeExcluded.filter((n) => n !== item.name), + ); + await toolRegistry?.discoverToolsForServer(item.name); + } + onStatus({ + type: 'success', + text: t('MCP "{{name}}" {{state}}.', { + name: item.name, + state: item.isActive ? t('disabled') : t('enabled'), + }), + }); + await load(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, + [config, load, onStatus], + ); + + const toggleFavorite = useCallback( + async (item: InstalledItem) => { + if (!extensionManager) return; + const nowFavorite = extensionManager.toggleFavorite(item.name); + onStatus({ + type: 'info', + text: nowFavorite + ? t('Added "{{name}}" to favorites.', { name: item.name }) + : t('Removed "{{name}}" from favorites.', { name: item.name }), + }); + await load(); + }, + [extensionManager, load, onStatus], + ); + + const handlePluginAction = useCallback( + async (action: PluginDetailAction) => { + if (!selectedItem || selectedItem.kind !== 'plugin' || !extensionManager) + return; + const item = selectedItem; + switch (action) { + case 'toggle': + await togglePlugin(item); + goToList(); + break; + case 'favorite': + await toggleFavorite(item); + break; + case 'mark-update': + try { + await extensionManager.checkForAllExtensionUpdates(() => {}); + onStatus({ + type: 'info', + text: t('Checked "{{name}}" for updates.', { name: item.name }), + }); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + break; + case 'update': + try { + await extensionManager.updateExtension( + item.extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + onStatus({ + type: 'success', + text: t('Updated "{{name}}".', { name: item.name }), + }); + await load(); + goToList(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + break; + case 'uninstall': + setView('uninstall-confirm'); + break; + default: + break; + } + }, + [ + selectedItem, + extensionManager, + togglePlugin, + toggleFavorite, + onStatus, + load, + goToList, + ], + ); + + const handleUninstall = useCallback( + async (extension: Extension) => { + if (!extensionManager) return; + try { + await extensionManager.uninstallExtension(extension.name, false); + onStatus({ + type: 'success', + text: t('Uninstalled "{{name}}".', { name: extension.name }), + }); + await load(); + goToList(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + goToList(); + } + }, + [extensionManager, load, onStatus, goToList], + ); + + // List keyboard handling. + useKeypress( + (key) => { + if (items.length === 0) return; + if (keyMatchers[Command.SELECTION_UP](key)) { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); + } else if (keyMatchers[Command.SELECTION_DOWN](key)) { + setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); + } else if (key.name === 'return') { + if (selectedItem) enterDetail(selectedItem); + } else if (key.name === 'space' || key.sequence === ' ') { + if (!selectedItem) return; + if (selectedItem.kind === 'plugin') { + void togglePlugin(selectedItem); + } else { + void toggleMcp(selectedItem); + } + } else if (key.sequence === 'f' && !key.ctrl && !key.meta) { + if (selectedItem) void toggleFavorite(selectedItem); + } + }, + { isActive: isActive && view === 'list' }, + ); + + // Escape handling for sub-views (the container handles Escape on the list). + useKeypress( + (key) => { + if (key.name === 'escape') { + goToList(); + } + }, + { isActive: isActive && view === 'mcp-detail' }, + ); + useKeypress( + (key) => { + if (key.name === 'escape') { + goToList(); + } + }, + { isActive: isActive && view === 'plugin-detail' }, + ); + + const hasUpdateAvailable = useMemo(() => { + if (!selectedItem || selectedItem.kind !== 'plugin') return false; + return ( + extensionsUpdateState.get(selectedItem.name) === + ExtensionUpdateState.UPDATE_AVAILABLE + ); + }, [selectedItem, extensionsUpdateState]); + + if (loading) { + return {t('Loading...')}; + } + + if (view === 'plugin-detail' && selectedItem?.kind === 'plugin') { + return ( + + ); + } + + if (view === 'mcp-detail' && selectedItem?.kind === 'mcp') { + return ; + } + + if (view === 'uninstall-confirm' && selectedItem?.kind === 'plugin') { + return ( + setView('plugin-detail')} + /> + ); + } + + if (items.length === 0) { + return ( + + + {t('No plugins or MCP servers installed.')} + + + {t('Use the Discover tab to find and install plugins.')} + + + ); + } + + // Grouped list rendering. + const groups = GROUP_ORDER.map((group) => ({ + group, + rows: items.filter((it) => it.group === group), + })).filter((g) => g.rows.length > 0); + + return ( + + {groups.map(({ group, rows }) => ( + + + {groupLabel(group)} ({rows.length}) + + {rows.map((item) => { + const globalIndex = items.indexOf(item); + const isSelected = globalIndex === selectedIndex; + const marker = isSelected ? '●' : ' '; + const kindBadge = + item.kind === 'mcp' ? t('MCP') : `v${item.extension.version}`; + const statusColor = item.isActive + ? theme.status.success + : theme.text.secondary; + return ( + + + + {marker} + + + + + {item.name} + + {item.isFavorite ? ( + + ) : null} + + {kindBadge} + + ({item.isActive ? t('active') : t('disabled')}) + + + ); + })} + + ))} + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx new file mode 100644 index 00000000000..dfe5ab970e6 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../../keyMatchers.js'; +import { TextInput } from '../../shared/TextInput.js'; +import { t } from '../../../../i18n/index.js'; +import { + type Config, + type MarketplaceSource, + type ClaudeMarketplaceConfig, + redactUrlCredentials, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import { getErrorMessage } from '../../../../utils/errors.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +const debugLogger = createDebugLogger('MARKETPLACES_TAB'); + +type MarketplacesView = 'list' | 'add' | 'detail' | 'remove-confirm'; + +interface MarketplacesTabProps { + config: Config; + isActive: boolean; + onLockChange: (locked: boolean) => void; + onStatus: (status: StatusMessage | null) => void; + onChanged: () => void; + reloadSignal: number; +} + +export const MarketplacesTab = ({ + config, + isActive, + onLockChange, + onStatus, + onChanged, + reloadSignal, +}: MarketplacesTabProps) => { + const [sources, setSources] = useState([]); + // selectedIndex: 0 = "add" row, 1..n = sources. + const [selectedIndex, setSelectedIndex] = useState(0); + const [view, setView] = useState('list'); + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [detailConfig, setDetailConfig] = + useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const extensionManager = config.getExtensionManager(); + + const load = useCallback(() => { + if (!extensionManager) return; + const list = extensionManager.getMarketplaces(); + setSources(list); + setSelectedIndex((prev) => (prev <= list.length ? prev : 0)); + }, [extensionManager]); + + useEffect(() => { + load(); + }, [load, reloadSignal]); + + const goToList = useCallback(() => { + setView('list'); + setInput(''); + setDetailConfig(null); + onLockChange(false); + }, [onLockChange]); + + const selectedSource = + selectedIndex >= 1 ? (sources[selectedIndex - 1] ?? null) : null; + + const submitAdd = useCallback(async () => { + if (!extensionManager || !input.trim()) return; + setBusy(true); + try { + const entry = await extensionManager.addMarketplace(input.trim()); + onStatus({ + type: 'success', + text: t('Added marketplace "{{name}}".', { name: entry.name }), + }); + load(); + onChanged(); + goToList(); + } catch (error) { + onStatus({ + type: 'error', + text: redactUrlCredentials(getErrorMessage(error)), + }); + } finally { + setBusy(false); + } + }, [extensionManager, input, onStatus, load, onChanged, goToList]); + + const openDetail = useCallback( + async (source: MarketplaceSource) => { + onStatus(null); + setView('detail'); + onLockChange(true); + setDetailLoading(true); + setDetailConfig(null); + try { + const cfg = await extensionManager?.loadMarketplace(source.source); + setDetailConfig(cfg ?? null); + } catch (error) { + debugLogger.error('Failed to load marketplace detail:', error); + } finally { + setDetailLoading(false); + } + }, + [extensionManager, onLockChange, onStatus], + ); + + const removeSelected = useCallback(() => { + if (!extensionManager || !selectedSource) return; + const removed = extensionManager.removeMarketplace(selectedSource.name); + if (removed) { + onStatus({ + type: 'success', + text: t('Removed marketplace "{{name}}".', { + name: selectedSource.name, + }), + }); + load(); + onChanged(); + } + goToList(); + }, [extensionManager, selectedSource, onStatus, load, onChanged, goToList]); + + // List keyboard. + useKeypress( + (key) => { + const itemCount = sources.length + 1; // +1 for the add row + if (keyMatchers[Command.SELECTION_UP](key)) { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + } else if (keyMatchers[Command.SELECTION_DOWN](key)) { + setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + } else if (key.name === 'return') { + if (selectedIndex === 0) { + onStatus(null); + setView('add'); + onLockChange(true); + } else if (selectedSource) { + void openDetail(selectedSource); + } + } else if ( + (key.sequence === 'd' || key.sequence === 'x') && + !key.ctrl && + !key.meta + ) { + if (selectedSource) { + setView('remove-confirm'); + onLockChange(true); + } + } + }, + { isActive: isActive && view === 'list' }, + ); + + // Add input escape. + useKeypress( + (key) => { + if (key.name === 'escape' && !busy) { + goToList(); + } + }, + { isActive: isActive && view === 'add' }, + ); + + // Detail escape. + useKeypress( + (key) => { + if (key.name === 'escape') { + goToList(); + } else if ( + (key.sequence === 'd' || key.sequence === 'x') && + !key.ctrl && + !key.meta + ) { + setView('remove-confirm'); + } + }, + { isActive: isActive && view === 'detail' }, + ); + + // Remove confirm. + useKeypress( + (key) => { + if (key.name === 'return' || key.sequence === 'y') { + removeSelected(); + } else if (key.name === 'escape' || key.sequence === 'n') { + goToList(); + } + }, + { isActive: isActive && view === 'remove-confirm' }, + ); + + if (view === 'add') { + return ( + + {t('Add a marketplace source:')} + + {t( + 'Formats: owner/repo · git@github.com:owner/repo.git · https://host/marketplace.json · ./local/path', + )} + + {busy ? ( + {t('Adding...')} + ) : ( + void submitAdd()} + placeholder="owner/repo" + isActive={isActive} + /> + )} + + ); + } + + if (view === 'detail' && selectedSource) { + return ( + + + + {selectedSource.name} + + + {redactUrlCredentials(selectedSource.source)} ({selectedSource.type} + ) + + + {detailLoading ? ( + {t('Loading...')} + ) : detailConfig ? ( + + + {t('{{count}} plugin(s):', { + count: String(detailConfig.plugins?.length ?? 0), + })} + + + {(detailConfig.plugins ?? []).slice(0, 15).map((p) => ( + + - {p.name} + {p.description ? `: ${p.description}` : ''} + + ))} + + + ) : ( + + {t('Could not load this marketplace.')} + + )} + + {t('d to remove · Esc to go back')} + + + ); + } + + if (view === 'remove-confirm' && selectedSource) { + return ( + + + {t('Remove marketplace "{{name}}"?', { name: selectedSource.name })} + + + {t('Y/Enter to confirm · N/Esc to cancel')} + + + ); + } + + // List view. + return ( + + + + + {selectedIndex === 0 ? '●' : ' '} + + + + {t('+ Add new marketplace')} + + + {sources.length === 0 ? ( + + + {t('No marketplaces added yet.')} + + + ) : ( + + {sources.map((source, index) => { + const isSelected = selectedIndex === index + 1; + return ( + + + + {isSelected ? '●' : ' '} + + + + + {source.name} + + + + {redactUrlCredentials(source.source)} ({source.type}) + + + ); + })} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts index 09a8426bd8f..e74f231d579 100644 --- a/packages/cli/src/ui/components/extensions/types.ts +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -4,7 +4,71 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Extension, Config } from '@qwen-code/qwen-code-core'; +import type { + Extension, + Config, + ExtensionScope, +} from '@qwen-code/qwen-code-core'; + +/** + * Top-level tabs of the extensions manager dialog, aligned with the Claude Code + * `/plugin` command. The Errors tab is intentionally deferred per the spec. + */ +export const EXTENSIONS_TABS = { + DISCOVER: 'discover', + INSTALLED: 'installed', + MARKETPLACES: 'marketplaces', +} as const; + +export type ExtensionsTab = + (typeof EXTENSIONS_TABS)[keyof typeof EXTENSIONS_TABS]; + +export interface ExtensionsTabDef { + id: ExtensionsTab; + label: string; +} + +/** + * Scope groups used to organize the Installed tab, mirroring Claude Code. + */ +export type InstalledGroup = + | 'favorites' + | 'local' + | 'user' + | 'project' + | 'disabled'; + +/** Minimal display info for an MCP server shown in the Installed tab. */ +export interface InstalledMcpInfo { + name: string; + status: string; + scope: 'user' | 'project' | 'extension'; + isDisabled: boolean; + transport: string; + toolCount: number; +} + +/** A single row in the Installed tab — either a plugin/extension or an MCP. */ +export type InstalledItem = + | { + kind: 'plugin'; + key: string; + name: string; + extension: Extension; + isActive: boolean; + isFavorite: boolean; + scope: ExtensionScope; + group: InstalledGroup; + } + | { + kind: 'mcp'; + key: string; + name: string; + mcp: InstalledMcpInfo; + isActive: boolean; + isFavorite: boolean; + group: InstalledGroup; + }; /** * Management steps for the extensions manager dialog. @@ -86,4 +150,5 @@ export type ExtensionAction = export interface ExtensionsManagerDialogProps { onClose: () => void; config: Config | null; + initialTab?: ExtensionsTab; } diff --git a/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx b/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx new file mode 100644 index 00000000000..3ecb95a7261 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { t } from '../../../../i18n/index.js'; +import type { InstalledMcpInfo } from '../types.js'; + +interface McpDetailViewProps { + mcp: InstalledMcpInfo; +} + +const LABEL_WIDTH = 14; + +const InfoRow = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + + {label} + + + {children} + + +); + +export const McpDetailView = ({ mcp }: McpDetailViewProps) => { + const statusColor = mcp.isDisabled + ? theme.text.secondary + : mcp.status === 'connected' + ? theme.status.success + : mcp.status === 'connecting' + ? theme.status.warning + : theme.status.error; + + return ( + + + {mcp.name} + {t('MCP Server')} + {mcp.scope} + {mcp.transport} + + + {mcp.isDisabled ? t('disabled') : mcp.status} + + + {String(mcp.toolCount)} + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx new file mode 100644 index 00000000000..c9660d2c248 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { + redactUrlCredentials, + type Extension, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; + +export type PluginDetailAction = + | 'toggle' + | 'favorite' + | 'mark-update' + | 'update' + | 'uninstall'; + +interface PluginDetailViewProps { + extension: Extension; + scope: string; + isFavorite: boolean; + hasUpdateAvailable: boolean; + isFocused: boolean; + onAction: (action: PluginDetailAction) => void; +} + +const LABEL_WIDTH = 14; + +const InfoRow = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + + {label} + + + {children} + + +); + +function componentSummary(ext: Extension): string { + const parts: string[] = []; + const mcpCount = ext.mcpServers ? Object.keys(ext.mcpServers).length : 0; + if (mcpCount) parts.push(t('{{count}} MCP', { count: String(mcpCount) })); + if (ext.skills?.length) + parts.push(t('{{count}} Skills', { count: String(ext.skills.length) })); + if (ext.commands?.length) + parts.push(t('{{count}} Commands', { count: String(ext.commands.length) })); + if (ext.agents?.length) + parts.push(t('{{count}} Agents', { count: String(ext.agents.length) })); + return parts.length ? parts.join(' · ') : t('None'); +} + +export const PluginDetailView = ({ + extension, + scope, + isFavorite, + hasUpdateAvailable, + isFocused, + onAction, +}: PluginDetailViewProps) => { + const ext = extension; + const isActive = ext.isActive; + + const actions = useMemo(() => { + const items: Array<{ + key: string; + label: string; + value: PluginDetailAction; + }> = [ + { + key: 'toggle', + label: isActive ? t('Disable') : t('Enable'), + value: 'toggle', + }, + { + key: 'favorite', + label: isFavorite ? t('Remove from Favorites') : t('Add to Favorites'), + value: 'favorite', + }, + { + key: 'mark-update', + label: t('Mark for Update'), + value: 'mark-update', + }, + ...(hasUpdateAvailable + ? [{ key: 'update', label: t('Update Now'), value: 'update' as const }] + : []), + { + key: 'uninstall', + label: t('Uninstall'), + value: 'uninstall', + }, + ]; + return items; + }, [isActive, isFavorite, hasUpdateAvailable]); + + return ( + + + {ext.name} + {ext.version} + {scope} + + + {isActive ? t('active') : t('disabled')} + + {isFavorite ? : null} + + {ext.installMetadata && ( + + {redactUrlCredentials(ext.installMetadata.source)} + + )} + {componentSummary(ext)} + + + + {t('Actions')} + + + {t('Note: Uninstall permanently removes this plugin.')} + + + + ); +}; diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index d691534c532..0f4737a7f55 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -47,6 +47,18 @@ import { downloadFromNpmRegistry } from './npm.js'; import { redactUrlCredentials } from './redaction.js'; import type { LoadExtensionContext } from './variableSchema.js'; import { Override, type AllExtensionsEnablementConfig } from './override.js'; +import { + ExtensionPreferencesStore, + type ExtensionScope, +} from './extensionPreferences.js'; +import { + MarketplaceRegistryStore, + discoverPlugins, + parseMarketplaceSourceType, + type MarketplaceSource, + type DiscoveredPlugin, +} from './marketplaceRegistry.js'; +import { loadMarketplaceConfigFromSource } from './marketplace.js'; import { isGeminiExtensionConfig, convertGeminiExtensionPackage, @@ -304,6 +316,8 @@ export class ExtensionManager { private readonly configFilePath: string; private readonly enabledExtensionNamesOverride: string[]; private readonly workspaceDir: string; + private readonly preferencesStore: ExtensionPreferencesStore; + private readonly marketplaceRegistryStore: MarketplaceRegistryStore; private config?: Config; private telemetrySettings?: TelemetrySettings; @@ -324,6 +338,12 @@ export class ExtensionManager { this.configDir, 'extension-enablement.json', ); + this.preferencesStore = new ExtensionPreferencesStore( + path.join(this.configDir, 'extension-preferences.json'), + ); + this.marketplaceRegistryStore = new MarketplaceRegistryStore( + path.join(this.configDir, 'marketplaces.json'), + ); this.requestSetting = options.requestSetting; this.requestChoicePlugin = options.requestChoicePlugin || (() => Promise.resolve('')); @@ -483,6 +503,89 @@ export class ExtensionManager { } } + // ========================================================================== + // Favorites & scope preferences (Installed view grouping) + // ========================================================================== + + isFavorite(name: string): boolean { + return this.preferencesStore.isFavorite(name); + } + + getFavorites(): string[] { + return this.preferencesStore.getFavorites(); + } + + /** Toggles favorite state for an extension/MCP server; returns new state. */ + toggleFavorite(name: string): boolean { + return this.preferencesStore.toggleFavorite(name); + } + + getExtensionScope(name: string): ExtensionScope | undefined { + return this.preferencesStore.getScope(name); + } + + getExtensionScopes(): Record { + return this.preferencesStore.getScopes(); + } + + setExtensionScope(name: string, scope: ExtensionScope): void { + this.preferencesStore.setScope(name, scope); + } + + // ========================================================================== + // Marketplace registry & discovery + // ========================================================================== + + getMarketplaces(): MarketplaceSource[] { + return this.marketplaceRegistryStore.read(); + } + + /** + * Adds a marketplace source. Loads the marketplace config to resolve a + * human-readable name (falling back to the raw source). Throws if no + * marketplace config can be resolved from the source. + */ + async addMarketplace(source: string): Promise { + const trimmed = source.trim(); + if (!trimmed) { + throw new Error('Marketplace source cannot be empty.'); + } + const config = await loadMarketplaceConfigFromSource(trimmed); + if (!config) { + throw new Error( + `No marketplace found at "${redactUrlCredentials(trimmed)}". ` + + `Expected a .claude-plugin/marketplace.json.`, + ); + } + const entry: MarketplaceSource = { + name: config.name || trimmed, + source: trimmed, + type: parseMarketplaceSourceType(trimmed), + addedAt: new Date().toISOString(), + }; + this.marketplaceRegistryStore.add(entry); + return entry; + } + + removeMarketplace(name: string): boolean { + return this.marketplaceRegistryStore.remove(name); + } + + loadMarketplace(source: string): Promise { + return loadMarketplaceConfigFromSource(source); + } + + /** + * Discovers all installable plugins across configured marketplaces, marking + * which are already installed. + */ + async discoverPlugins(): Promise { + const installedNames = new Set( + this.getLoadedExtensions().map((ext) => ext.name), + ); + return discoverPlugins(this.getMarketplaces(), installedNames); + } + private enableByPath( extensionName: string, includeSubdirs: boolean, @@ -1110,7 +1213,10 @@ export class ExtensionManager { 'success', ), ); - this.enableExtension(newExtensionConfig.name, SettingScope.User); + await this.enableExtension( + newExtensionConfig.name, + SettingScope.User, + ); } } finally { if (tempDir) { @@ -1212,6 +1318,7 @@ export class ExtensionManager { if (isUpdate) return; this.removeEnablementConfig(extension.name); + this.preferencesStore.clear(extension.name); await this.refreshTools(); logExtensionUninstall( diff --git a/packages/core/src/extension/extensionPreferences.test.ts b/packages/core/src/extension/extensionPreferences.test.ts new file mode 100644 index 00000000000..220973f3a02 --- /dev/null +++ b/packages/core/src/extension/extensionPreferences.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ExtensionPreferencesStore } from './extensionPreferences.js'; + +describe('ExtensionPreferencesStore', () => { + let tmpDir: string; + let filePath: string; + let store: ExtensionPreferencesStore; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-prefs-')); + filePath = path.join(tmpDir, 'nested', 'extension-preferences.json'); + store = new ExtensionPreferencesStore(filePath); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns empty defaults when the file does not exist', () => { + expect(store.getFavorites()).toEqual([]); + expect(store.getScopes()).toEqual({}); + expect(store.isFavorite('foo')).toBe(false); + expect(store.getScope('foo')).toBeUndefined(); + }); + + it('toggles favorites on and off and persists them', () => { + expect(store.toggleFavorite('alpha')).toBe(true); + expect(store.isFavorite('alpha')).toBe(true); + expect(store.getFavorites()).toEqual(['alpha']); + + // A fresh store reading the same file sees the persisted state. + const reopened = new ExtensionPreferencesStore(filePath); + expect(reopened.isFavorite('alpha')).toBe(true); + + expect(store.toggleFavorite('alpha')).toBe(false); + expect(store.isFavorite('alpha')).toBe(false); + expect(store.getFavorites()).toEqual([]); + }); + + it('records and reads per-extension scope intent', () => { + store.setScope('alpha', 'project'); + store.setScope('beta', 'local'); + expect(store.getScope('alpha')).toBe('project'); + expect(store.getScope('beta')).toBe('local'); + expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'local' }); + }); + + it('clears all preference state for an extension', () => { + store.toggleFavorite('alpha'); + store.setScope('alpha', 'user'); + store.toggleFavorite('beta'); + + store.clear('alpha'); + + expect(store.isFavorite('alpha')).toBe(false); + expect(store.getScope('alpha')).toBeUndefined(); + // Unrelated entries are untouched. + expect(store.isFavorite('beta')).toBe(true); + }); + + it('does not leak favorites between fresh stores via a shared default array', () => { + // Toggling a favorite on a store whose file does not exist must not + // mutate a shared module-level default, polluting other instances. + const otherFile = path.join(tmpDir, 'other', 'extension-preferences.json'); + const a = new ExtensionPreferencesStore(filePath); + const b = new ExtensionPreferencesStore(otherFile); + a.toggleFavorite('alpha'); + expect(b.getFavorites()).toEqual([]); + }); + + it('recovers from a corrupted preferences file', () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{ not valid json'); + expect(store.getFavorites()).toEqual([]); + expect(store.getScopes()).toEqual({}); + // And can still write afterwards. + expect(store.toggleFavorite('alpha')).toBe(true); + expect(store.isFavorite('alpha')).toBe(true); + }); +}); diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts new file mode 100644 index 00000000000..bb07dbd767a --- /dev/null +++ b/packages/core/src/extension/extensionPreferences.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('EXT_PREFERENCES'); + +/** + * Install/visibility scope intent recorded for an extension. This mirrors the + * Claude Code plugin scopes so the Installed view can group extensions the way + * the user installed them: + * - `user` -> Global (User Scope), available everywhere. + * - `project` -> Project (All Collaborators), enabled for the current workspace. + * - `local` -> Local (Only You), enabled for the current workspace privately. + * + * Enable/disable state itself still lives in `extension-enablement.json`; this + * value only records *where the user chose to install* an extension so the UI + * can render the same grouping Claude Code does. + */ +export type ExtensionScope = 'user' | 'project' | 'local'; + +export interface ExtensionPreferences { + /** Names of extensions/MCP servers the user has favorited. */ + favorites: string[]; + /** Per-extension scope intent, keyed by extension name. */ + scopes: Record; +} + +/** Always returns fresh containers so callers can safely mutate the result. */ +function emptyPreferences(): ExtensionPreferences { + return { favorites: [], scopes: {} }; +} + +/** + * Persists user preferences for extensions (favorites, scope intent) that are + * orthogonal to the enable/disable enablement config. Backed by a single JSON + * file so it is cheap to read/write and easy to reason about. + */ +export class ExtensionPreferencesStore { + constructor(private readonly filePath: string) {} + + read(): ExtensionPreferences { + try { + const content = fs.readFileSync(this.filePath, 'utf-8'); + const parsed = JSON.parse(content) as Partial; + return { + favorites: Array.isArray(parsed.favorites) ? parsed.favorites : [], + scopes: + parsed.scopes && typeof parsed.scopes === 'object' + ? parsed.scopes + : {}, + }; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return emptyPreferences(); + } + debugLogger.error('Error reading extension preferences:', error); + return emptyPreferences(); + } + } + + private write(prefs: ExtensionPreferences): void { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + atomicWriteFileSync(this.filePath, JSON.stringify(prefs, null, 2)); + } + + isFavorite(name: string): boolean { + return this.read().favorites.includes(name); + } + + getFavorites(): string[] { + return this.read().favorites; + } + + /** + * Toggles the favorite state for an item and returns the new state. + */ + toggleFavorite(name: string): boolean { + const prefs = this.read(); + const index = prefs.favorites.indexOf(name); + let nowFavorite: boolean; + if (index >= 0) { + prefs.favorites.splice(index, 1); + nowFavorite = false; + } else { + prefs.favorites.push(name); + nowFavorite = true; + } + this.write(prefs); + return nowFavorite; + } + + getScope(name: string): ExtensionScope | undefined { + return this.read().scopes[name]; + } + + getScopes(): Record { + return this.read().scopes; + } + + setScope(name: string, scope: ExtensionScope): void { + const prefs = this.read(); + prefs.scopes[name] = scope; + this.write(prefs); + } + + /** Removes all preference state for an extension (used on uninstall). */ + clear(name: string): void { + const prefs = this.read(); + const favIndex = prefs.favorites.indexOf(name); + let changed = false; + if (favIndex >= 0) { + prefs.favorites.splice(favIndex, 1); + changed = true; + } + if (prefs.scopes[name]) { + delete prefs.scopes[name]; + changed = true; + } + if (changed) { + this.write(prefs); + } + } +} diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index f33fc307699..4d4729bcd3d 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -3,6 +3,8 @@ export * from './variables.js'; export * from './github.js'; export * from './extensionSettings.js'; export * from './marketplace.js'; +export * from './marketplaceRegistry.js'; +export * from './extensionPreferences.js'; export * from './npm.js'; export * from './claude-converter.js'; export * from './redaction.js'; diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index fd42422b081..f0febf22c36 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -205,6 +205,84 @@ async function readLocalMarketplaceConfig( } } +/** + * Loads a Claude-format marketplace config (`.claude-plugin/marketplace.json`) + * from any supported source string, without installing anything. Used by the + * marketplace registry / Discover view to enumerate installable plugins. + * + * Supported sources: + * - Local directory containing `.claude-plugin/marketplace.json` + * - Local path directly to a `marketplace.json` file + * - `owner/repo`, `https://github.com/owner/repo`, `git@github.com:owner/repo.git` + * - Arbitrary `https://host/.../marketplace.json` returning the JSON document + * + * Returns `null` when no marketplace config can be resolved. + */ +export async function loadMarketplaceConfigFromSource( + source: string, +): Promise { + const trimmed = source.trim(); + + // Priority 1: local path (directory with .claude-plugin/marketplace.json, + // or a direct marketplace.json file). + try { + const stats = await stat(trimmed); + if (stats.isDirectory()) { + return await readLocalMarketplaceConfig(trimmed); + } + if (stats.isFile()) { + try { + const content = await fs.promises.readFile(trimmed, 'utf-8'); + return JSON.parse(content) as ClaudeMarketplaceConfig; + } catch { + return null; + } + } + } catch { + // Not a local path; continue. + } + + // Priority 2: http(s) URL — try GitHub repo first, then a direct JSON doc. + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + try { + const { owner, repo } = parseGitHubRepoForReleases(trimmed); + const ghConfig = await fetchGitHubMarketplaceConfig(owner, repo); + if (ghConfig) { + return ghConfig; + } + } catch { + // Not a github.com repo URL — fall through to direct-JSON fetch. + } + const content = await fetchUrl(trimmed, { 'User-Agent': 'qwen-code' }); + if (!content) { + return null; + } + try { + return JSON.parse(content) as ClaudeMarketplaceConfig; + } catch { + return null; + } + } + + // Priority 3: ssh/sso git URLs -> resolve owner/repo via github. + if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) { + try { + const { owner, repo } = parseGitHubRepoForReleases(trimmed); + return await fetchGitHubMarketplaceConfig(owner, repo); + } catch { + return null; + } + } + + // Priority 4: owner/repo shorthand. + if (isOwnerRepoFormat(trimmed)) { + const [owner, repo] = trimmed.split('/'); + return await fetchGitHubMarketplaceConfig(owner, repo); + } + + return null; +} + export async function parseInstallSource( source: string, ): Promise { diff --git a/packages/core/src/extension/marketplaceRegistry.test.ts b/packages/core/src/extension/marketplaceRegistry.test.ts new file mode 100644 index 00000000000..d3b423dc826 --- /dev/null +++ b/packages/core/src/extension/marketplaceRegistry.test.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + MarketplaceRegistryStore, + parseMarketplaceSourceType, + discoverPlugins, + type MarketplaceSource, +} from './marketplaceRegistry.js'; +import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import type { ClaudeMarketplaceConfig } from './claude-converter.js'; + +vi.mock('./marketplace.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadMarketplaceConfigFromSource: vi.fn(), + }; +}); + +describe('parseMarketplaceSourceType', () => { + it.each([ + ['anthropics/skills', 'github'], + ['https://github.com/owner/repo', 'github'], + ['git@github.com:owner/repo.git', 'git'], + ['sso://team/repo', 'git'], + ['https://example.com/marketplace.json', 'http'], + ['./local/marketplace', 'local'], + ['/abs/path/marketplace', 'local'], + ] as const)('classifies %s as %s', (input, expected) => { + expect(parseMarketplaceSourceType(input)).toBe(expected); + }); +}); + +describe('MarketplaceRegistryStore', () => { + let tmpDir: string; + let filePath: string; + let store: MarketplaceRegistryStore; + + const make = (name: string, source: string): MarketplaceSource => ({ + name, + source, + type: 'github', + }); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-reg-')); + filePath = path.join(tmpDir, 'nested', 'marketplaces.json'); + store = new MarketplaceRegistryStore(filePath); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns an empty list when no file exists', () => { + expect(store.read()).toEqual([]); + }); + + it('adds and persists sources', () => { + store.add(make('Skills', 'anthropics/skills')); + expect(store.read()).toHaveLength(1); + + const reopened = new MarketplaceRegistryStore(filePath); + expect(reopened.read()[0].name).toBe('Skills'); + }); + + it('replaces an entry with the same name or source instead of duplicating', () => { + store.add(make('Skills', 'anthropics/skills')); + store.add(make('Skills', 'anthropics/skills-v2')); + store.add(make('Other', 'anthropics/skills-v2')); + const all = store.read(); + expect(all).toHaveLength(1); + expect(all[0].name).toBe('Other'); + expect(all[0].source).toBe('anthropics/skills-v2'); + }); + + it('removes by name', () => { + store.add(make('A', 'a/a')); + store.add(make('B', 'b/b')); + expect(store.remove('A')).toBe(true); + expect(store.read().map((s) => s.name)).toEqual(['B']); + expect(store.remove('missing')).toBe(false); + }); +}); + +describe('discoverPlugins', () => { + beforeEach(() => { + vi.mocked(loadMarketplaceConfigFromSource).mockReset(); + }); + + const config = ( + name: string, + plugins: ClaudeMarketplaceConfig['plugins'], + ): ClaudeMarketplaceConfig => ({ + name, + owner: { name: 'o', email: 'e' }, + plugins, + }); + + it('flattens plugins across marketplaces and marks installed ones', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( + async (source: string) => { + if (source === 'anthropics/skills') { + return config('Skills', [ + { + name: 'pdf', + version: '1.0.0', + description: 'PDF tools', + homepage: 'https://example.com/pdf', + source: 'anthropics/skills', + }, + { + name: 'docx', + version: '1.0.0', + source: 'anthropics/skills', + }, + ]); + } + return config('Other', [ + { name: 'xlsx', version: '2.0.0', source: 'me/other' }, + ]); + }, + ); + + const sources: MarketplaceSource[] = [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + { name: 'Other', source: 'me/other', type: 'github' }, + ]; + + const discovered = await discoverPlugins(sources, new Set(['docx'])); + + expect(discovered).toHaveLength(3); + const pdf = discovered.find((p) => p.name === 'pdf')!; + expect(pdf.installed).toBe(false); + expect(pdf.homepage).toBe('https://example.com/pdf'); + expect(pdf.installSource).toBe('anthropics/skills:pdf'); + expect(discovered.find((p) => p.name === 'docx')!.installed).toBe(true); + }); + + it('derives install source from per-plugin source for http marketplaces', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [ + { + name: 'gh-plugin', + version: '1.0.0', + source: { source: 'github', repo: 'someone/repo' }, + }, + { + name: 'url-plugin', + version: '1.0.0', + source: { source: 'url', url: 'https://example.com/p.tgz' }, + }, + ]), + ); + + const discovered = await discoverPlugins( + [{ name: 'Remote', source: 'https://x/m.json', type: 'http' }], + new Set(), + ); + + expect(discovered.find((p) => p.name === 'gh-plugin')!.installSource).toBe( + 'someone/repo:gh-plugin', + ); + expect(discovered.find((p) => p.name === 'url-plugin')!.installSource).toBe( + 'https://example.com/p.tgz', + ); + }); + + it('skips marketplaces that fail to load without throwing', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( + async (source: string) => { + if (source === 'good/repo') { + return config('Good', [ + { name: 'ok', version: '1.0.0', source: 'good/repo' }, + ]); + } + throw new Error('network down'); + }, + ); + + const discovered = await discoverPlugins( + [ + { name: 'Bad', source: 'bad/repo', type: 'github' }, + { name: 'Good', source: 'good/repo', type: 'github' }, + ], + new Set(), + ); + + expect(discovered).toHaveLength(1); + expect(discovered[0].name).toBe('ok'); + }); +}); diff --git a/packages/core/src/extension/marketplaceRegistry.ts b/packages/core/src/extension/marketplaceRegistry.ts new file mode 100644 index 00000000000..e58bcadd4a7 --- /dev/null +++ b/packages/core/src/extension/marketplaceRegistry.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { redactUrlCredentials } from './redaction.js'; +import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import type { + ClaudeMarketplaceConfig, + ClaudeMarketplacePluginConfig, +} from './claude-converter.js'; + +const debugLogger = createDebugLogger('MARKETPLACE_REGISTRY'); + +export type MarketplaceSourceType = 'github' | 'git' | 'http' | 'local'; + +/** + * A persisted marketplace source the user has added (Marketplaces tab). + */ +export interface MarketplaceSource { + /** Display name (from the marketplace config `name`, or derived). */ + name: string; + /** Original input string used to add the source. */ + source: string; + type: MarketplaceSourceType; + /** ISO timestamp recorded when the source was added. */ + addedAt?: string; +} + +/** + * A single installable plugin surfaced by the Discover view. + */ +export interface DiscoveredPlugin { + /** Name of the marketplace this plugin came from. */ + marketplaceName: string; + name: string; + description?: string; + version?: string; + author?: string; + homepage?: string; + category?: string; + /** Source string suitable for `parseInstallSource`. */ + installSource: string; + /** Whether an extension with this name is already installed. */ + installed: boolean; +} + +/** + * Classifies a marketplace source string into a {@link MarketplaceSourceType} + * using format heuristics only (no network / filesystem access required for a + * confident answer, beyond an optional existence check the caller may do). + */ +export function parseMarketplaceSourceType( + source: string, +): MarketplaceSourceType { + const trimmed = source.trim(); + if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) { + return 'git'; + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return isGitHubHost(trimmed) ? 'github' : 'http'; + } + if (isOwnerRepoShorthand(trimmed)) { + return 'github'; + } + return 'local'; +} + +function isGitHubHost(url: string): boolean { + try { + return new URL(url).hostname === 'github.com'; + } catch { + return false; + } +} + +function isOwnerRepoShorthand(source: string): boolean { + return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(source); +} + +/** + * Builds the install-source string fed to `parseInstallSource` for a discovered + * plugin. For repo/local marketplaces this is `:`, + * which the existing installer resolves against the marketplace's + * `marketplace.json`. For direct-JSON (`http`) marketplaces it is derived from + * the per-plugin `source` field. + */ +function resolveInstallSource( + marketplace: MarketplaceSource, + plugin: ClaudeMarketplacePluginConfig, +): string { + if (marketplace.type !== 'http') { + return `${marketplace.source}:${plugin.name}`; + } + const src = plugin.source; + if (typeof src === 'string') { + return src.includes(':') ? src : `${src}:${plugin.name}`; + } + if (src && src.source === 'github') { + return `${src.repo}:${plugin.name}`; + } + if (src && src.source === 'url') { + return src.url; + } + return plugin.name; +} + +function pluginsFromConfig( + marketplace: MarketplaceSource, + config: ClaudeMarketplaceConfig, + installedNames: ReadonlySet, +): DiscoveredPlugin[] { + return (config.plugins ?? []).map((plugin) => ({ + marketplaceName: config.name || marketplace.name, + name: plugin.name, + description: plugin.description, + version: plugin.version, + author: plugin.author?.name, + homepage: plugin.homepage, + category: plugin.category, + installSource: resolveInstallSource(marketplace, plugin), + installed: installedNames.has(plugin.name), + })); +} + +/** + * Persists the list of marketplace sources the user has added. + */ +export class MarketplaceRegistryStore { + constructor(private readonly filePath: string) {} + + read(): MarketplaceSource[] { + try { + const content = fs.readFileSync(this.filePath, 'utf-8'); + const parsed = JSON.parse(content); + return Array.isArray(parsed) ? (parsed as MarketplaceSource[]) : []; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return []; + } + debugLogger.error('Error reading marketplace registry:', error); + return []; + } + } + + private write(sources: MarketplaceSource[]): void { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + atomicWriteFileSync(this.filePath, JSON.stringify(sources, null, 2)); + } + + /** + * Adds (or replaces, when name/source matches) a marketplace source. + */ + add(source: MarketplaceSource): void { + const sources = this.read().filter( + (existing) => + existing.name !== source.name && existing.source !== source.source, + ); + sources.push(source); + this.write(sources); + } + + /** Removes a marketplace by name. Returns true if anything was removed. */ + remove(name: string): boolean { + const sources = this.read(); + const next = sources.filter((s) => s.name !== name); + if (next.length === sources.length) { + return false; + } + this.write(next); + return true; + } +} + +/** + * Loads each configured marketplace and flattens their plugin lists into a + * single de-duplicated {@link DiscoveredPlugin} array, tagging which entries are + * already installed. Marketplaces that fail to load are skipped (and logged) so + * one bad source does not break discovery. + */ +export async function discoverPlugins( + marketplaces: readonly MarketplaceSource[], + installedNames: ReadonlySet, +): Promise { + const results = await Promise.all( + marketplaces.map(async (marketplace) => { + try { + const config = await loadMarketplaceConfigFromSource( + marketplace.source, + ); + if (!config) { + debugLogger.debug( + `No marketplace config resolved for ${redactUrlCredentials( + marketplace.source, + )}`, + ); + return []; + } + return pluginsFromConfig(marketplace, config, installedNames); + } catch (error) { + debugLogger.error( + `Failed to discover plugins from ${redactUrlCredentials( + marketplace.source, + )}:`, + error, + ); + return []; + } + }), + ); + + // De-duplicate by `${marketplaceName}/${pluginName}` to keep distinct plugins + // that happen to share a name across different marketplaces. + const seen = new Set(); + const deduped: DiscoveredPlugin[] = []; + for (const plugin of results.flat()) { + const key = `${plugin.marketplaceName}/${plugin.name}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(plugin); + } + return deduped; +} From a63e37addd3f6f32d74ae081fa51b47d6f468a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 17:59:16 +0800 Subject: [PATCH 02/48] feat(extensions): align Discover plugin detail with Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Discover tab's Enter detail view to match Claude Code's "Plugin details" page in both layout and interaction: - Layout: "Plugin details" header, title, "from ", last updated / version, description, "By: ", a "Will install:" component summary (Skills/Commands/Agents/MCP servers), and a trust warning. - Interaction: the scope choice is now an inline action selector on the detail page (Install for you / for all collaborators / in this repo only / Open homepage / Back to plugin list), selected with Enter — replacing the previous i/h shortcuts and the separate scope step. - Footer shows "Enter to select · Esc to go back" while a tab sub-view is open. Core: DiscoveredPlugin now carries declared `components` and a best-effort `lastUpdated`, surfaced by discoverPlugins(). Adds a core test for component/lastUpdated extraction and a UI test for the detail layout + inline selector. --- .../ExtensionsManagerDialog.test.tsx | 36 ++++ .../extensions/ExtensionsManagerDialog.tsx | 6 +- .../extensions/tabs/DiscoverTab.tsx | 182 +++++++++++++----- .../src/extension/marketplaceRegistry.test.ts | 26 +++ .../core/src/extension/marketplaceRegistry.ts | 51 +++++ 5 files changed, 252 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 00303ea953d..6e8c46cbee9 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -156,6 +156,42 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('installed'); }); + it('opens a CC-style plugin detail with an inline scope selector on Enter', async () => { + const discovered: DiscoveredPlugin[] = [ + { + marketplaceName: 'claude-plugins-official', + name: '42crunch-api-security-testing', + description: 'Automate API security directly in your workflow.', + author: '42Crunch', + homepage: 'https://example.com/42crunch', + components: { skills: ['42crunch-audit', '42crunch-scan'] }, + installSource: 'owner/repo:42crunch-api-security-testing', + installed: false, + }, + ]; + const { stdin, lastFrame } = renderDialog( + createConfig(createManager({ discovered })), + ); + await waitFor(() => { + expect(lastFrame()).toContain('42crunch-api-security-testing'); + }); + stdin.write('\r'); // Enter -> detail + await waitFor(() => { + expect(lastFrame()).toContain('Plugin details'); + }); + const frame = lastFrame(); + expect(frame).toContain('from claude-plugins-official'); + expect(frame).toContain('By: 42Crunch'); + expect(frame).toContain('Will install:'); + expect(frame).toContain('42crunch-audit'); + // Inline action selector with the three CC scopes + homepage + back. + expect(frame).toContain('Install for you (user scope)'); + expect(frame).toContain('project scope'); + expect(frame).toContain('local scope'); + expect(frame).toContain('Open homepage'); + expect(frame).toContain('Back to plugin list'); + }); + it('prompts to add a marketplace when none discovered', async () => { const { lastFrame } = renderDialog(createConfig(createManager())); await waitFor(() => { diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 61da40c43e3..0b5fe8ea5ac 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -179,7 +179,11 @@ export function ExtensionsManagerDialog({ )} - {footerHint(activeTab)} + + {tabLocked + ? t('Enter to select · Esc to go back') + : footerHint(activeTab)} + ); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 55799809873..3fb26613a21 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -66,10 +66,6 @@ export const DiscoverTab = ({ const [cursor, setCursor] = useState(0); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [view, setView] = useState('list'); - // Where Esc from the scope-select view should return to. - const [scopeReturnView, setScopeReturnView] = useState<'list' | 'detail'>( - 'list', - ); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); @@ -116,23 +112,18 @@ export const DiscoverTab = ({ return []; }, [plugins, selectedKeys, selected]); - const beginInstall = useCallback( - (from: 'list' | 'detail') => { - if (pendingInstall().length === 0) { - onStatus({ type: 'info', text: t('No installable plugins selected.') }); - return; - } - setScopeReturnView(from); - setView('scope-select'); - onLockChange(true); - }, - [pendingInstall, onLockChange, onStatus], - ); + const beginInstall = useCallback(() => { + if (pendingInstall().length === 0) { + onStatus({ type: 'info', text: t('No installable plugins selected.') }); + return; + } + setView('scope-select'); + onLockChange(true); + }, [pendingInstall, onLockChange, onStatus]); - const installWithScope = useCallback( - async (scope: ExtensionScope) => { - if (!extensionManager) return; - const targets = pendingInstall(); + const runInstall = useCallback( + async (targets: DiscoveredPlugin[], scope: ExtensionScope) => { + if (!extensionManager || targets.length === 0) return; setInstalling(true); let installed = 0; const errors: string[] = []; @@ -184,7 +175,12 @@ export const DiscoverTab = ({ onInstalled(); goToList(); }, - [extensionManager, pendingInstall, onStatus, load, onInstalled, goToList], + [extensionManager, onStatus, load, onInstalled, goToList], + ); + + const installWithScope = useCallback( + (scope: ExtensionScope) => void runInstall(pendingInstall(), scope), + [runInstall, pendingInstall], ); const openHomepage = useCallback( @@ -212,6 +208,60 @@ export const DiscoverTab = ({ [onStatus], ); + // Inline action selector on the detail page (mirrors Claude Code). + type DetailAction = ExtensionScope | 'homepage' | 'back'; + const handleDetailAction = useCallback( + (action: DetailAction) => { + if (action === 'back') { + goToList(); + } else if (action === 'homepage') { + if (selected) void openHomepage(selected); + } else if (selected) { + void runInstall([selected], action); + } + }, + [selected, goToList, openHomepage, runInstall], + ); + + const detailActionItems = useCallback(() => { + const items: Array<{ key: string; label: string; value: DetailAction }> = + []; + if (selected && !selected.installed) { + items.push( + { + key: 'user', + label: t('Install for you (user scope)'), + value: 'user', + }, + { + key: 'project', + label: t( + 'Install for all collaborators on this repository (project scope)', + ), + value: 'project', + }, + { + key: 'local', + label: t('Install for you, in this repo only (local scope)'), + value: 'local', + }, + ); + } + if (selected?.homepage) { + items.push({ + key: 'homepage', + label: t('Open homepage'), + value: 'homepage', + }); + } + items.push({ + key: 'back', + label: t('Back to plugin list'), + value: 'back', + }); + return items; + }, [selected]); + // List keyboard. useKeypress( (key) => { @@ -230,7 +280,7 @@ export const DiscoverTab = ({ return next; }); } else if (key.sequence === 'i' && !key.ctrl && !key.meta) { - beginInstall('list'); + beginInstall(); } else if (key.name === 'return') { if (selected) { onStatus(null); @@ -242,31 +292,21 @@ export const DiscoverTab = ({ { isActive: isActive && view === 'list' }, ); - // Detail keyboard (Open homepage / install / back). + // Detail: Escape goes back; the action selector (RadioButtonSelect) owns Enter. useKeypress( (key) => { if (key.name === 'escape') { goToList(); - } else if (key.sequence === 'h' && !key.ctrl && !key.meta) { - if (selected) void openHomepage(selected); - } else if (key.sequence === 'i' && !key.ctrl && !key.meta) { - if (selected && !selected.installed) { - beginInstall('detail'); - } } }, { isActive: isActive && view === 'detail' }, ); - // Scope-select escape returns to wherever it was opened from. + // Scope-select (batch install from the list) escape returns to the list. useKeypress( (key) => { if (key.name === 'escape' && !installing) { - if (scopeReturnView === 'detail') { - setView('detail'); - } else { - goToList(); - } + goToList(); } }, { isActive: isActive && view === 'scope-select' }, @@ -302,10 +342,28 @@ export const DiscoverTab = ({ } if (view === 'detail' && selected) { + const comps = selected.components; + const componentLines: Array<{ label: string; names: string[] }> = []; + if (comps?.skills?.length) + componentLines.push({ label: t('Skills'), names: comps.skills }); + if (comps?.commands?.length) + componentLines.push({ label: t('Commands'), names: comps.commands }); + if (comps?.agents?.length) + componentLines.push({ label: t('Agents'), names: comps.agents }); + if (comps?.mcpServers?.length) + componentLines.push({ + label: t('MCP servers'), + names: comps.mcpServers, + }); + return ( + + {t('Plugin details')} + + - + {selected.name} @@ -313,26 +371,54 @@ export const DiscoverTab = ({ marketplace: selected.marketplaceName, })} + {selected.lastUpdated ? ( + + {t('Last updated: {{date}}', { date: selected.lastUpdated })} + + ) : selected.version ? ( + + {t('Version: {{v}}', { v: selected.version })} + + ) : null} + {selected.description ? {selected.description} : null} - {selected.version ? ( - - {t('Version: {{v}}', { v: selected.version })} - - ) : null} + {selected.author ? ( - {t('Author: {{a}}', { a: selected.author })} + {t('By: {{a}}', { a: selected.author })} ) : null} - {selected.homepage ? ( - {selected.homepage} + + {componentLines.length > 0 ? ( + + + {t('Will install:')} + + {componentLines.map((line) => ( + + {`· ${line.label}: ${line.names.join(', ')}`} + + ))} + ) : null} - - {selected.installed - ? t('Already installed.') - : t('i to install · h to open homepage · Esc to go back')} + + + {t( + '⚠ Make sure you trust a plugin before installing, updating, or using it. We cannot verify what MCP servers, files, or other software a plugin includes, or that it works as intended. See the plugin homepage for more information.', + )} + + {installing ? ( + {t('Installing...')} + ) : ( + + )} ); } diff --git a/packages/core/src/extension/marketplaceRegistry.test.ts b/packages/core/src/extension/marketplaceRegistry.test.ts index d3b423dc826..ee136d7ac69 100644 --- a/packages/core/src/extension/marketplaceRegistry.test.ts +++ b/packages/core/src/extension/marketplaceRegistry.test.ts @@ -145,6 +145,32 @@ describe('discoverPlugins', () => { expect(discovered.find((p) => p.name === 'docx')!.installed).toBe(true); }); + it('surfaces declared components and lastUpdated for the detail view', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Skills', [ + { + name: 'pdf', + version: '1.0.0', + source: 'anthropics/skills', + skills: ['pdf-audit', 'pdf-scan'], + mcpServers: { 'pdf-server': { command: 'node' } }, + // Arbitrary marketplace metadata field, read best-effort. + lastUpdated: 'Jun 5, 2026', + } as never, + ]), + ); + + const [plugin] = await discoverPlugins( + [{ name: 'Skills', source: 'anthropics/skills', type: 'github' }], + new Set(), + ); + + expect(plugin.components?.skills).toEqual(['pdf-audit', 'pdf-scan']); + expect(plugin.components?.mcpServers).toEqual(['pdf-server']); + expect(plugin.components?.commands).toBeUndefined(); + expect(plugin.lastUpdated).toBe('Jun 5, 2026'); + }); + it('derives install source from per-plugin source for http marketplaces', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( config('Remote', [ diff --git a/packages/core/src/extension/marketplaceRegistry.ts b/packages/core/src/extension/marketplaceRegistry.ts index e58bcadd4a7..ff1af919b5b 100644 --- a/packages/core/src/extension/marketplaceRegistry.ts +++ b/packages/core/src/extension/marketplaceRegistry.ts @@ -35,6 +35,14 @@ export interface MarketplaceSource { /** * A single installable plugin surfaced by the Discover view. */ +/** Components a plugin declares in its marketplace entry ("Will install"). */ +export interface DiscoveredPluginComponents { + skills?: string[]; + commands?: string[]; + agents?: string[]; + mcpServers?: string[]; +} + export interface DiscoveredPlugin { /** Name of the marketplace this plugin came from. */ marketplaceName: string; @@ -44,12 +52,53 @@ export interface DiscoveredPlugin { author?: string; homepage?: string; category?: string; + /** Best-effort last-updated string when the marketplace entry provides one. */ + lastUpdated?: string; + /** Components the plugin declares (for the "Will install" summary). */ + components?: DiscoveredPluginComponents; /** Source string suitable for `parseInstallSource`. */ installSource: string; /** Whether an extension with this name is already installed. */ installed: boolean; } +function asNameList( + value: string | string[] | undefined, +): string[] | undefined { + return Array.isArray(value) && value.length > 0 ? value : undefined; +} + +function asMcpNames( + value: string | Record | undefined, +): string[] | undefined { + if (value && typeof value === 'object') { + const names = Object.keys(value); + return names.length > 0 ? names : undefined; + } + return undefined; +} + +function pluginComponents( + plugin: ClaudeMarketplacePluginConfig, +): DiscoveredPluginComponents | undefined { + const components: DiscoveredPluginComponents = { + skills: asNameList(plugin.skills), + commands: asNameList(plugin.commands), + agents: asNameList(plugin.agents), + mcpServers: asMcpNames(plugin.mcpServers), + }; + return Object.values(components).some(Boolean) ? components : undefined; +} + +function pluginLastUpdated( + plugin: ClaudeMarketplacePluginConfig, +): string | undefined { + const record = plugin as unknown as Record; + const value = + record['lastUpdated'] ?? record['updatedAt'] ?? record['updated']; + return typeof value === 'string' ? value : undefined; +} + /** * Classifies a marketplace source string into a {@link MarketplaceSourceType} * using format heuristics only (no network / filesystem access required for a @@ -123,6 +172,8 @@ function pluginsFromConfig( author: plugin.author?.name, homepage: plugin.homepage, category: plugin.category, + lastUpdated: pluginLastUpdated(plugin), + components: pluginComponents(plugin), installSource: resolveInstallSource(marketplace, plugin), installed: installedNames.has(plugin.name), })); From 86c760bc277e485b474386c5bb456e0e58a53cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 19:18:59 +0800 Subject: [PATCH 03/48] feat(extensions): align Add Marketplace view with Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match CC's "Add Marketplace" screen: a bold "Add Marketplace" header, an "Enter marketplace source:" prompt, and an "Examples:" bullet list (owner/repo · git@…:owner/repo.git (SSH) · https://…/marketplace.json · ./path/to/marketplace) above a bare cursor input (placeholder removed). Update the Marketplaces add-view tests accordingly. --- .../ExtensionsManagerDialog.test.tsx | 14 +++++++---- .../extensions/tabs/MarketplacesTab.tsx | 24 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 6e8c46cbee9..b5c85028345 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -323,7 +323,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }); stdin.write('\r'); // Enter on the add row -> opens the add sub-view (locks tabs) await waitFor(() => { - expect(lastFrame()).toContain('Add a marketplace source'); + expect(lastFrame()).toContain('Enter marketplace source:'); }); stdin.write('\x1b'); // Escape should return to the list, not close the dialog await waitFor(() => { @@ -341,9 +341,15 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }); stdin.write('\r'); // Enter on add row await waitFor(() => { - expect(lastFrame()).toContain('Add a marketplace source'); + expect(lastFrame()).toContain('Add Marketplace'); }); - // The supported source formats are surfaced to guide the user. - expect(lastFrame()).toContain('owner/repo'); + // CC-style: prompt + examples list to guide the user. + const frame = lastFrame(); + expect(frame).toContain('Enter marketplace source:'); + expect(frame).toContain('Examples:'); + expect(frame).toContain('owner/repo (GitHub)'); + expect(frame).toContain('git@github.com:owner/repo.git (SSH)'); + expect(frame).toContain('https://example.com/marketplace.json'); + expect(frame).toContain('./path/to/marketplace'); }); }); diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx index dfe5ab970e6..38f64a3e312 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -203,12 +203,25 @@ export const MarketplacesTab = ({ if (view === 'add') { return ( - {t('Add a marketplace source:')} - - {t( - 'Formats: owner/repo · git@github.com:owner/repo.git · https://host/marketplace.json · ./local/path', - )} + + {t('Add Marketplace')} + + + + {t('Enter marketplace source:')} + + {t('Examples:')} + {' · owner/repo (GitHub)'} + + {' · git@github.com:owner/repo.git (SSH)'} + + + {' · https://example.com/marketplace.json'} + + {' · ./path/to/marketplace'} + + {busy ? ( {t('Adding...')} ) : ( @@ -216,7 +229,6 @@ export const MarketplacesTab = ({ value={input} onChange={setInput} onSubmit={() => void submitAdd()} - placeholder="owner/repo" isActive={isActive} /> )} From d1f8e98cbc983a0ddc37630d5c3c44c248d12486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 19:39:25 +0800 Subject: [PATCH 04/48] feat(extensions): fix Discover hang + add search/scrolling, align list with CC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover reliability and UX fixes: - Fix the "Discovering plugins…" hang: marketplace network fetches had no timeout, so a slow/unreachable source could block discovery forever. Add a 10s per-request timeout (resolve null) plus socket drain on non-200. - Cache the fetched listing in ExtensionManager for the session so revisiting the tab no longer refetches over the network; `installed` flags are recomputed cheaply, and the cache is invalidated on add/remove marketplace. CC-aligned Discover list: - Windowed/scrolling viewport (no longer renders the entire 200+ list at once) with "↑ more above" / "↓ more below" hints, sized to the terminal. - Type-to-search filter with a search box and a "Discover plugins (pos/total)" count header. - Item layout: cursor "›", ○/●/✓ checkbox, bold title · marketplace · " installs", with a truncated description line. - Space toggles selection, Enter views detail (or installs the selected set); the conflicting "i" shortcut was removed in favor of search. Core: DiscoveredPlugin gains a best-effort `installs` count. Adds tests for windowing, search filtering, and install-count extraction. --- .../ExtensionsManagerDialog.test.tsx | 64 +++++ .../extensions/ExtensionsManagerDialog.tsx | 2 +- .../extensions/tabs/DiscoverTab.tsx | 236 ++++++++++++++---- .../core/src/extension/extensionManager.ts | 29 ++- packages/core/src/extension/marketplace.ts | 41 +-- .../core/src/extension/marketplaceRegistry.ts | 14 ++ 6 files changed, 312 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index b5c85028345..4f5f17bc5b5 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -192,6 +192,70 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Back to plugin list'); }); + it('windows a long Discover list with a scroll hint and count header', async () => { + const discovered: DiscoveredPlugin[] = Array.from( + { length: 15 }, + (_, i) => ({ + marketplaceName: 'mkt', + name: `plugin-${i}`, + installSource: `owner/repo:plugin-${i}`, + installed: false, + }), + ); + const { lastFrame } = renderDialog( + createConfig(createManager({ discovered })), + ); + await waitFor(() => { + expect(lastFrame()).toContain('plugin-0'); + }); + const frame = lastFrame(); + expect(frame).toContain('Discover plugins'); + expect(frame).toContain('(1/15)'); + expect(frame).toContain('Search'); // search box + // Not all 15 fit; the more-below indicator is shown. + expect(frame).toContain('more below'); + // The last item is scrolled out of the initial window. + expect(frame).not.toContain('plugin-14'); + }); + + it('filters the Discover list as you type', async () => { + const discovered: DiscoveredPlugin[] = [ + { + marketplaceName: 'm', + name: 'alpha', + installSource: 'o/r:alpha', + installed: false, + }, + { + marketplaceName: 'm', + name: 'beta', + installSource: 'o/r:beta', + installed: false, + }, + { + marketplaceName: 'm', + name: 'gamma', + installSource: 'o/r:gamma', + installed: false, + }, + ]; + const { stdin, lastFrame } = renderDialog( + createConfig(createManager({ discovered })), + ); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + for (const ch of 'beta') { + stdin.write(ch); // type-to-search, one printable char at a time + } + await waitFor(() => { + expect(lastFrame()).toContain('beta'); + expect(lastFrame()).not.toContain('alpha'); + }); + expect(lastFrame()).not.toContain('gamma'); + expect(lastFrame()).toContain('(1/1)'); + }); + it('prompts to add a marketplace when none discovered', async () => { const { lastFrame } = renderDialog(createConfig(createManager())); await waitFor(() => { diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 0b5fe8ea5ac..afc617df450 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -38,7 +38,7 @@ function footerHint(tab: ExtensionsTab): string { switch (tab) { case EXTENSIONS_TABS.DISCOVER: return t( - '↑↓ navigate · Space select · i install · Enter details · Esc close', + 'Type to search · Space to toggle · Enter to view · Esc to go back', ); case EXTENSIONS_TABS.INSTALLED: return t( diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 3fb26613a21..42d2927c371 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import open from 'open'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../../keyMatchers.js'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import { t } from '../../../../i18n/index.js'; import { @@ -37,6 +38,20 @@ interface DiscoverTabProps { reloadSignal: number; } +/** Formats a raw install count like 787100 -> "787.1K". */ +function formatInstalls(n?: number): string | null { + if (typeof n !== 'number' || !Number.isFinite(n)) return null; + if (n >= 1_000_000) + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + return String(n); +} + +function truncateText(text: string, max: number): string { + if (max <= 1 || text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} + // Built per-render so the literal t() labels stay extractable and localize. function scopeItems(): Array<{ key: string; @@ -64,15 +79,37 @@ export const DiscoverTab = ({ }: DiscoverTabProps) => { const [plugins, setPlugins] = useState([]); const [cursor, setCursor] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [query, setQuery] = useState(''); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [view, setView] = useState('list'); const [loading, setLoading] = useState(true); const [installing, setInstalling] = useState(false); + const { columns, rows } = useTerminalSize(); + const availableWidth = Math.max(24, columns - 8); + // Each item renders as 3 lines (title, description, gap). Reserve rows for + // the tab bar, header, search box, scroll hints, status and footer. + const visibleCount = Math.max( + 3, + Math.min(8, Math.floor(((rows || 24) - 13) / 3)), + ); + const extensionManager = config.getExtensionManager(); const keyOf = (p: DiscoveredPlugin) => `${p.marketplaceName}/${p.name}`; + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return plugins; + return plugins.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.marketplaceName.toLowerCase().includes(q) || + (p.description?.toLowerCase().includes(q) ?? false), + ); + }, [plugins, query]); + const load = useCallback(async () => { if (!extensionManager) { setLoading(false); @@ -100,7 +137,23 @@ export const DiscoverTab = ({ onLockChange(false); }, [onLockChange]); - const selected = plugins[cursor] ?? null; + const selected = filtered[cursor] ?? null; + + // Keep the cursor in range as the filtered list changes (e.g. while typing). + useEffect(() => { + if (cursor > filtered.length - 1) { + setCursor(filtered.length > 0 ? filtered.length - 1 : 0); + } + }, [filtered.length, cursor]); + + // Keep the cursor inside the visible window (scrolling viewport). + useEffect(() => { + if (cursor < scrollOffset) { + setScrollOffset(cursor); + } else if (cursor >= scrollOffset + visibleCount) { + setScrollOffset(cursor - visibleCount + 1); + } + }, [cursor, scrollOffset, visibleCount]); // Plugins queued for installation when the scope is chosen. const pendingInstall = useCallback((): DiscoveredPlugin[] => { @@ -262,15 +315,31 @@ export const DiscoverTab = ({ return items; }, [selected]); - // List keyboard. + // List keyboard: navigate, type-to-search, Space to toggle, Enter to view + // (or install the selected set), matching Claude Code's Discover list. useKeypress( (key) => { - if (plugins.length === 0) return; if (keyMatchers[Command.SELECTION_UP](key)) { - setCursor((prev) => (prev > 0 ? prev - 1 : plugins.length - 1)); - } else if (keyMatchers[Command.SELECTION_DOWN](key)) { - setCursor((prev) => (prev < plugins.length - 1 ? prev + 1 : 0)); - } else if (key.name === 'space' || key.sequence === ' ') { + if (filtered.length > 0) + setCursor((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + return; + } + if (keyMatchers[Command.SELECTION_DOWN](key)) { + if (filtered.length > 0) + setCursor((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + return; + } + if (key.name === 'return') { + if (selectedKeys.size > 0) { + beginInstall(); + } else if (selected) { + onStatus(null); + setView('detail'); + onLockChange(true); + } + return; + } + if (key.name === 'space' || key.sequence === ' ') { if (!selected || selected.installed) return; setSelectedKeys((prev) => { const next = new Set(prev); @@ -279,14 +348,21 @@ export const DiscoverTab = ({ else next.add(k); return next; }); - } else if (key.sequence === 'i' && !key.ctrl && !key.meta) { - beginInstall(); - } else if (key.name === 'return') { - if (selected) { - onStatus(null); - setView('detail'); - onLockChange(true); - } + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + setQuery((q) => q.slice(0, -1)); + return; + } + // Printable character -> append to the search query. + if ( + !key.ctrl && + !key.meta && + key.sequence && + key.sequence.length === 1 && + key.sequence >= ' ' + ) { + setQuery((q) => q + key.sequence); } }, { isActive: isActive && view === 'list' }, @@ -434,48 +510,98 @@ export const DiscoverTab = ({ ); } + const windowItems = filtered.slice(scrollOffset, scrollOffset + visibleCount); + const hasAbove = scrollOffset > 0; + const hasBelow = scrollOffset + visibleCount < filtered.length; + return ( - - {t('{{count}} plugin(s) available', { count: String(plugins.length) })} - - - {plugins.map((plugin, index) => { - const isSelected = index === cursor; - const isChecked = selectedKeys.has(keyOf(plugin)); - return ( - - - - {isSelected ? '●' : ' '} - - - - - {plugin.installed ? '[✓]' : isChecked ? '[•]' : '[ ]'} - - - - - {plugin.name} - - - - {plugin.marketplaceName} - {plugin.installed ? ` (${t('installed')})` : ''} - - - ); - })} + + + {t('Discover plugins')} + + + {` (${filtered.length ? cursor + 1 : 0}/${filtered.length})`} + + + + {'⌕ '} + {query ? ( + {query} + ) : ( + {t('Search…')} + )} + + + {filtered.length === 0 ? ( + + + {t('No plugins match your search.')} + + + ) : ( + + {hasAbove ? ( + {t('↑ more above')} + ) : null} + {windowItems.map((plugin, i) => { + const absIndex = scrollOffset + i; + const isCursor = absIndex === cursor; + const isChecked = selectedKeys.has(keyOf(plugin)); + const installs = formatInstalls(plugin.installs); + const checkbox = plugin.installed ? '✓' : isChecked ? '●' : '○'; + const titleColor = isCursor + ? theme.text.accent + : theme.text.primary; + const meta = + ` · ${plugin.marketplaceName}` + + (installs ? ` · ${installs} installs` : '') + + (plugin.installed ? ` · ${t('installed')}` : ''); + return ( + + + + + {isCursor ? '›' : ' '} + + + + + {checkbox} + + + + {plugin.name} + + {meta} + + {plugin.description ? ( + + + {truncateText(plugin.description, availableWidth - 4)} + + + ) : null} + + ); + })} + {hasBelow ? ( + {t('↓ more below')} + ) : null} + + )} ); }; diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 0f4737a7f55..bf8da1667a4 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -318,6 +318,7 @@ export class ExtensionManager { private readonly workspaceDir: string; private readonly preferencesStore: ExtensionPreferencesStore; private readonly marketplaceRegistryStore: MarketplaceRegistryStore; + private discoverCache: DiscoveredPlugin[] | null = null; private config?: Config; private telemetrySettings?: TelemetrySettings; @@ -564,11 +565,16 @@ export class ExtensionManager { addedAt: new Date().toISOString(), }; this.marketplaceRegistryStore.add(entry); + this.discoverCache = null; // sources changed -> refetch on next discover return entry; } removeMarketplace(name: string): boolean { - return this.marketplaceRegistryStore.remove(name); + const removed = this.marketplaceRegistryStore.remove(name); + if (removed) { + this.discoverCache = null; + } + return removed; } loadMarketplace(source: string): Promise { @@ -577,13 +583,28 @@ export class ExtensionManager { /** * Discovers all installable plugins across configured marketplaces, marking - * which are already installed. + * which are already installed. The fetched listing is cached for the session; + * pass `{ refresh: true }` to force a re-fetch. The cheap `installed` flags are + * always recomputed against the current install state. */ - async discoverPlugins(): Promise { + async discoverPlugins(options?: { + refresh?: boolean; + }): Promise { const installedNames = new Set( this.getLoadedExtensions().map((ext) => ext.name), ); - return discoverPlugins(this.getMarketplaces(), installedNames); + if (this.discoverCache && !options?.refresh) { + return this.discoverCache.map((plugin) => ({ + ...plugin, + installed: installedNames.has(plugin.name), + })); + } + const result = await discoverPlugins( + this.getMarketplaces(), + installedNames, + ); + this.discoverCache = result; + return result; } private enableByPath( diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index f0febf22c36..935617f9b46 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -119,27 +119,40 @@ function isGitUrl(source: string): boolean { ); } +/** Max time to wait for a single marketplace network request. */ +const MARKETPLACE_FETCH_TIMEOUT_MS = 10000; + /** - * Fetch content from a URL + * Fetch content from a URL. Resolves to null on non-200, error, or timeout so a + * slow/unreachable marketplace can never hang discovery indefinitely. */ function fetchUrl( url: string, headers: Record, ): Promise { return new Promise((resolve) => { - https - .get(url, { headers }, (res) => { - if (res.statusCode !== 200) { - resolve(null); - return; - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - resolve(Buffer.concat(chunks).toString()); - }); - }) - .on('error', () => resolve(null)); + let settled = false; + const done = (value: string | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + const req = https.get(url, { headers }, (res) => { + if (res.statusCode !== 200) { + res.resume(); // drain so the socket can be freed + done(null); + return; + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => done(Buffer.concat(chunks).toString())); + res.on('error', () => done(null)); + }); + req.on('error', () => done(null)); + req.setTimeout(MARKETPLACE_FETCH_TIMEOUT_MS, () => { + req.destroy(); + done(null); + }); }); } diff --git a/packages/core/src/extension/marketplaceRegistry.ts b/packages/core/src/extension/marketplaceRegistry.ts index ff1af919b5b..97b81102463 100644 --- a/packages/core/src/extension/marketplaceRegistry.ts +++ b/packages/core/src/extension/marketplaceRegistry.ts @@ -54,6 +54,8 @@ export interface DiscoveredPlugin { category?: string; /** Best-effort last-updated string when the marketplace entry provides one. */ lastUpdated?: string; + /** Best-effort install/download count when the marketplace entry provides one. */ + installs?: number; /** Components the plugin declares (for the "Will install" summary). */ components?: DiscoveredPluginComponents; /** Source string suitable for `parseInstallSource`. */ @@ -99,6 +101,17 @@ function pluginLastUpdated( return typeof value === 'string' ? value : undefined; } +function pluginInstalls( + plugin: ClaudeMarketplacePluginConfig, +): number | undefined { + const record = plugin as unknown as Record; + const value = + record['installs'] ?? record['installCount'] ?? record['downloads']; + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + /** * Classifies a marketplace source string into a {@link MarketplaceSourceType} * using format heuristics only (no network / filesystem access required for a @@ -173,6 +186,7 @@ function pluginsFromConfig( homepage: plugin.homepage, category: plugin.category, lastUpdated: pluginLastUpdated(plugin), + installs: pluginInstalls(plugin), components: pluginComponents(plugin), installSource: resolveInstallSource(marketplace, plugin), installed: installedNames.has(plugin.name), From 54c4f9fd8e75981a9978de1d0475dcdc3cc61760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 20:30:05 +0800 Subject: [PATCH 05/48] feat(extensions): align marketplace detail with CC + Browse-to-Discover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Marketplaces tab detail to match Claude Code: - Show marketplace name, source, "N available plugins", and the plugins from this marketplace that are installed ("Installed plugins (K):" with descriptions) — instead of dumping a truncated list of all plugins. - Replace the ad-hoc "d to remove" hint with an action selector: Browse plugins (N) · Update marketplace [(last updated DATE)] · Remove marketplace. - "Browse plugins" switches to the Discover tab filtered to this marketplace (only its plugins); the filter clears on manual tab switch and is shown in the Discover header. - "Update marketplace" re-fetches the marketplace config, stamps a fresh "last updated", and invalidates the discovery cache. Core: MarketplaceSource gains lastUpdatedAt; addMarketplace stamps it and ExtensionManager.markMarketplaceUpdated() refreshes it + clears the discovery cache. Adds tests for the marketplace detail layout and the Browse-to-Discover filtering flow. --- .../ExtensionsManagerDialog.test.tsx | 99 ++++++++++ .../extensions/ExtensionsManagerDialog.tsx | 13 ++ .../extensions/tabs/DiscoverTab.tsx | 25 ++- .../extensions/tabs/MarketplacesTab.tsx | 169 +++++++++++++++--- .../core/src/extension/extensionManager.ts | 22 ++- .../core/src/extension/marketplaceRegistry.ts | 2 + 6 files changed, 300 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 4f5f17bc5b5..2076145c3fa 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -350,6 +350,105 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('Skills'); }); + it('shows a CC-style marketplace detail with installed plugins and actions', async () => { + const manager = createManager({ + extensions: [mockExtension('pdf', true)], // installed, belongs to Skills + marketplaces: [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + ], + }); + manager.loadMarketplace = vi.fn().mockResolvedValue({ + name: 'Skills', + owner: { name: 'o', email: 'e' }, + plugins: [ + { name: 'pdf', version: '1', description: 'PDF tools', source: 'a/s' }, + { name: 'docx', version: '1', source: 'a/s' }, + ], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + // Wait until sources have loaded (and the keypress handler re-subscribed). + await waitFor(() => { + expect(lastFrame()).toContain('Skills'); + }); + stdin.write('\x1B[B'); // down -> select the Skills source row + await waitFor(() => { + expect(lastFrame()).toContain('● Skills'); + }); + stdin.write('\r'); // Enter -> open detail + await waitFor(() => { + expect(lastFrame()).toContain('available plugins'); + }); + const frame = lastFrame(); + expect(frame).toContain('2 available plugins'); + expect(frame).toContain('Installed plugins (1):'); + expect(frame).toContain('pdf'); + expect(frame).toContain('Browse plugins (2)'); + expect(frame).toContain('Update marketplace'); + expect(frame).toContain('Remove marketplace'); + }); + + it('Browse plugins jumps to Discover filtered by the marketplace', async () => { + const manager = createManager({ + marketplaces: [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + ], + discovered: [ + { + marketplaceName: 'Skills', + name: 'pdf', + installSource: 'a/s:pdf', + installed: false, + }, + { + marketplaceName: 'Skills', + name: 'docx', + installSource: 'a/s:docx', + installed: false, + }, + { + marketplaceName: 'Other', + name: 'zzz', + installSource: 'o/o:zzz', + installed: false, + }, + ], + }); + manager.loadMarketplace = vi.fn().mockResolvedValue({ + name: 'Skills', + owner: { name: 'o', email: 'e' }, + plugins: [ + { name: 'pdf', version: '1', source: 'a/s' }, + { name: 'docx', version: '1', source: 'a/s' }, + ], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('Skills'); // sources loaded + }); + stdin.write('\x1B[B'); // down -> Skills source + await waitFor(() => { + expect(lastFrame()).toContain('● Skills'); + }); + stdin.write('\r'); // Enter -> detail + await waitFor(() => { + expect(lastFrame()).toContain('Browse plugins (2)'); + }); + stdin.write('\r'); // select "Browse plugins" -> Discover filtered + await waitFor(() => { + expect(lastFrame()).toContain('Discover plugins'); + }); + const frame = lastFrame(); + // Filtered to the Skills marketplace: its plugins show, the other does not. + expect(frame).toContain('Skills'); + expect(frame).toContain('pdf'); + expect(frame).toContain('docx'); + expect(frame).not.toContain('zzz'); + }); + it('switches tabs with the Tab key', async () => { const config = createConfig( createManager({ extensions: [mockExtension('alpha', true)] }), diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index afc617df450..44fe5c8e9fd 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -68,9 +68,13 @@ export function ExtensionsManagerDialog({ // Bumped to force tabs to re-load when a cross-tab change happens // (e.g. installing from Discover should refresh Installed). const [reloadSignal, setReloadSignal] = useState(0); + // When set, the Discover tab is restricted to this marketplace (set by the + // Marketplaces tab's "Browse plugins" action; cleared on manual tab switch). + const [discoverFilter, setDiscoverFilter] = useState(null); const cycleTab = useCallback((direction: 1 | -1) => { setStatus(null); + setDiscoverFilter(null); setActiveTab((current) => { const index = TABS.findIndex((tab) => tab.id === current); const next = (index + direction + TABS.length) % TABS.length; @@ -78,6 +82,13 @@ export function ExtensionsManagerDialog({ }); }, []); + const handleBrowseMarketplace = useCallback((marketplaceName: string) => { + setStatus(null); + setTabLocked(false); + setDiscoverFilter(marketplaceName); + setActiveTab(EXTENSIONS_TABS.DISCOVER); + }, []); + const bumpReload = useCallback(() => { setReloadSignal((value) => value + 1); }, []); @@ -140,6 +151,7 @@ export function ExtensionsManagerDialog({ onLockChange={handleLockChange} onStatus={setStatus} onInstalled={bumpReload} + marketplaceFilter={discoverFilter ?? undefined} reloadSignal={reloadSignal} /> )} @@ -160,6 +172,7 @@ export function ExtensionsManagerDialog({ onLockChange={handleLockChange} onStatus={setStatus} onChanged={bumpReload} + onBrowse={handleBrowseMarketplace} reloadSignal={reloadSignal} /> )} diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 42d2927c371..267ef9ad1d0 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -35,6 +35,8 @@ interface DiscoverTabProps { onLockChange: (locked: boolean) => void; onStatus: (status: StatusMessage | null) => void; onInstalled: () => void; + /** When set, only plugins from this marketplace are shown. */ + marketplaceFilter?: string; reloadSignal: number; } @@ -75,6 +77,7 @@ export const DiscoverTab = ({ onLockChange, onStatus, onInstalled, + marketplaceFilter, reloadSignal, }: DiscoverTabProps) => { const [plugins, setPlugins] = useState([]); @@ -100,15 +103,24 @@ export const DiscoverTab = ({ const keyOf = (p: DiscoveredPlugin) => `${p.marketplaceName}/${p.name}`; const filtered = useMemo(() => { + const byMarketplace = marketplaceFilter + ? plugins.filter((p) => p.marketplaceName === marketplaceFilter) + : plugins; const q = query.trim().toLowerCase(); - if (!q) return plugins; - return plugins.filter( + if (!q) return byMarketplace; + return byMarketplace.filter( (p) => p.name.toLowerCase().includes(q) || p.marketplaceName.toLowerCase().includes(q) || (p.description?.toLowerCase().includes(q) ?? false), ); - }, [plugins, query]); + }, [plugins, query, marketplaceFilter]); + + // Reset the cursor to the top when the marketplace filter changes. + useEffect(() => { + setCursor(0); + setScrollOffset(0); + }, [marketplaceFilter]); const load = useCallback(async () => { if (!extensionManager) { @@ -523,6 +535,13 @@ export const DiscoverTab = ({ {` (${filtered.length ? cursor + 1 : 0}/${filtered.length})`} + {marketplaceFilter ? ( + + {t(' · {{marketplace}} (Tab to clear)', { + marketplace: marketplaceFilter, + })} + + ) : null} void; onStatus: (status: StatusMessage | null) => void; onChanged: () => void; + /** Switch to the Discover tab filtered to the given marketplace. */ + onBrowse: (marketplaceName: string) => void; reloadSignal: number; } +function formatDate(iso?: string): string | null { + if (!iso) return null; + const time = Date.parse(iso); + if (Number.isNaN(time)) return null; + return new Date(time).toLocaleDateString(); +} + export const MarketplacesTab = ({ config, isActive, onLockChange, onStatus, onChanged, + onBrowse, reloadSignal, }: MarketplacesTabProps) => { const [sources, setSources] = useState([]); @@ -132,6 +144,46 @@ export const MarketplacesTab = ({ goToList(); }, [extensionManager, selectedSource, onStatus, load, onChanged, goToList]); + const updateMarketplace = useCallback(async () => { + if (!extensionManager || !selectedSource) return; + setDetailLoading(true); + try { + // Re-fetch the marketplace config and stamp a fresh "last updated". + const cfg = await extensionManager.loadMarketplace(selectedSource.source); + setDetailConfig(cfg ?? null); + extensionManager.markMarketplaceUpdated(selectedSource.name); + load(); + onChanged(); + onStatus({ + type: 'success', + text: t('Updated marketplace "{{name}}".', { + name: selectedSource.name, + }), + }); + } catch (error) { + onStatus({ + type: 'error', + text: redactUrlCredentials(getErrorMessage(error)), + }); + } finally { + setDetailLoading(false); + } + }, [extensionManager, selectedSource, load, onChanged, onStatus]); + + const handleDetailAction = useCallback( + (action: MarketplaceDetailAction) => { + if (!selectedSource) return; + if (action === 'browse') { + onBrowse(selectedSource.name); + } else if (action === 'update') { + void updateMarketplace(); + } else if (action === 'remove') { + setView('remove-confirm'); + } + }, + [selectedSource, onBrowse, updateMarketplace], + ); + // List keyboard. useKeypress( (key) => { @@ -172,17 +224,11 @@ export const MarketplacesTab = ({ { isActive: isActive && view === 'add' }, ); - // Detail escape. + // Detail: Escape goes back; the action selector (RadioButtonSelect) owns Enter. useKeypress( (key) => { if (key.name === 'escape') { goToList(); - } else if ( - (key.sequence === 'd' || key.sequence === 'x') && - !key.ctrl && - !key.meta - ) { - setView('remove-confirm'); } }, { isActive: isActive && view === 'detail' }, @@ -237,43 +283,114 @@ export const MarketplacesTab = ({ } if (view === 'detail' && selectedSource) { + const plugins = detailConfig?.plugins ?? []; + const availableCount = plugins.length; + const installedNames = new Set( + (extensionManager?.getLoadedExtensions() ?? []).map((ext) => ext.name), + ); + const installedPlugins = plugins.filter((p) => installedNames.has(p.name)); + const lastUpdated = formatDate( + selectedSource.lastUpdatedAt ?? selectedSource.addedAt, + ); + + const actions: Array<{ + key: string; + label: string; + value: MarketplaceDetailAction; + }> = [ + { + key: 'browse', + label: t('Browse plugins ({{count}})', { + count: String(availableCount), + }), + value: 'browse', + }, + { + key: 'update', + label: lastUpdated + ? t('Update marketplace (last updated {{date}})', { + date: lastUpdated, + }) + : t('Update marketplace'), + value: 'update', + }, + { key: 'remove', label: t('Remove marketplace'), value: 'remove' }, + ]; + return ( - + {selectedSource.name} - {redactUrlCredentials(selectedSource.source)} ({selectedSource.type} - ) + {redactUrlCredentials(selectedSource.source)} + {detailLoading ? ( {t('Loading...')} ) : detailConfig ? ( - + - {t('{{count}} plugin(s):', { - count: String(detailConfig.plugins?.length ?? 0), + {t('{{count}} available plugins', { + count: String(availableCount), })} - - {(detailConfig.plugins ?? []).slice(0, 15).map((p) => ( - - - {p.name} - {p.description ? `: ${p.description}` : ''} + + {installedPlugins.length > 0 ? ( + + + {t('Installed plugins ({{count}}):', { + count: String(installedPlugins.length), + })} - ))} - + {installedPlugins.map((p) => ( + + + + {'●'} + + {p.name} + + {p.description ? ( + + + {p.description} + + + ) : null} + + ))} + + ) : null} + + ) : ( - - {t('Could not load this marketplace.')} - + + + {t('Could not load this marketplace.')} + + + )} - - {t('d to remove · Esc to go back')} - ); } diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index bf8da1667a4..fa740c491c9 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -558,11 +558,13 @@ export class ExtensionManager { `Expected a .claude-plugin/marketplace.json.`, ); } + const now = new Date().toISOString(); const entry: MarketplaceSource = { name: config.name || trimmed, source: trimmed, type: parseMarketplaceSourceType(trimmed), - addedAt: new Date().toISOString(), + addedAt: now, + lastUpdatedAt: now, }; this.marketplaceRegistryStore.add(entry); this.discoverCache = null; // sources changed -> refetch on next discover @@ -577,6 +579,24 @@ export class ExtensionManager { return removed; } + /** + * Records a fresh "last updated" timestamp for a marketplace and invalidates + * the discovery cache so the next discover re-fetches it. + */ + markMarketplaceUpdated(name: string): MarketplaceSource | undefined { + const entry = this.getMarketplaces().find((m) => m.name === name); + if (!entry) { + return undefined; + } + const updated: MarketplaceSource = { + ...entry, + lastUpdatedAt: new Date().toISOString(), + }; + this.marketplaceRegistryStore.add(updated); // add() replaces by name + this.discoverCache = null; + return updated; + } + loadMarketplace(source: string): Promise { return loadMarketplaceConfigFromSource(source); } diff --git a/packages/core/src/extension/marketplaceRegistry.ts b/packages/core/src/extension/marketplaceRegistry.ts index 97b81102463..e79392e1109 100644 --- a/packages/core/src/extension/marketplaceRegistry.ts +++ b/packages/core/src/extension/marketplaceRegistry.ts @@ -30,6 +30,8 @@ export interface MarketplaceSource { type: MarketplaceSourceType; /** ISO timestamp recorded when the source was added. */ addedAt?: string; + /** ISO timestamp of the last successful (re)fetch / update. */ + lastUpdatedAt?: string; } /** From d5bf1447038a68bf84d73a572acbe4d087deeba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 21:07:03 +0800 Subject: [PATCH 06/48] feat(extensions): show plugin type in Installed; guide single-extension adds - Installed list: plugin rows now show their type + version ("Extension v0.7.0"), parallel to MCP rows ("MCP"), instead of a bare version. - Add Marketplace: when the source is not a Claude marketplace but is a valid single extension source (Gemini/Claude/git/npm), the error now guides the user to install it directly ("... looks like a single extension, not a marketplace. Install it with: /extensions install X") instead of the generic "expected marketplace.json" message. --- .../ExtensionsManagerDialog.test.tsx | 2 ++ .../extensions/tabs/InstalledTab.tsx | 6 ++++- .../core/src/extension/extensionManager.ts | 24 +++++++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 2076145c3fa..9587ae2d270 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -283,6 +283,8 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('User'); expect(frame).toContain('Disabled'); expect(frame).toContain('beta'); + // Plugins show their type + version (parallel to "MCP"), not a bare version. + expect(frame).toContain('Extension v1.0.0'); }); it('toggles favorite when pressing f on the Installed tab', async () => { diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 86558cdd7aa..2920805cf10 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -530,7 +530,11 @@ export const InstalledTab = ({ const isSelected = globalIndex === selectedIndex; const marker = isSelected ? '●' : ' '; const kindBadge = - item.kind === 'mcp' ? t('MCP') : `v${item.extension.version}`; + item.kind === 'mcp' + ? t('MCP') + : t('Extension v{{version}}', { + version: item.extension.version, + }); const statusColor = item.isActive ? theme.status.success : theme.text.secondary; diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index fa740c491c9..79521048dbd 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -58,7 +58,10 @@ import { type MarketplaceSource, type DiscoveredPlugin, } from './marketplaceRegistry.js'; -import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import { + loadMarketplaceConfigFromSource, + parseInstallSource, +} from './marketplace.js'; import { isGeminiExtensionConfig, convertGeminiExtensionPackage, @@ -553,8 +556,25 @@ export class ExtensionManager { } const config = await loadMarketplaceConfigFromSource(trimmed); if (!config) { + // A "marketplace" is a Claude-format collection (.claude-plugin/ + // marketplace.json). A single extension repo (Gemini/Claude/git/npm) is + // not a marketplace — guide the user to install it directly instead. + let isInstallableExtension = false; + try { + await parseInstallSource(trimmed); + isInstallableExtension = true; + } catch { + // Not a recognizable install source either. + } + const redacted = redactUrlCredentials(trimmed); + if (isInstallableExtension) { + throw new Error( + `"${redacted}" looks like a single extension, not a marketplace. ` + + `Install it directly with: /extensions install ${redacted}`, + ); + } throw new Error( - `No marketplace found at "${redactUrlCredentials(trimmed)}". ` + + `No marketplace found at "${redacted}". ` + `Expected a .claude-plugin/marketplace.json.`, ); } From d47d32a2e954379846b1f9bd1cbe33133b6219b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 8 Jun 2026 21:50:13 +0800 Subject: [PATCH 07/48] fix(extensions): resolve git@ SSH marketplace sources The Marketplaces add flow advertises git@github.com:owner/repo.git (SSH) as a supported format, but loadMarketplaceConfigFromSource relied on parseGitHubRepoForReleases, which rejects the git@ scp-like form. Extract owner/repo directly from the git@github.com:owner/repo(.git) form before falling back to the URL parser, so SSH marketplace sources actually resolve. Adds a regression test. --- .../core/src/extension/marketplace.test.ts | 39 ++++++++++++++++++- packages/core/src/extension/marketplace.ts | 8 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 80fad9de6be..2886a77addd 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { parseInstallSource } from './marketplace.js'; +import { + parseInstallSource, + loadMarketplaceConfigFromSource, +} from './marketplace.js'; import * as fs from 'node:fs/promises'; import * as https from 'node:https'; @@ -323,4 +326,38 @@ describe('parseInstallSource', () => { expect(result.marketplaceConfig).toBeUndefined(); }); }); + + describe('loadMarketplaceConfigFromSource', () => { + it('resolves a marketplace from a git@ SSH source', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + const cfg = { + name: 'ssh-marketplace', + owner: { name: 'Owner' }, + plugins: [{ name: 'p1' }], + }; + vi.mocked(https.get).mockImplementation((_url, _options, callback) => { + const mockRes = { + statusCode: 200, + resume: vi.fn(), + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(Buffer.from(JSON.stringify(cfg))); + } + if (event === 'end') { + handler(); + } + }), + }; + if (typeof callback === 'function') { + callback(mockRes as never); + } + return { on: vi.fn(), setTimeout: vi.fn(), destroy: vi.fn() } as never; + }); + + const result = await loadMarketplaceConfigFromSource( + 'git@github.com:owner/repo.git', + ); + expect(result).toEqual(cfg); + }); + }); }); diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 935617f9b46..44c83156e8c 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -279,6 +279,14 @@ export async function loadMarketplaceConfigFromSource( // Priority 3: ssh/sso git URLs -> resolve owner/repo via github. if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) { + // `git@github.com:owner/repo(.git)` isn't a parseable URL, so extract + // owner/repo directly before falling back to the URL-based parser. + const sshMatch = trimmed.match( + /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/, + ); + if (sshMatch) { + return fetchGitHubMarketplaceConfig(sshMatch[1], sshMatch[2]); + } try { const { owner, repo } = parseGitHubRepoForReleases(trimmed); return await fetchGitHubMarketplaceConfig(owner, repo); From 99d26a476cdfbc2abafa876409cc4c966616975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 10:14:10 +0800 Subject: [PATCH 08/48] feat(extensions): cap Discover list window at 6 items --- packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 267ef9ad1d0..88142ebcc04 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -95,7 +95,7 @@ export const DiscoverTab = ({ // the tab bar, header, search box, scroll hints, status and footer. const visibleCount = Math.max( 3, - Math.min(8, Math.floor(((rows || 24) - 13) / 3)), + Math.min(6, Math.floor(((rows || 24) - 13) / 3)), ); const extensionManager = config.getExtensionManager(); From 73d9b58751dfd48846724328931b1291fd50fab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 10:50:11 +0800 Subject: [PATCH 09/48] feat(extensions): unify 'Extension' wording, reorder tabs, expand Marketplaces tab - Terminology: use 'Extension' instead of 'Plugin' across the dialog (Discover extensions, Extension details, Back to extension list, etc.). - Tabs reordered to Installed, Discover, Marketplaces; the dialog now opens on Installed by default. - Marketplaces tab is now a sources hub: - new 'Install new extension' action (installs a single Gemini/Qwen/ Claude/git/npm extension directly via parseInstallSource). - 'Add new marketplace' annotated as a Claude plugin marketplace. - items grouped into 'Extensions' and 'Marketplaces' sections; an extension row opens a compact detail with Uninstall. Updates the dialog tests for the new wording, tab order and layout. --- .../ExtensionsManagerDialog.test.tsx | 85 +-- .../extensions/ExtensionsManagerDialog.tsx | 4 +- .../extensions/tabs/DiscoverTab.tsx | 29 +- .../extensions/tabs/MarketplacesTab.tsx | 487 +++++++++++++----- 4 files changed, 439 insertions(+), 166 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 9587ae2d270..9a947e737b6 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -148,6 +148,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ]; const { lastFrame } = renderDialog( createConfig(createManager({ discovered })), + { initialTab: EXTENSIONS_TABS.DISCOVER }, ); await waitFor(() => { expect(lastFrame()).toContain('pdf'); @@ -171,13 +172,14 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ]; const { stdin, lastFrame } = renderDialog( createConfig(createManager({ discovered })), + { initialTab: EXTENSIONS_TABS.DISCOVER }, ); await waitFor(() => { expect(lastFrame()).toContain('42crunch-api-security-testing'); }); stdin.write('\r'); // Enter -> detail await waitFor(() => { - expect(lastFrame()).toContain('Plugin details'); + expect(lastFrame()).toContain('Extension details'); }); const frame = lastFrame(); expect(frame).toContain('from claude-plugins-official'); @@ -189,7 +191,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('project scope'); expect(frame).toContain('local scope'); expect(frame).toContain('Open homepage'); - expect(frame).toContain('Back to plugin list'); + expect(frame).toContain('Back to extension list'); }); it('windows a long Discover list with a scroll hint and count header', async () => { @@ -204,12 +206,13 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ); const { lastFrame } = renderDialog( createConfig(createManager({ discovered })), + { initialTab: EXTENSIONS_TABS.DISCOVER }, ); await waitFor(() => { expect(lastFrame()).toContain('plugin-0'); }); const frame = lastFrame(); - expect(frame).toContain('Discover plugins'); + expect(frame).toContain('Discover extensions'); expect(frame).toContain('(1/15)'); expect(frame).toContain('Search'); // search box // Not all 15 fit; the more-below indicator is shown. @@ -241,6 +244,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ]; const { stdin, lastFrame } = renderDialog( createConfig(createManager({ discovered })), + { initialTab: EXTENSIONS_TABS.DISCOVER }, ); await waitFor(() => { expect(lastFrame()).toContain('alpha'); @@ -257,9 +261,11 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }); it('prompts to add a marketplace when none discovered', async () => { - const { lastFrame } = renderDialog(createConfig(createManager())); + const { lastFrame } = renderDialog(createConfig(createManager()), { + initialTab: EXTENSIONS_TABS.DISCOVER, + }); await waitFor(() => { - expect(lastFrame()).toContain('No plugins discovered'); + expect(lastFrame()).toContain('No extensions discovered'); }); }); @@ -335,7 +341,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('beta'); }); - it('shows the add-marketplace row on the Marketplaces tab', async () => { + it('shows the install/add actions and grouped sections on the Marketplaces tab', async () => { const config = createConfig( createManager({ marketplaces: [ @@ -349,7 +355,11 @@ describe('ExtensionsManagerDialog (tabbed)', () => { await waitFor(() => { expect(lastFrame()).toContain('Add new marketplace'); }); - expect(lastFrame()).toContain('Skills'); + const frame = lastFrame(); + expect(frame).toContain('Install new extension'); + expect(frame).toContain('Claude plugin marketplace'); // (Claude) annotation + expect(frame).toContain('Marketplaces'); // section header + expect(frame).toContain('Skills'); }); it('shows a CC-style marketplace detail with installed plugins and actions', async () => { @@ -370,23 +380,26 @@ describe('ExtensionsManagerDialog (tabbed)', () => { const { stdin, lastFrame } = renderDialog(createConfig(manager), { initialTab: EXTENSIONS_TABS.MARKETPLACES, }); - // Wait until sources have loaded (and the keypress handler re-subscribed). + // Wait until the list has loaded (and the keypress handler re-subscribed). await waitFor(() => { expect(lastFrame()).toContain('Skills'); }); - stdin.write('\x1B[B'); // down -> select the Skills source row + // Rows: Install ext (0), Add marketplace (1), pdf extension (2), Skills (3). + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); await waitFor(() => { expect(lastFrame()).toContain('● Skills'); }); stdin.write('\r'); // Enter -> open detail await waitFor(() => { - expect(lastFrame()).toContain('available plugins'); + expect(lastFrame()).toContain('available extensions'); }); const frame = lastFrame(); - expect(frame).toContain('2 available plugins'); - expect(frame).toContain('Installed plugins (1):'); + expect(frame).toContain('2 available extensions'); + expect(frame).toContain('Installed extensions (1):'); expect(frame).toContain('pdf'); - expect(frame).toContain('Browse plugins (2)'); + expect(frame).toContain('Browse extensions (2)'); expect(frame).toContain('Update marketplace'); expect(frame).toContain('Remove marketplace'); }); @@ -429,22 +442,24 @@ describe('ExtensionsManagerDialog (tabbed)', () => { initialTab: EXTENSIONS_TABS.MARKETPLACES, }); await waitFor(() => { - expect(lastFrame()).toContain('Skills'); // sources loaded + expect(lastFrame()).toContain('Skills'); // list loaded }); - stdin.write('\x1B[B'); // down -> Skills source + // Rows: Install ext (0), Add marketplace (1), Skills (2). + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); await waitFor(() => { expect(lastFrame()).toContain('● Skills'); }); stdin.write('\r'); // Enter -> detail await waitFor(() => { - expect(lastFrame()).toContain('Browse plugins (2)'); + expect(lastFrame()).toContain('Browse extensions (2)'); }); - stdin.write('\r'); // select "Browse plugins" -> Discover filtered + stdin.write('\r'); // select "Browse extensions" -> Discover filtered await waitFor(() => { - expect(lastFrame()).toContain('Discover plugins'); + expect(lastFrame()).toContain('Discover extensions'); }); const frame = lastFrame(); - // Filtered to the Skills marketplace: its plugins show, the other does not. + // Filtered to the Skills marketplace: its extensions show, the other not. expect(frame).toContain('Skills'); expect(frame).toContain('pdf'); expect(frame).toContain('docx'); @@ -456,13 +471,13 @@ describe('ExtensionsManagerDialog (tabbed)', () => { createManager({ extensions: [mockExtension('alpha', true)] }), ); const { stdin, lastFrame } = renderDialog(config); - // Starts on Discover. + // Starts on Installed (first tab). await waitFor(() => { - expect(lastFrame()).toContain('No plugins discovered'); + expect(lastFrame()).toContain('alpha'); }); - stdin.write('\t'); // -> Installed + stdin.write('\t'); // -> Discover await waitFor(() => { - expect(lastFrame()).toContain('alpha'); + expect(lastFrame()).toContain('No extensions discovered'); }); }); @@ -484,37 +499,33 @@ describe('ExtensionsManagerDialog (tabbed)', () => { initialTab: EXTENSIONS_TABS.MARKETPLACES, }); await waitFor(() => { - expect(lastFrame()).toContain('Add new marketplace'); + expect(lastFrame()).toContain('Install new extension'); }); - stdin.write('\r'); // Enter on the add row -> opens the add sub-view (locks tabs) + stdin.write('\r'); // Enter on row 0 -> install-extension sub-view (locks tabs) await waitFor(() => { - expect(lastFrame()).toContain('Enter marketplace source:'); + expect(lastFrame()).toContain('Enter extension source:'); }); stdin.write('\x1b'); // Escape should return to the list, not close the dialog await waitFor(() => { - expect(lastFrame()).toContain('Add new marketplace'); + expect(lastFrame()).toContain('Install new extension'); }); expect(onClose).not.toHaveBeenCalled(); }); - it('opens the add-marketplace input view on Enter', async () => { + it('opens the install-extension input view on Enter', async () => { const { stdin, lastFrame } = renderDialog(createConfig(createManager()), { initialTab: EXTENSIONS_TABS.MARKETPLACES, }); await waitFor(() => { - expect(lastFrame()).toContain('Add new marketplace'); + expect(lastFrame()).toContain('Install new extension'); }); - stdin.write('\r'); // Enter on add row + stdin.write('\r'); // Enter on the first row (Install new extension) await waitFor(() => { - expect(lastFrame()).toContain('Add Marketplace'); + expect(lastFrame()).toContain('Install Extension'); }); - // CC-style: prompt + examples list to guide the user. const frame = lastFrame(); - expect(frame).toContain('Enter marketplace source:'); - expect(frame).toContain('Examples:'); + expect(frame).toContain('Enter extension source:'); expect(frame).toContain('owner/repo (GitHub)'); - expect(frame).toContain('git@github.com:owner/repo.git (SSH)'); - expect(frame).toContain('https://example.com/marketplace.json'); - expect(frame).toContain('./path/to/marketplace'); + expect(frame).toContain('@scope/name (npm)'); }); }); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 44fe5c8e9fd..51069c9ef11 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -28,8 +28,8 @@ export interface StatusMessage { } const TABS: ExtensionsTabDef[] = [ - { id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' }, { id: EXTENSIONS_TABS.INSTALLED, label: 'Installed' }, + { id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' }, { id: EXTENSIONS_TABS.MARKETPLACES, label: 'Marketplaces' }, ]; @@ -61,7 +61,7 @@ export function ExtensionsManagerDialog({ const boxWidth = columns - 4; const [activeTab, setActiveTab] = useState( - initialTab ?? EXTENSIONS_TABS.DISCOVER, + initialTab ?? EXTENSIONS_TABS.INSTALLED, ); const [tabLocked, setTabLocked] = useState(false); const [status, setStatus] = useState(null); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 88142ebcc04..c07c421e0a0 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -179,7 +179,10 @@ export const DiscoverTab = ({ const beginInstall = useCallback(() => { if (pendingInstall().length === 0) { - onStatus({ type: 'info', text: t('No installable plugins selected.') }); + onStatus({ + type: 'info', + text: t('No installable extensions selected.'), + }); return; } setView('scope-select'); @@ -222,7 +225,7 @@ export const DiscoverTab = ({ if (errors.length === 0) { onStatus({ type: 'success', - text: t('Installed {{count}} plugin(s).', { + text: t('Installed {{count}} extension(s).', { count: String(installed), }), }); @@ -321,7 +324,7 @@ export const DiscoverTab = ({ } items.push({ key: 'back', - label: t('Back to plugin list'), + label: t('Back to extension list'), value: 'back', }); return items; @@ -402,7 +405,7 @@ export const DiscoverTab = ({ if (loading) { return ( - {t('Discovering plugins...')} + {t('Discovering extensions...')} ); } @@ -411,7 +414,7 @@ export const DiscoverTab = ({ return ( - {t('Install {{count}} plugin(s) to which scope?', { + {t('Install {{count}} extension(s) to which scope?', { count: String(count), })} @@ -447,7 +450,7 @@ export const DiscoverTab = ({ return ( - {t('Plugin details')} + {t('Extension details')} @@ -493,7 +496,7 @@ export const DiscoverTab = ({ {t( - '⚠ Make sure you trust a plugin before installing, updating, or using it. We cannot verify what MCP servers, files, or other software a plugin includes, or that it works as intended. See the plugin homepage for more information.', + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.', )} @@ -514,9 +517,13 @@ export const DiscoverTab = ({ if (plugins.length === 0) { return ( - {t('No plugins discovered.')} - {t('Add a marketplace in the Marketplaces tab to discover plugins.')} + {t('No extensions discovered.')} + + + {t( + 'Add a marketplace in the Marketplaces tab to discover extensions.', + )} ); @@ -530,7 +537,7 @@ export const DiscoverTab = ({ - {t('Discover plugins')} + {t('Discover extensions')} {` (${filtered.length ? cursor + 1 : 0}/${filtered.length})`} @@ -561,7 +568,7 @@ export const DiscoverTab = ({ {filtered.length === 0 ? ( - {t('No plugins match your search.')} + {t('No extensions match your search.')} ) : ( diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx index 2bc1cabb4a5..28bf5d764db 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -14,8 +14,10 @@ import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import { t } from '../../../../i18n/index.js'; import { type Config, + type Extension, type MarketplaceSource, type ClaudeMarketplaceConfig, + parseInstallSource, redactUrlCredentials, createDebugLogger, } from '@qwen-code/qwen-code-core'; @@ -24,8 +26,22 @@ import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('MARKETPLACES_TAB'); -type MarketplacesView = 'list' | 'add' | 'detail' | 'remove-confirm'; +type MarketplacesView = + | 'list' + | 'install-extension' + | 'add' + | 'detail' + | 'extension-detail' + | 'remove-confirm'; type MarketplaceDetailAction = 'browse' | 'update' | 'remove'; +type ExtensionDetailAction = 'uninstall' | 'back'; + +// Flat, navigable entries shown on the Marketplaces tab list. +type Entry = + | { kind: 'install-extension' } + | { kind: 'add-marketplace' } + | { kind: 'extension'; extension: Extension } + | { kind: 'marketplace'; source: MarketplaceSource }; interface MarketplacesTabProps { config: Config; @@ -45,6 +61,12 @@ function formatDate(iso?: string): string | null { return new Date(time).toLocaleDateString(); } +function extensionSourceLabel(ext: Extension): string { + const meta = ext.installMetadata; + if (!meta) return t('local'); + return `${redactUrlCredentials(meta.source)} (${meta.type})`; +} + export const MarketplacesTab = ({ config, isActive, @@ -55,7 +77,7 @@ export const MarketplacesTab = ({ reloadSignal, }: MarketplacesTabProps) => { const [sources, setSources] = useState([]); - // selectedIndex: 0 = "add" row, 1..n = sources. + const [extensions, setExtensions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [view, setView] = useState('list'); const [input, setInput] = useState(''); @@ -63,30 +85,63 @@ export const MarketplacesTab = ({ const [detailConfig, setDetailConfig] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + // The marketplace / extension currently being viewed or confirmed. + const [detailSource, setDetailSource] = useState( + null, + ); + const [detailExtension, setDetailExtension] = useState( + null, + ); const extensionManager = config.getExtensionManager(); - const load = useCallback(() => { + const load = useCallback(async () => { if (!extensionManager) return; - const list = extensionManager.getMarketplaces(); - setSources(list); - setSelectedIndex((prev) => (prev <= list.length ? prev : 0)); + try { + await extensionManager.refreshCache(); + } catch (error) { + debugLogger.error('Failed to refresh extensions:', error); + } + setExtensions(extensionManager.getLoadedExtensions()); + setSources(extensionManager.getMarketplaces()); }, [extensionManager]); useEffect(() => { load(); }, [load, reloadSignal]); + // Entries: two action rows, then installed extensions, then marketplaces. + const entries = useMemo( + () => [ + { kind: 'install-extension' }, + { kind: 'add-marketplace' }, + ...extensions.map((extension) => ({ + kind: 'extension' as const, + extension, + })), + ...sources.map((source) => ({ kind: 'marketplace' as const, source })), + ], + [extensions, sources], + ); + + // Keep the cursor in range as the list changes. + useEffect(() => { + if (selectedIndex >= entries.length) { + setSelectedIndex(0); + } + }, [entries.length, selectedIndex]); + + const selectedEntry = entries[selectedIndex]; + const goToList = useCallback(() => { setView('list'); setInput(''); setDetailConfig(null); + setDetailSource(null); + setDetailExtension(null); onLockChange(false); }, [onLockChange]); - const selectedSource = - selectedIndex >= 1 ? (sources[selectedIndex - 1] ?? null) : null; - const submitAdd = useCallback(async () => { if (!extensionManager || !input.trim()) return; setBusy(true); @@ -96,7 +151,30 @@ export const MarketplacesTab = ({ type: 'success', text: t('Added marketplace "{{name}}".', { name: entry.name }), }); - load(); + await load(); + onChanged(); + goToList(); + } catch (error) { + onStatus({ + type: 'error', + text: redactUrlCredentials(getErrorMessage(error)), + }); + } finally { + setBusy(false); + } + }, [extensionManager, input, onStatus, load, onChanged, goToList]); + + const submitInstall = useCallback(async () => { + if (!extensionManager || !input.trim()) return; + setBusy(true); + try { + const metadata = await parseInstallSource(input.trim()); + const ext = await extensionManager.installExtension(metadata); + onStatus({ + type: 'success', + text: t('Installed extension "{{name}}".', { name: ext.name }), + }); + await load(); onChanged(); goToList(); } catch (error) { @@ -109,9 +187,10 @@ export const MarketplacesTab = ({ } }, [extensionManager, input, onStatus, load, onChanged, goToList]); - const openDetail = useCallback( + const openMarketplaceDetail = useCallback( async (source: MarketplaceSource) => { onStatus(null); + setDetailSource(source); setView('detail'); onLockChange(true); setDetailLoading(true); @@ -128,37 +207,63 @@ export const MarketplacesTab = ({ [extensionManager, onLockChange, onStatus], ); - const removeSelected = useCallback(() => { - if (!extensionManager || !selectedSource) return; - const removed = extensionManager.removeMarketplace(selectedSource.name); + const openExtensionDetail = useCallback( + (extension: Extension) => { + onStatus(null); + setDetailExtension(extension); + setView('extension-detail'); + onLockChange(true); + }, + [onLockChange, onStatus], + ); + + const removeMarketplace = useCallback(() => { + if (!extensionManager || !detailSource) return; + const removed = extensionManager.removeMarketplace(detailSource.name); if (removed) { onStatus({ type: 'success', - text: t('Removed marketplace "{{name}}".', { - name: selectedSource.name, + text: t('Removed marketplace "{{name}}".', { name: detailSource.name }), + }); + void load(); + onChanged(); + } + goToList(); + }, [extensionManager, detailSource, onStatus, load, onChanged, goToList]); + + const uninstallExtension = useCallback(async () => { + if (!extensionManager || !detailExtension) return; + try { + await extensionManager.uninstallExtension(detailExtension.name, false); + onStatus({ + type: 'success', + text: t('Uninstalled extension "{{name}}".', { + name: detailExtension.name, }), }); - load(); + await load(); onChanged(); + } catch (error) { + onStatus({ + type: 'error', + text: redactUrlCredentials(getErrorMessage(error)), + }); } goToList(); - }, [extensionManager, selectedSource, onStatus, load, onChanged, goToList]); + }, [extensionManager, detailExtension, onStatus, load, onChanged, goToList]); const updateMarketplace = useCallback(async () => { - if (!extensionManager || !selectedSource) return; + if (!extensionManager || !detailSource) return; setDetailLoading(true); try { - // Re-fetch the marketplace config and stamp a fresh "last updated". - const cfg = await extensionManager.loadMarketplace(selectedSource.source); + const cfg = await extensionManager.loadMarketplace(detailSource.source); setDetailConfig(cfg ?? null); - extensionManager.markMarketplaceUpdated(selectedSource.name); - load(); + extensionManager.markMarketplaceUpdated(detailSource.name); + await load(); onChanged(); onStatus({ type: 'success', - text: t('Updated marketplace "{{name}}".', { - name: selectedSource.name, - }), + text: t('Updated marketplace "{{name}}".', { name: detailSource.name }), }); } catch (error) { onStatus({ @@ -168,84 +273,161 @@ export const MarketplacesTab = ({ } finally { setDetailLoading(false); } - }, [extensionManager, selectedSource, load, onChanged, onStatus]); + }, [extensionManager, detailSource, load, onChanged, onStatus]); - const handleDetailAction = useCallback( + const handleMarketplaceDetailAction = useCallback( (action: MarketplaceDetailAction) => { - if (!selectedSource) return; + if (!detailSource) return; if (action === 'browse') { - onBrowse(selectedSource.name); + onBrowse(detailSource.name); } else if (action === 'update') { void updateMarketplace(); } else if (action === 'remove') { setView('remove-confirm'); } }, - [selectedSource, onBrowse, updateMarketplace], + [detailSource, onBrowse, updateMarketplace], + ); + + const handleExtensionDetailAction = useCallback( + (action: ExtensionDetailAction) => { + if (action === 'uninstall') { + setView('remove-confirm'); + } else { + goToList(); + } + }, + [goToList], ); - // List keyboard. + // List keyboard: navigate entries, Enter dispatches by kind, d removes. useKeypress( (key) => { - const itemCount = sources.length + 1; // +1 for the add row + if (entries.length === 0) return; if (keyMatchers[Command.SELECTION_UP](key)) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); - } else if (keyMatchers[Command.SELECTION_DOWN](key)) { - setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); - } else if (key.name === 'return') { - if (selectedIndex === 0) { - onStatus(null); - setView('add'); - onLockChange(true); - } else if (selectedSource) { - void openDetail(selectedSource); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : entries.length - 1)); + return; + } + if (keyMatchers[Command.SELECTION_DOWN](key)) { + setSelectedIndex((prev) => (prev < entries.length - 1 ? prev + 1 : 0)); + return; + } + if (key.name === 'return') { + if (!selectedEntry) return; + onStatus(null); + switch (selectedEntry.kind) { + case 'install-extension': + setView('install-extension'); + onLockChange(true); + break; + case 'add-marketplace': + setView('add'); + onLockChange(true); + break; + case 'extension': + openExtensionDetail(selectedEntry.extension); + break; + case 'marketplace': + void openMarketplaceDetail(selectedEntry.source); + break; + default: + break; } - } else if ( + return; + } + if ( (key.sequence === 'd' || key.sequence === 'x') && !key.ctrl && - !key.meta + !key.meta && + selectedEntry?.kind === 'marketplace' ) { - if (selectedSource) { - setView('remove-confirm'); - onLockChange(true); - } + setDetailSource(selectedEntry.source); + setView('remove-confirm'); + onLockChange(true); } }, { isActive: isActive && view === 'list' }, ); - // Add input escape. + // Input views: Escape cancels. useKeypress( (key) => { if (key.name === 'escape' && !busy) { goToList(); } }, - { isActive: isActive && view === 'add' }, + { + isActive: isActive && (view === 'add' || view === 'install-extension'), + }, ); - // Detail: Escape goes back; the action selector (RadioButtonSelect) owns Enter. + // Detail views: Escape goes back; the selector owns Enter. useKeypress( (key) => { if (key.name === 'escape') { goToList(); } }, - { isActive: isActive && view === 'detail' }, + { + isActive: isActive && (view === 'detail' || view === 'extension-detail'), + }, ); - // Remove confirm. + // Remove/uninstall confirmation. useKeypress( (key) => { if (key.name === 'return' || key.sequence === 'y') { - removeSelected(); + if (detailExtension) { + void uninstallExtension(); + } else { + removeMarketplace(); + } } else if (key.name === 'escape' || key.sequence === 'n') { - goToList(); + // Return to the originating detail view if there was one. + if (detailExtension) { + setView('extension-detail'); + } else if (detailSource) { + setView('detail'); + } else { + goToList(); + } } }, { isActive: isActive && view === 'remove-confirm' }, ); + if (view === 'install-extension') { + return ( + + + {t('Install Extension')} + + + + {t('Enter extension source:')} + {t('Examples:')} + {' · owner/repo (GitHub)'} + + {' · git@github.com:owner/repo.git (SSH)'} + + {' · @scope/name (npm)'} + {' · ./path/to/extension'} + + + {busy ? ( + {t('Installing...')} + ) : ( + void submitInstall()} + isActive={isActive} + /> + )} + + ); + } + if (view === 'add') { return ( @@ -255,7 +437,7 @@ export const MarketplacesTab = ({ - {t('Enter marketplace source:')} + {t('Enter marketplace source (Claude format):')} {t('Examples:')} {' · owner/repo (GitHub)'} @@ -282,15 +464,57 @@ export const MarketplacesTab = ({ ); } - if (view === 'detail' && selectedSource) { + if (view === 'extension-detail' && detailExtension) { + const ext = detailExtension; + const parts: string[] = []; + const mcpCount = ext.mcpServers ? Object.keys(ext.mcpServers).length : 0; + if (mcpCount) parts.push(t('{{count}} MCP', { count: String(mcpCount) })); + if (ext.skills?.length) + parts.push(t('{{count}} Skills', { count: String(ext.skills.length) })); + if (ext.commands?.length) + parts.push( + t('{{count}} Commands', { count: String(ext.commands.length) }), + ); + if (ext.agents?.length) + parts.push(t('{{count}} Agents', { count: String(ext.agents.length) })); + + return ( + + + {t('Extension details')} + + + + {ext.name} + + {`v${ext.version}`} + {extensionSourceLabel(ext)} + + {t('Components: {{summary}}', { + summary: parts.length ? parts.join(' · ') : t('None'), + })} + + + + + ); + } + + if (view === 'detail' && detailSource) { const plugins = detailConfig?.plugins ?? []; const availableCount = plugins.length; - const installedNames = new Set( - (extensionManager?.getLoadedExtensions() ?? []).map((ext) => ext.name), - ); - const installedPlugins = plugins.filter((p) => installedNames.has(p.name)); + const installedNames = new Set(extensions.map((ext) => ext.name)); + const installedHere = plugins.filter((p) => installedNames.has(p.name)); const lastUpdated = formatDate( - selectedSource.lastUpdatedAt ?? selectedSource.addedAt, + detailSource.lastUpdatedAt ?? detailSource.addedAt, ); const actions: Array<{ @@ -300,7 +524,7 @@ export const MarketplacesTab = ({ }> = [ { key: 'browse', - label: t('Browse plugins ({{count}})', { + label: t('Browse extensions ({{count}})', { count: String(availableCount), }), value: 'browse', @@ -321,10 +545,10 @@ export const MarketplacesTab = ({ - {selectedSource.name} + {detailSource.name} - {redactUrlCredentials(selectedSource.source)} + {redactUrlCredentials(detailSource.source)} @@ -333,19 +557,19 @@ export const MarketplacesTab = ({ ) : detailConfig ? ( - {t('{{count}} available plugins', { + {t('{{count}} available extensions', { count: String(availableCount), })} - {installedPlugins.length > 0 ? ( + {installedHere.length > 0 ? ( - {t('Installed plugins ({{count}}):', { - count: String(installedPlugins.length), + {t('Installed extensions ({{count}}):', { + count: String(installedHere.length), })} - {installedPlugins.map((p) => ( + {installedHere.map((p) => ( @@ -369,7 +593,7 @@ export const MarketplacesTab = ({ items={actions} isFocused={isActive} showNumbers={false} - onSelect={handleDetailAction} + onSelect={handleMarketplaceDetailAction} /> ) : ( @@ -387,7 +611,7 @@ export const MarketplacesTab = ({ ]} isFocused={isActive} showNumbers={false} - onSelect={handleDetailAction} + onSelect={handleMarketplaceDetailAction} /> )} @@ -395,11 +619,15 @@ export const MarketplacesTab = ({ ); } - if (view === 'remove-confirm' && selectedSource) { + if (view === 'remove-confirm') { + const isExtension = !!detailExtension; + const name = isExtension ? detailExtension!.name : detailSource?.name; return ( - {t('Remove marketplace "{{name}}"?', { name: selectedSource.name })} + {isExtension + ? t('Uninstall extension "{{name}}"?', { name: name ?? '' }) + : t('Remove marketplace "{{name}}"?', { name: name ?? '' })} {t('Y/Enter to confirm · N/Esc to cancel')} @@ -409,54 +637,81 @@ export const MarketplacesTab = ({ } // List view. - return ( - - + const renderRow = ( + index: number, + label: string, + rightText?: string, + isAction = false, + ) => { + const isSelected = index === selectedIndex; + const labelColor = isSelected + ? theme.text.accent + : isAction + ? theme.text.link + : theme.text.primary; + return ( + - - {selectedIndex === 0 ? '●' : ' '} + + {isSelected ? '●' : ' '} - - {t('+ Add new marketplace')} - + + {label} + + {rightText ? ( + {rightText} + ) : null} - {sources.length === 0 ? ( - - - {t('No marketplaces added yet.')} + ); + }; + + const extensionsStart = 2; + const marketplacesStart = 2 + extensions.length; + + return ( + + {renderRow(0, t('+ Install new extension'), undefined, true)} + {renderRow( + 1, + t('+ Add new marketplace'), + t('Claude plugin marketplace'), + true, + )} + + {extensions.length > 0 ? ( + + + {t('Extensions')} ({extensions.length}) + {extensions.map((ext, i) => + renderRow(extensionsStart + i, ext.name, extensionSourceLabel(ext)), + )} - ) : ( + ) : null} + + {sources.length > 0 ? ( - {sources.map((source, index) => { - const isSelected = selectedIndex === index + 1; - return ( - - - - {isSelected ? '●' : ' '} - - - - - {source.name} - - - - {redactUrlCredentials(source.source)} ({source.type}) - - - ); - })} + + {t('Marketplaces')} ({sources.length}) + + {sources.map((source, j) => + renderRow( + marketplacesStart + j, + source.name, + `${redactUrlCredentials(source.source)} (${source.type})`, + ), + )} - )} + ) : null} + + {extensions.length === 0 && sources.length === 0 ? ( + + + {t('No extensions or marketplaces added yet.')} + + + ) : null} ); }; From cff6ad468f1c208f22a6a203914db162b036641a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 10:54:14 +0800 Subject: [PATCH 10/48] feat(extensions): update Marketplaces tab footer hint --- .../src/ui/components/extensions/ExtensionsManagerDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 51069c9ef11..34575c31fc8 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -45,7 +45,7 @@ function footerHint(tab: ExtensionsTab): string { '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', ); case EXTENSIONS_TABS.MARKETPLACES: - return t('↑↓ navigate · Enter open · d remove · Esc close'); + return t('↑↓ navigate · Enter select · d remove marketplace · Esc close'); default: return ''; } From 23f5dc1f3efba59f0a18f26fc2b5b494f7c182fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 11:39:14 +0800 Subject: [PATCH 11/48] feat(extensions): full extension actions in both tabs + context-aware Marketplaces footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a shared ExtensionActionsView (info + components + action menu + scope-select + uninstall-confirm) used by both the Installed and Marketplaces tabs, so the Marketplaces extension detail now offers the full set (Enable/Disable, Favorite, Mark for Update, Update Now, Uninstall) instead of just Uninstall/Back. - Add a new 'Change scope' action (Global/Project/Local) that re-scopes enablement (User vs workspace), available in both tabs. - Context-aware Marketplaces footer: shows 'Enter details' for an extension row, 'Enter open · d remove marketplace' for a marketplace row, and a neutral hint for the action rows — no longer says 'd remove marketplace' when an extension is selected. Adds a test for the full extension actions in the Marketplaces detail. --- .../ExtensionsManagerDialog.test.tsx | 29 ++ .../extensions/ExtensionsManagerDialog.tsx | 6 +- .../extensions/tabs/InstalledTab.tsx | 132 +-------- .../extensions/tabs/MarketplacesTab.tsx | 138 +++------ .../extensions/views/ExtensionActionsView.tsx | 264 ++++++++++++++++++ .../extensions/views/PluginDetailView.tsx | 8 +- 6 files changed, 358 insertions(+), 219 deletions(-) create mode 100644 packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 9a947e737b6..7ab0e1ae78c 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -55,6 +55,8 @@ const createManager = (o: ManagerOverrides = {}) => { getLoadedExtensions: vi.fn(() => extensions), getFavorites: vi.fn(() => o.favorites ?? []), getExtensionScopes: vi.fn(() => o.scopes ?? {}), + isFavorite: vi.fn((name: string) => (o.favorites ?? []).includes(name)), + getExtensionScope: vi.fn((name: string) => o.scopes?.[name] ?? 'user'), getMarketplaces: vi.fn(() => o.marketplaces ?? []), discoverPlugins: vi.fn().mockResolvedValue(o.discovered ?? []), toggleFavorite: vi.fn(() => true), @@ -362,6 +364,33 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Skills'); }); + it('shows full extension actions (incl. Change scope) in the Marketplaces extension detail', async () => { + const manager = createManager({ + extensions: [mockExtension('alpha', true)], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); // Extensions section row + }); + // Rows: Install ext (0), Add marketplace (1), alpha extension (2). + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); + await waitFor(() => { + expect(lastFrame()).toContain('● alpha'); + }); + stdin.write('\r'); // Enter -> shared extension actions view + await waitFor(() => { + expect(lastFrame()).toContain('Change scope'); + }); + const frame = lastFrame(); + expect(frame).toContain('Disable'); // alpha is active + expect(frame).toContain('Add to Favorites'); + expect(frame).toContain('Mark for Update'); + expect(frame).toContain('Uninstall'); + }); + it('shows a CC-style marketplace detail with installed plugins and actions', async () => { const manager = createManager({ extensions: [mockExtension('pdf', true)], // installed, belongs to Skills diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 34575c31fc8..ce7deaa4d52 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -71,10 +71,13 @@ export function ExtensionsManagerDialog({ // When set, the Discover tab is restricted to this marketplace (set by the // Marketplaces tab's "Browse plugins" action; cleared on manual tab switch). const [discoverFilter, setDiscoverFilter] = useState(null); + // Optional context-aware footer hint provided by the active tab. + const [tabFooter, setTabFooter] = useState(null); const cycleTab = useCallback((direction: 1 | -1) => { setStatus(null); setDiscoverFilter(null); + setTabFooter(null); setActiveTab((current) => { const index = TABS.findIndex((tab) => tab.id === current); const next = (index + direction + TABS.length) % TABS.length; @@ -173,6 +176,7 @@ export function ExtensionsManagerDialog({ onStatus={setStatus} onChanged={bumpReload} onBrowse={handleBrowseMarketplace} + onFooter={setTabFooter} reloadSignal={reloadSignal} /> )} @@ -195,7 +199,7 @@ export function ExtensionsManagerDialog({ {tabLocked ? t('Enter to select · Esc to go back') - : footerHint(activeTab)} + : (tabFooter ?? footerHint(activeTab))} diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 2920805cf10..60a500e963c 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -23,18 +23,13 @@ import { SettingScope as CliSettingScope, } from '../../../../config/settings.js'; import { getErrorMessage } from '../../../../utils/errors.js'; -import { ExtensionUpdateState } from '../../../state/extensions.js'; import type { InstalledItem, InstalledGroup, InstalledMcpInfo, } from '../types.js'; -import { - PluginDetailView, - type PluginDetailAction, -} from '../views/PluginDetailView.js'; import { McpDetailView } from '../views/McpDetailView.js'; -import { UninstallConfirmStep } from '../steps/UninstallConfirmStep.js'; +import { ExtensionActionsView } from '../views/ExtensionActionsView.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('INSTALLED_TAB'); @@ -65,11 +60,7 @@ const groupLabel = (group: InstalledGroup): string => { } }; -type InstalledView = - | 'list' - | 'plugin-detail' - | 'mcp-detail' - | 'uninstall-confirm'; +type InstalledView = 'list' | 'plugin-detail' | 'mcp-detail'; interface InstalledTabProps { config: Config; @@ -340,84 +331,6 @@ export const InstalledTab = ({ [extensionManager, load, onStatus], ); - const handlePluginAction = useCallback( - async (action: PluginDetailAction) => { - if (!selectedItem || selectedItem.kind !== 'plugin' || !extensionManager) - return; - const item = selectedItem; - switch (action) { - case 'toggle': - await togglePlugin(item); - goToList(); - break; - case 'favorite': - await toggleFavorite(item); - break; - case 'mark-update': - try { - await extensionManager.checkForAllExtensionUpdates(() => {}); - onStatus({ - type: 'info', - text: t('Checked "{{name}}" for updates.', { name: item.name }), - }); - } catch (error) { - onStatus({ type: 'error', text: getErrorMessage(error) }); - } - break; - case 'update': - try { - await extensionManager.updateExtension( - item.extension, - ExtensionUpdateState.UPDATE_AVAILABLE, - () => {}, - ); - onStatus({ - type: 'success', - text: t('Updated "{{name}}".', { name: item.name }), - }); - await load(); - goToList(); - } catch (error) { - onStatus({ type: 'error', text: getErrorMessage(error) }); - } - break; - case 'uninstall': - setView('uninstall-confirm'); - break; - default: - break; - } - }, - [ - selectedItem, - extensionManager, - togglePlugin, - toggleFavorite, - onStatus, - load, - goToList, - ], - ); - - const handleUninstall = useCallback( - async (extension: Extension) => { - if (!extensionManager) return; - try { - await extensionManager.uninstallExtension(extension.name, false); - onStatus({ - type: 'success', - text: t('Uninstalled "{{name}}".', { name: extension.name }), - }); - await load(); - goToList(); - } catch (error) { - onStatus({ type: 'error', text: getErrorMessage(error) }); - goToList(); - } - }, - [extensionManager, load, onStatus, goToList], - ); - // List keyboard handling. useKeypress( (key) => { @@ -451,22 +364,6 @@ export const InstalledTab = ({ }, { isActive: isActive && view === 'mcp-detail' }, ); - useKeypress( - (key) => { - if (key.name === 'escape') { - goToList(); - } - }, - { isActive: isActive && view === 'plugin-detail' }, - ); - - const hasUpdateAvailable = useMemo(() => { - if (!selectedItem || selectedItem.kind !== 'plugin') return false; - return ( - extensionsUpdateState.get(selectedItem.name) === - ExtensionUpdateState.UPDATE_AVAILABLE - ); - }, [selectedItem, extensionsUpdateState]); if (loading) { return {t('Loading...')}; @@ -474,13 +371,14 @@ export const InstalledTab = ({ if (view === 'plugin-detail' && selectedItem?.kind === 'plugin') { return ( - ); } @@ -489,16 +387,6 @@ export const InstalledTab = ({ return ; } - if (view === 'uninstall-confirm' && selectedItem?.kind === 'plugin') { - return ( - setView('plugin-detail')} - /> - ); - } - if (items.length === 0) { return ( diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx index 28bf5d764db..922a0aed433 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -22,6 +22,7 @@ import { createDebugLogger, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { ExtensionActionsView } from '../views/ExtensionActionsView.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('MARKETPLACES_TAB'); @@ -34,7 +35,6 @@ type MarketplacesView = | 'extension-detail' | 'remove-confirm'; type MarketplaceDetailAction = 'browse' | 'update' | 'remove'; -type ExtensionDetailAction = 'uninstall' | 'back'; // Flat, navigable entries shown on the Marketplaces tab list. type Entry = @@ -51,6 +51,8 @@ interface MarketplacesTabProps { onChanged: () => void; /** Switch to the Discover tab filtered to the given marketplace. */ onBrowse: (marketplaceName: string) => void; + /** Provide a context-aware footer hint for the list (null = default). */ + onFooter: (hint: string | null) => void; reloadSignal: number; } @@ -74,6 +76,7 @@ export const MarketplacesTab = ({ onStatus, onChanged, onBrowse, + onFooter, reloadSignal, }: MarketplacesTabProps) => { const [sources, setSources] = useState([]); @@ -133,6 +136,25 @@ export const MarketplacesTab = ({ const selectedEntry = entries[selectedIndex]; + // Context-aware footer hint based on the highlighted row (list view only). + useEffect(() => { + if (!isActive || view !== 'list') { + onFooter(null); + return; + } + const kind = selectedEntry?.kind; + if (kind === 'marketplace') { + onFooter( + t('↑↓ navigate · Enter open · d remove marketplace · Esc close'), + ); + } else if (kind === 'extension') { + onFooter(t('↑↓ navigate · Enter details · Esc close')); + } else { + onFooter(t('↑↓ navigate · Enter select · Esc close')); + } + return () => onFooter(null); + }, [isActive, view, selectedEntry?.kind, onFooter]); + const goToList = useCallback(() => { setView('list'); setInput(''); @@ -231,27 +253,6 @@ export const MarketplacesTab = ({ goToList(); }, [extensionManager, detailSource, onStatus, load, onChanged, goToList]); - const uninstallExtension = useCallback(async () => { - if (!extensionManager || !detailExtension) return; - try { - await extensionManager.uninstallExtension(detailExtension.name, false); - onStatus({ - type: 'success', - text: t('Uninstalled extension "{{name}}".', { - name: detailExtension.name, - }), - }); - await load(); - onChanged(); - } catch (error) { - onStatus({ - type: 'error', - text: redactUrlCredentials(getErrorMessage(error)), - }); - } - goToList(); - }, [extensionManager, detailExtension, onStatus, load, onChanged, goToList]); - const updateMarketplace = useCallback(async () => { if (!extensionManager || !detailSource) return; setDetailLoading(true); @@ -289,17 +290,6 @@ export const MarketplacesTab = ({ [detailSource, onBrowse, updateMarketplace], ); - const handleExtensionDetailAction = useCallback( - (action: ExtensionDetailAction) => { - if (action === 'uninstall') { - setView('remove-confirm'); - } else { - goToList(); - } - }, - [goToList], - ); - // List keyboard: navigate entries, Enter dispatches by kind, d removes. useKeypress( (key) => { @@ -361,36 +351,24 @@ export const MarketplacesTab = ({ }, ); - // Detail views: Escape goes back; the selector owns Enter. + // Marketplace detail: Escape goes back; the selector owns Enter. (The + // extension detail view owns its own keyboard handling.) useKeypress( (key) => { if (key.name === 'escape') { goToList(); } }, - { - isActive: isActive && (view === 'detail' || view === 'extension-detail'), - }, + { isActive: isActive && view === 'detail' }, ); - // Remove/uninstall confirmation. + // Remove-marketplace confirmation. useKeypress( (key) => { if (key.name === 'return' || key.sequence === 'y') { - if (detailExtension) { - void uninstallExtension(); - } else { - removeMarketplace(); - } + removeMarketplace(); } else if (key.name === 'escape' || key.sequence === 'n') { - // Return to the originating detail view if there was one. - if (detailExtension) { - setView('extension-detail'); - } else if (detailSource) { - setView('detail'); - } else { - goToList(); - } + goToList(); } }, { isActive: isActive && view === 'remove-confirm' }, @@ -465,46 +443,18 @@ export const MarketplacesTab = ({ } if (view === 'extension-detail' && detailExtension) { - const ext = detailExtension; - const parts: string[] = []; - const mcpCount = ext.mcpServers ? Object.keys(ext.mcpServers).length : 0; - if (mcpCount) parts.push(t('{{count}} MCP', { count: String(mcpCount) })); - if (ext.skills?.length) - parts.push(t('{{count}} Skills', { count: String(ext.skills.length) })); - if (ext.commands?.length) - parts.push( - t('{{count}} Commands', { count: String(ext.commands.length) }), - ); - if (ext.agents?.length) - parts.push(t('{{count}} Agents', { count: String(ext.agents.length) })); - return ( - - - {t('Extension details')} - - - - {ext.name} - - {`v${ext.version}`} - {extensionSourceLabel(ext)} - - {t('Components: {{summary}}', { - summary: parts.length ? parts.join(' · ') : t('None'), - })} - - - - + { + void load(); + onChanged(); + }} + onExit={goToList} + /> ); } @@ -620,14 +570,12 @@ export const MarketplacesTab = ({ } if (view === 'remove-confirm') { - const isExtension = !!detailExtension; - const name = isExtension ? detailExtension!.name : detailSource?.name; return ( - {isExtension - ? t('Uninstall extension "{{name}}"?', { name: name ?? '' }) - : t('Remove marketplace "{{name}}"?', { name: name ?? '' })} + {t('Remove marketplace "{{name}}"?', { + name: detailSource?.name ?? '', + })} {t('Y/Enter to confirm · N/Esc to cancel')} diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx new file mode 100644 index 00000000000..b76c414fe7a --- /dev/null +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -0,0 +1,264 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import { + type Config, + type Extension, + type ExtensionScope, + SettingScope, +} from '@qwen-code/qwen-code-core'; +import { getErrorMessage } from '../../../../utils/errors.js'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; +import { + PluginDetailView, + type PluginDetailAction, +} from './PluginDetailView.js'; +import { UninstallConfirmStep } from '../steps/UninstallConfirmStep.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +type SubView = 'detail' | 'scope-select' | 'uninstall-confirm'; + +interface ExtensionActionsViewProps { + config: Config; + /** The extension to manage (identified by name; live state is re-read). */ + extension: Extension; + isActive: boolean; + /** Current update state for this extension, if known. */ + updateState?: string; + onStatus: (status: StatusMessage | null) => void; + /** Ask the parent list to reload (state changed). */ + onReload: () => void; + /** Leave the detail and return to the list. */ + onExit: () => void; +} + +const SCOPE_LABEL: Record = { + user: 'User', + project: 'Project', + local: 'Local', +}; + +function scopeItems(): Array<{ + key: string; + label: string; + value: ExtensionScope; +}> { + return [ + { key: 'user', label: t('Global (User Scope)'), value: 'user' }, + { + key: 'project', + label: t('Project (All Collaborators)'), + value: 'project', + }, + { key: 'local', label: t('Local (Only You)'), value: 'local' }, + ]; +} + +export const ExtensionActionsView = ({ + config, + extension, + isActive, + updateState, + onStatus, + onReload, + onExit, +}: ExtensionActionsViewProps) => { + const manager = config.getExtensionManager(); + const [sub, setSub] = useState('detail'); + // Bumped after a mutation to re-read live favorite/scope/active state. + const [tick, setTick] = useState(0); + + const live = useMemo(() => { + const current = + manager?.getLoadedExtensions().find((e) => e.name === extension.name) ?? + extension; + return { + ext: current, + isFavorite: manager?.isFavorite(current.name) ?? false, + scope: (manager?.getExtensionScope(current.name) ?? + 'user') as ExtensionScope, + }; + // tick forces a re-read after mutations. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manager, extension, tick]); + + const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; + + const refresh = useCallback(() => { + setTick((value) => value + 1); + onReload(); + }, [onReload]); + + const handleAction = useCallback( + async (action: PluginDetailAction) => { + if (!manager) return; + const name = live.ext.name; + const settingScope = + live.scope === 'user' ? SettingScope.User : SettingScope.Workspace; + try { + switch (action) { + case 'toggle': + if (live.ext.isActive) { + await manager.disableExtension(name, settingScope); + } else { + await manager.enableExtension(name, settingScope); + } + onStatus({ + type: 'success', + text: t('"{{name}}" {{state}}.', { + name, + state: live.ext.isActive ? t('disabled') : t('enabled'), + }), + }); + refresh(); + break; + case 'favorite': { + const now = manager.toggleFavorite(name); + onStatus({ + type: 'info', + text: now + ? t('Added "{{name}}" to favorites.', { name }) + : t('Removed "{{name}}" from favorites.', { name }), + }); + refresh(); + break; + } + case 'change-scope': + setSub('scope-select'); + break; + case 'mark-update': + await manager.checkForAllExtensionUpdates(() => {}); + onStatus({ + type: 'info', + text: t('Checked "{{name}}" for updates.', { name }), + }); + break; + case 'update': + await manager.updateExtension( + live.ext, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + onStatus({ + type: 'success', + text: t('Updated "{{name}}".', { name }), + }); + refresh(); + break; + case 'uninstall': + setSub('uninstall-confirm'); + break; + default: + break; + } + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, + [manager, live, onStatus, refresh], + ); + + const handleScope = useCallback( + async (scope: ExtensionScope) => { + if (!manager) return; + const name = live.ext.name; + try { + manager.setExtensionScope(name, scope); + // Apply enablement: Global -> User; Project/Local -> workspace only. + if (scope === 'user') { + await manager.enableExtension(name, SettingScope.User); + } else { + await manager.disableExtension(name, SettingScope.User); + await manager.enableExtension(name, SettingScope.Workspace); + } + onStatus({ + type: 'success', + text: t('Set "{{name}}" scope to {{scope}}.', { + name, + scope: t(SCOPE_LABEL[scope]), + }), + }); + refresh(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + setSub('detail'); + }, + [manager, live, onStatus, refresh], + ); + + const handleUninstall = useCallback( + async (ext: Extension) => { + if (!manager) return; + try { + await manager.uninstallExtension(ext.name, false); + onStatus({ + type: 'success', + text: t('Uninstalled "{{name}}".', { name: ext.name }), + }); + onReload(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + onExit(); + }, + [manager, onStatus, onReload, onExit], + ); + + // Escape: from the detail leaves; from a sub-view returns to the detail. + useKeypress( + (key) => { + if (key.name === 'escape') { + if (sub === 'detail') onExit(); + else setSub('detail'); + } + }, + { isActive: isActive && sub !== 'uninstall-confirm' }, + ); + + if (sub === 'scope-select') { + return ( + + + {t('Change scope for "{{name}}":', { name: live.ext.name })} + + void handleScope(scope)} + /> + + ); + } + + if (sub === 'uninstall-confirm') { + return ( + setSub('detail')} + /> + ); + } + + return ( + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx index c9660d2c248..f51c1875962 100644 --- a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx +++ b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx @@ -17,6 +17,7 @@ import { t } from '../../../../i18n/index.js'; export type PluginDetailAction = | 'toggle' | 'favorite' + | 'change-scope' | 'mark-update' | 'update' | 'uninstall'; @@ -89,6 +90,11 @@ export const PluginDetailView = ({ label: isFavorite ? t('Remove from Favorites') : t('Add to Favorites'), value: 'favorite', }, + { + key: 'change-scope', + label: t('Change scope'), + value: 'change-scope', + }, { key: 'mark-update', label: t('Mark for Update'), @@ -135,7 +141,7 @@ export const PluginDetailView = ({ onSelect={onAction} /> - {t('Note: Uninstall permanently removes this plugin.')} + {t('Note: Uninstall permanently removes this extension.')} From dde9c5993c4e4897dca6be18179005c26ad8699c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 12:12:54 +0800 Subject: [PATCH 12/48] feat(extensions): rename Marketplaces tab to Sources, hide Favorites + fix enable/disable in Sources detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the user-visible tab label 'Marketplaces' -> 'Sources' (TabBar + TABS). The in-tab 'Marketplaces' section header (grouping marketplace sources) is kept. Also update the Discover empty-state hint to point at the 'Sources' tab. - Hide the Add/Remove Favorites action in the Sources extension detail via a showFavorite prop (default true; Installed keeps it). - Fix a stale enable/disable label in the Sources extension detail: ExtensionActionsView re-read enablement through the manager cache keyed by a tick, but refreshCache() briefly empties that cache, so the read raced and fell back to the stale extension prop (isActive: true). It now holds authoritative local state (enabled/isFavorite/scope) updated optimistically after each action — no cache read-back. The Installed tab was immune only because it fed a fresh extension object each load. Adds regression tests for the enable/disable toggle staying in sync and for change-scope re-scoping + re-enabling a disabled extension. --- .../ExtensionsManagerDialog.test.tsx | 89 +++++++++++++++++- .../extensions/ExtensionsManagerDialog.tsx | 2 +- .../src/ui/components/extensions/TabBar.tsx | 2 +- .../extensions/tabs/DiscoverTab.tsx | 4 +- .../extensions/tabs/MarketplacesTab.tsx | 1 + .../extensions/views/ExtensionActionsView.tsx | 92 +++++++++---------- .../extensions/views/PluginDetailView.tsx | 21 +++-- 7 files changed, 150 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 7ab0e1ae78c..fae4520c3d0 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -15,6 +15,7 @@ import { SettingsContext } from '../../contexts/SettingsContext.js'; import { ShellFocusContext } from '../../contexts/ShellFocusContext.js'; import { LoadedSettings } from '../../../config/settings.js'; import type { UIState } from '../../contexts/UIStateContext.js'; +import { SettingScope } from '@qwen-code/qwen-code-core'; import type { Config, Extension, @@ -129,7 +130,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { const frame = lastFrame(); expect(frame).toContain('Discover'); expect(frame).toContain('Installed'); - expect(frame).toContain('Marketplaces'); + expect(frame).toContain('Sources'); }); it('shows discovered plugins on the Discover tab', async () => { @@ -364,7 +365,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Skills'); }); - it('shows full extension actions (incl. Change scope) in the Marketplaces extension detail', async () => { + it('shows extension actions without Favorites in the Sources extension detail', async () => { const manager = createManager({ extensions: [mockExtension('alpha', true)], }); @@ -386,9 +387,91 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }); const frame = lastFrame(); expect(frame).toContain('Disable'); // alpha is active - expect(frame).toContain('Add to Favorites'); expect(frame).toContain('Mark for Update'); expect(frame).toContain('Uninstall'); + // Favorites is omitted in the Sources tab. + expect(frame).not.toContain('Favorites'); + }); + + it('toggles enable/disable in the Sources extension detail and keeps the label in sync', async () => { + const manager = createManager({ + extensions: [mockExtension('alpha', true)], + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); + await waitFor(() => { + expect(lastFrame()).toContain('● alpha'); + }); + stdin.write('\r'); // open detail + await waitFor(() => { + expect(lastFrame()).toContain('Disable'); // active -> Disable + }); + stdin.write('\r'); // Disable (toggle is the first action) + await waitFor(() => { + expect(manager.disableExtension).toHaveBeenCalled(); + expect(lastFrame()).toContain('Enable'); // label flipped + }); + stdin.write('\r'); // Enable (toggle still first) + await waitFor(() => { + expect(manager.enableExtension).toHaveBeenCalled(); + expect(lastFrame()).toContain('Disable'); // flipped back, not stuck + }); + }); + + it('change-scope re-scopes and re-enables a disabled extension in the Sources detail', async () => { + const manager = createManager({ + extensions: [mockExtension('alpha', false)], // disabled + }); + const { stdin, lastFrame } = renderDialog(createConfig(manager), { + initialTab: EXTENSIONS_TABS.MARKETPLACES, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha'); + }); + stdin.write('\x1B[B'); + stdin.write('\x1B[B'); + await waitFor(() => { + expect(lastFrame()).toContain('● alpha'); + }); + stdin.write('\r'); // open detail + await waitFor(() => { + expect(lastFrame()).toContain('Enable'); // disabled -> Enable label + }); + // Actions (no favorite in Sources): toggle(0), change-scope(1), ... + stdin.write('\x1B[B'); // highlight Change scope + stdin.write('\r'); // enter scope-select + await waitFor(() => { + expect(lastFrame()).toContain('Project (All Collaborators)'); + }); + stdin.write('\x1B[B'); // highlight Project (index 1) + stdin.write('\r'); // select Project + await waitFor(() => { + expect(manager.setExtensionScope).toHaveBeenCalledWith( + 'alpha', + 'project', + ); + }); + // Project/Local scope => disable at User, enable at Workspace. + expect(manager.disableExtension).toHaveBeenCalledWith( + 'alpha', + SettingScope.User, + ); + expect(manager.enableExtension).toHaveBeenCalledWith( + 'alpha', + SettingScope.Workspace, + ); + // Back on the detail: scope is Project and the extension is now enabled. + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Project'); + expect(frame).toContain('Disable'); // enabled -> Disable label + }); }); it('shows a CC-style marketplace detail with installed plugins and actions', async () => { diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index ce7deaa4d52..3ce8dafb99b 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -30,7 +30,7 @@ export interface StatusMessage { const TABS: ExtensionsTabDef[] = [ { id: EXTENSIONS_TABS.INSTALLED, label: 'Installed' }, { id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' }, - { id: EXTENSIONS_TABS.MARKETPLACES, label: 'Marketplaces' }, + { id: EXTENSIONS_TABS.MARKETPLACES, label: 'Sources' }, ]; // Literal t() calls keep the footer hints extractable for translation. diff --git a/packages/cli/src/ui/components/extensions/TabBar.tsx b/packages/cli/src/ui/components/extensions/TabBar.tsx index 99728e5d30f..bb7de562a1e 100644 --- a/packages/cli/src/ui/components/extensions/TabBar.tsx +++ b/packages/cli/src/ui/components/extensions/TabBar.tsx @@ -28,7 +28,7 @@ function tabLabel(id: ExtensionsTab): string { case EXTENSIONS_TABS.INSTALLED: return t('Installed'); case EXTENSIONS_TABS.MARKETPLACES: - return t('Marketplaces'); + return t('Sources'); default: return id; } diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index c07c421e0a0..4a02b415d9c 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -521,9 +521,7 @@ export const DiscoverTab = ({ {t('No extensions discovered.')} - {t( - 'Add a marketplace in the Marketplaces tab to discover extensions.', - )} + {t('Add a marketplace in the Sources tab to discover extensions.')} ); diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx index 922a0aed433..8477597da94 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -448,6 +448,7 @@ export const MarketplacesTab = ({ config={config} extension={detailExtension} isActive={isActive} + showFavorite={false} onStatus={onStatus} onReload={() => { void load(); diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index b76c414fe7a..cb07b6d445d 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -29,11 +29,13 @@ type SubView = 'detail' | 'scope-select' | 'uninstall-confirm'; interface ExtensionActionsViewProps { config: Config; - /** The extension to manage (identified by name; live state is re-read). */ + /** The extension to manage. A fresh mount is expected per detail open. */ extension: Extension; isActive: boolean; /** Current update state for this extension, if known. */ updateState?: string; + /** Whether to offer the favorite toggle (hidden in the Sources tab). */ + showFavorite?: boolean; onStatus: (status: StatusMessage | null) => void; /** Ask the parent list to reload (state changed). */ onReload: () => void; @@ -68,68 +70,61 @@ export const ExtensionActionsView = ({ extension, isActive, updateState, + showFavorite = true, onStatus, onReload, onExit, }: ExtensionActionsViewProps) => { const manager = config.getExtensionManager(); const [sub, setSub] = useState('detail'); - // Bumped after a mutation to re-read live favorite/scope/active state. - const [tick, setTick] = useState(0); - - const live = useMemo(() => { - const current = - manager?.getLoadedExtensions().find((e) => e.name === extension.name) ?? - extension; - return { - ext: current, - isFavorite: manager?.isFavorite(current.name) ?? false, - scope: (manager?.getExtensionScope(current.name) ?? - 'user') as ExtensionScope, - }; - // tick forces a re-read after mutations. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [manager, extension, tick]); + // Authoritative local state. Initialised once on mount and updated + // optimistically after each action — we do NOT read enablement back through + // the manager's cache, which is briefly empty during refreshCache(). + const [enabled, setEnabled] = useState(extension.isActive); + const [isFavorite, setIsFavorite] = useState( + () => manager?.isFavorite(extension.name) ?? false, + ); + const [scope, setScope] = useState( + () => manager?.getExtensionScope(extension.name) ?? 'user', + ); const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; - const refresh = useCallback(() => { - setTick((value) => value + 1); - onReload(); - }, [onReload]); + const settingScopeFor = (s: ExtensionScope) => + s === 'user' ? SettingScope.User : SettingScope.Workspace; const handleAction = useCallback( async (action: PluginDetailAction) => { if (!manager) return; - const name = live.ext.name; - const settingScope = - live.scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const name = extension.name; try { switch (action) { case 'toggle': - if (live.ext.isActive) { - await manager.disableExtension(name, settingScope); + if (enabled) { + await manager.disableExtension(name, settingScopeFor(scope)); } else { - await manager.enableExtension(name, settingScope); + await manager.enableExtension(name, settingScopeFor(scope)); } + setEnabled(!enabled); onStatus({ type: 'success', text: t('"{{name}}" {{state}}.', { name, - state: live.ext.isActive ? t('disabled') : t('enabled'), + state: enabled ? t('disabled') : t('enabled'), }), }); - refresh(); + onReload(); break; case 'favorite': { const now = manager.toggleFavorite(name); + setIsFavorite(now); onStatus({ type: 'info', text: now ? t('Added "{{name}}" to favorites.', { name }) : t('Removed "{{name}}" from favorites.', { name }), }); - refresh(); + onReload(); break; } case 'change-scope': @@ -144,7 +139,7 @@ export const ExtensionActionsView = ({ break; case 'update': await manager.updateExtension( - live.ext, + extension, ExtensionUpdateState.UPDATE_AVAILABLE, () => {}, ); @@ -152,7 +147,7 @@ export const ExtensionActionsView = ({ type: 'success', text: t('Updated "{{name}}".', { name }), }); - refresh(); + onReload(); break; case 'uninstall': setSub('uninstall-confirm'); @@ -164,36 +159,38 @@ export const ExtensionActionsView = ({ onStatus({ type: 'error', text: getErrorMessage(error) }); } }, - [manager, live, onStatus, refresh], + [manager, extension, enabled, scope, onStatus, onReload], ); const handleScope = useCallback( - async (scope: ExtensionScope) => { + async (newScope: ExtensionScope) => { if (!manager) return; - const name = live.ext.name; + const name = extension.name; try { - manager.setExtensionScope(name, scope); + manager.setExtensionScope(name, newScope); // Apply enablement: Global -> User; Project/Local -> workspace only. - if (scope === 'user') { + if (newScope === 'user') { await manager.enableExtension(name, SettingScope.User); } else { await manager.disableExtension(name, SettingScope.User); await manager.enableExtension(name, SettingScope.Workspace); } + setScope(newScope); + setEnabled(true); onStatus({ type: 'success', text: t('Set "{{name}}" scope to {{scope}}.', { name, - scope: t(SCOPE_LABEL[scope]), + scope: t(SCOPE_LABEL[newScope]), }), }); - refresh(); + onReload(); } catch (error) { onStatus({ type: 'error', text: getErrorMessage(error) }); } setSub('detail'); }, - [manager, live, onStatus, refresh], + [manager, extension, onStatus, onReload], ); const handleUninstall = useCallback( @@ -229,13 +226,13 @@ export const ExtensionActionsView = ({ return ( - {t('Change scope for "{{name}}":', { name: live.ext.name })} + {t('Change scope for "{{name}}":', { name: extension.name })} void handleScope(scope)} + onSelect={(value) => void handleScope(value)} /> ); @@ -244,7 +241,7 @@ export const ExtensionActionsView = ({ if (sub === 'uninstall-confirm') { return ( setSub('detail')} /> @@ -253,9 +250,10 @@ export const ExtensionActionsView = ({ return ( void; } @@ -69,6 +71,7 @@ export const PluginDetailView = ({ isFavorite, hasUpdateAvailable, isFocused, + showFavorite = true, onAction, }: PluginDetailViewProps) => { const ext = extension; @@ -85,11 +88,17 @@ export const PluginDetailView = ({ label: isActive ? t('Disable') : t('Enable'), value: 'toggle', }, - { - key: 'favorite', - label: isFavorite ? t('Remove from Favorites') : t('Add to Favorites'), - value: 'favorite', - }, + ...(showFavorite + ? [ + { + key: 'favorite', + label: isFavorite + ? t('Remove from Favorites') + : t('Add to Favorites'), + value: 'favorite' as const, + }, + ] + : []), { key: 'change-scope', label: t('Change scope'), @@ -110,7 +119,7 @@ export const PluginDetailView = ({ }, ]; return items; - }, [isActive, isFavorite, hasUpdateAvailable]); + }, [isActive, isFavorite, hasUpdateAvailable, showFavorite]); return ( From 3f891f9a453e51b172bd02f2272559d824bdbdbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 14:32:40 +0800 Subject: [PATCH 13/48] feat(extensions): group Sources action rows + show current scope in selector - Add an 'Add new' section heading above the '+ Install new extension' and '+ Add new marketplace' rows on the Sources tab, so those two actions are grouped like the Extensions and Marketplaces sections. - In the Change scope selector, default the cursor to the extension's current scope and show a 'Current: ' line. Previously it always defaulted to Global, so after changing scope it was unclear whether the change took effect. Applies to both the Sources and Installed extension detail (shared ExtensionActionsView). Updates tests to assert the 'Add new' section title renders and that re-entering the scope selector reflects the now-current scope. --- .../ExtensionsManagerDialog.test.tsx | 10 +++++++++ .../extensions/tabs/MarketplacesTab.tsx | 19 ++++++++++------- .../extensions/views/ExtensionActionsView.tsx | 21 +++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index fae4520c3d0..7421bc88812 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -359,6 +359,9 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('Add new marketplace'); }); const frame = lastFrame(); + // 'Add new' is the section title for the action rows; it also appears inside + // '+ Add new marketplace', so assert it renders at least twice (title + row). + expect((frame?.split('Add new').length ?? 1) - 1).toBeGreaterThanOrEqual(2); expect(frame).toContain('Install new extension'); expect(frame).toContain('Claude plugin marketplace'); // (Claude) annotation expect(frame).toContain('Marketplaces'); // section header @@ -472,6 +475,13 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Project'); expect(frame).toContain('Disable'); // enabled -> Disable label }); + // Re-entering the scope selector reflects the now-current scope (Project), + // not a default of Global — so the change is visibly in effect. + stdin.write('\x1B[B'); // highlight Change scope + stdin.write('\r'); // enter scope-select again + await waitFor(() => { + expect(lastFrame()).toContain('Current: Project (All Collaborators)'); + }); }); it('shows a CC-style marketplace detail with installed plugins and actions', async () => { diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx index 8477597da94..5dc55c1cfd2 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx @@ -620,13 +620,18 @@ export const MarketplacesTab = ({ return ( - {renderRow(0, t('+ Install new extension'), undefined, true)} - {renderRow( - 1, - t('+ Add new marketplace'), - t('Claude plugin marketplace'), - true, - )} + + + {t('Add new')} + + {renderRow(0, t('+ Install new extension'), undefined, true)} + {renderRow( + 1, + t('+ Add new marketplace'), + t('Claude plugin marketplace'), + true, + )} + {extensions.length > 0 ? ( diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index cb07b6d445d..418d4a4d846 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -223,13 +223,26 @@ export const ExtensionActionsView = ({ ); if (sub === 'scope-select') { + const items = scopeItems(); + // Default the cursor to the extension's current scope so the user can see + // what is in effect (and that a prior change took hold). + const currentIndex = Math.max( + 0, + items.findIndex((item) => item.value === scope), + ); return ( - - {t('Change scope for "{{name}}":', { name: extension.name })} - + + + {t('Change scope for "{{name}}":', { name: extension.name })} + + + {t('Current: {{scope}}', { scope: items[currentIndex].label })} + + void handleScope(value)} From aed6a930418db4fdeaaca5a2838b58ba132cd930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 15:53:10 +0800 Subject: [PATCH 14/48] fix(extensions): move uninstall note to confirm step; complete zh/zh-TW i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the 'Note: Uninstall permanently removes this extension.' warning out of the detail-view action list and into the uninstall confirmation step (replacing the near-synonymous 'This action cannot be undone.'). - Fix the Chinese/English mix in the extensions manager: the new multi-tab UI added ~104 English strings that had no locale entries, so they fell back to the English key at runtime. Add Simplified (zh) and Traditional (zh-TW) translations for all of them, plus the matching en.js base keys (en.js is the canonical superset; zh/zh-TW require strict key parity per check-i18n). Placeholders, keyboard tokens (Tab/Enter/Esc/Space/↑↓/·) and the ⚠ glyph are preserved across all locales. --- packages/cli/src/i18n/locales/en.js | 128 ++++++++++++++++++ packages/cli/src/i18n/locales/zh-TW.js | 124 +++++++++++++++++ packages/cli/src/i18n/locales/zh.js | 124 +++++++++++++++++ .../extensions/steps/UninstallConfirmStep.tsx | 4 +- .../extensions/views/PluginDetailView.tsx | 3 - 5 files changed, 378 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c395466fecf..d134e08dfe9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -8,6 +8,134 @@ // The key serves as both the translation key and the default English text export default { + // ========================================================================== + // Extensions manager dialog (Installed / Discover / Sources tabs) + // ========================================================================== + ' · {{marketplace}} (Tab to clear)': ' · {{marketplace}} (Tab to clear)', + '"{{name}}" {{state}}.': '"{{name}}" {{state}}.', + '(Tab / ←→ to switch)': '(Tab / ←→ to switch)', + '+ Add new marketplace': '+ Add new marketplace', + '+ Install new extension': '+ Install new extension', + Actions: 'Actions', + 'Add Marketplace': 'Add Marketplace', + 'Add a marketplace in the Sources tab to discover extensions.': + 'Add a marketplace in the Sources tab to discover extensions.', + 'Add new': 'Add new', + 'Add to Favorites': 'Add to Favorites', + 'Added "{{name}}" to favorites.': 'Added "{{name}}" to favorites.', + 'Added marketplace "{{name}}".': 'Added marketplace "{{name}}".', + 'Adding...': 'Adding...', + 'Back to extension list': 'Back to extension list', + 'Browse extensions ({{count}})': 'Browse extensions ({{count}})', + 'By: {{a}}': 'By: {{a}}', + 'Change scope': 'Change scope', + 'Change scope for "{{name}}":': 'Change scope for "{{name}}":', + 'Checked "{{name}}" for updates.': 'Checked "{{name}}" for updates.', + 'Claude plugin marketplace': 'Claude plugin marketplace', + Commands: 'Commands', + 'Components:': 'Components:', + 'Could not load this marketplace.': 'Could not load this marketplace.', + 'Current: {{scope}}': 'Current: {{scope}}', + Disabled: 'Disabled', + Discover: 'Discover', + 'Discover extensions': 'Discover extensions', + 'Discovering extensions...': 'Discovering extensions...', + 'Enter extension source:': 'Enter extension source:', + 'Enter marketplace source (Claude format):': + 'Enter marketplace source (Claude format):', + 'Examples:': 'Examples:', + 'Extension details': 'Extension details', + 'Extension v{{version}}': 'Extension v{{version}}', + 'Extensions are not available in this environment.': + 'Extensions are not available in this environment.', + 'Failed to open {{url}}': 'Failed to open {{url}}', + Favorites: 'Favorites', + 'Global (User Scope)': 'Global (User Scope)', + 'Install Extension': 'Install Extension', + 'Install for all collaborators on this repository (project scope)': + 'Install for all collaborators on this repository (project scope)', + 'Install for you (user scope)': 'Install for you (user scope)', + 'Install for you, in this repo only (local scope)': + 'Install for you, in this repo only (local scope)', + 'Install {{count}} extension(s) to which scope?': + 'Install {{count}} extension(s) to which scope?', + Installed: 'Installed', + 'Installed extension "{{name}}".': 'Installed extension "{{name}}".', + 'Installed extensions ({{count}}):': 'Installed extensions ({{count}}):', + 'Installed {{count}} extension(s).': 'Installed {{count}} extension(s).', + 'Installed {{ok}}, failed {{fail}}: {{detail}}': + 'Installed {{ok}}, failed {{fail}}: {{detail}}', + 'Installing...': 'Installing...', + 'Last updated: {{date}}': 'Last updated: {{date}}', + Local: 'Local', + 'Local (Only You)': 'Local (Only You)', + MCP: 'MCP', + 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}.', + 'MCP Server': 'MCP Server', + 'MCP servers': 'MCP servers', + 'Mark for Update': 'Mark for Update', + Marketplaces: 'Marketplaces', + 'No extensions discovered.': 'No extensions discovered.', + 'No extensions match your search.': 'No extensions match your search.', + 'No extensions or marketplaces added yet.': + 'No extensions or marketplaces added yet.', + 'No homepage available.': 'No homepage available.', + 'No installable extensions selected.': 'No installable extensions selected.', + 'No plugins or MCP servers installed.': + 'No plugins or MCP servers installed.', + None: 'None', + 'Note: Uninstall permanently removes this extension.': + 'Note: Uninstall permanently removes this extension.', + 'Open homepage': 'Open homepage', + 'Project (All Collaborators)': 'Project (All Collaborators)', + 'Remove from Favorites': 'Remove from Favorites', + 'Remove marketplace': 'Remove marketplace', + 'Remove marketplace "{{name}}"?': 'Remove marketplace "{{name}}"?', + 'Removed "{{name}}" from favorites.': 'Removed "{{name}}" from favorites.', + 'Removed marketplace "{{name}}".': 'Removed marketplace "{{name}}".', + 'Scope:': 'Scope:', + 'Set "{{name}}" scope to {{scope}}.': 'Set "{{name}}" scope to {{scope}}.', + Sources: 'Sources', + 'Transport:': 'Transport:', + 'Type to search · Space to toggle · Enter to view · Esc to go back': + 'Type to search · Space to toggle · Enter to view · Esc to go back', + Uninstall: 'Uninstall', + 'Uninstalled "{{name}}".': 'Uninstalled "{{name}}".', + 'Update Now': 'Update Now', + 'Update marketplace': 'Update marketplace', + 'Update marketplace (last updated {{date}})': + 'Update marketplace (last updated {{date}})', + 'Updated "{{name}}".': 'Updated "{{name}}".', + 'Updated marketplace "{{name}}".': 'Updated marketplace "{{name}}".', + 'Use the Discover tab to find and install plugins.': + 'Use the Discover tab to find and install plugins.', + 'Version: {{v}}': 'Version: {{v}}', + 'Will install:': 'Will install:', + 'Would open: {{url}}': 'Would open: {{url}}', + 'Y/Enter to confirm · N/Esc to cancel': + 'Y/Enter to confirm · N/Esc to cancel', + 'from {{marketplace}}': 'from {{marketplace}}', + installed: 'installed', + '{{count}} Agents': '{{count}} Agents', + '{{count}} Commands': '{{count}} Commands', + '{{count}} MCP': '{{count}} MCP', + '{{count}} Skills': '{{count}} Skills', + '{{count}} available extensions': '{{count}} available extensions', + '↑ more above': '↑ more above', + '↑↓ navigate · Enter details · Esc close': + '↑↓ navigate · Enter details · Esc close', + '↑↓ navigate · Enter open · d remove marketplace · Esc close': + '↑↓ navigate · Enter open · d remove marketplace · Esc close', + '↑↓ navigate · Enter select · Esc close': + '↑↓ navigate · Enter select · Esc close', + '↑↓ navigate · Enter select · d remove marketplace · Esc close': + '↑↓ navigate · Enter select · d remove marketplace · Esc close', + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close': + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', + '↓ more below': '↓ more below', + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.': + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.', + // ============================================================================ // Help / UI Components // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 89de88c23d1..9efd74ad6b1 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -9,6 +9,130 @@ // then extensively hand-corrected for Taiwan vocabulary conventions. // This file is the authoritative source — do not overwrite with auto-generated output. export default { + // ========================================================================== + // Extensions manager dialog (Installed / Discover / Sources tabs) + // ========================================================================== + ' · {{marketplace}} (Tab to clear)': ' · {{marketplace}}(Tab 清除)', + '"{{name}}" {{state}}.': '"{{name}}" {{state}}。', + '(Tab / ←→ to switch)': '(Tab / ←→ 切換)', + '+ Add new marketplace': '+ 新增市場來源', + '+ Install new extension': '+ 安裝新擴展', + Actions: '操作', + 'Add Marketplace': '新增市場來源', + 'Add a marketplace in the Sources tab to discover extensions.': + '在「來源」分頁中新增市場來源以發現擴展。', + 'Add new': '新增', + 'Add to Favorites': '加入收藏', + 'Added "{{name}}" to favorites.': '已將 "{{name}}" 加入收藏。', + 'Added marketplace "{{name}}".': '已新增市場來源 "{{name}}"。', + 'Adding...': '新增中...', + 'Back to extension list': '返回擴展清單', + 'Browse extensions ({{count}})': '瀏覽擴展({{count}})', + 'By: {{a}}': '作者:{{a}}', + 'Change scope': '變更作用域', + 'Change scope for "{{name}}":': '變更 "{{name}}" 的作用域:', + 'Checked "{{name}}" for updates.': '已檢查 "{{name}}" 的更新。', + 'Claude plugin marketplace': 'Claude 外掛市場', + Commands: '命令', + 'Components:': '元件:', + 'Could not load this marketplace.': '無法載入此市場來源。', + 'Current: {{scope}}': '目前:{{scope}}', + Disabled: '已禁用', + Discover: '發現', + 'Discover extensions': '發現擴展', + 'Discovering extensions...': '正在發現擴展...', + 'Enter extension source:': '輸入擴展來源:', + 'Enter marketplace source (Claude format):': + '輸入市場來源位址(Claude 格式):', + 'Examples:': '範例:', + 'Extension details': '擴展詳情', + 'Extension v{{version}}': '擴展 v{{version}}', + 'Extensions are not available in this environment.': '目前環境中擴展不可用。', + 'Failed to open {{url}}': '開啟 {{url}} 失敗', + Favorites: '收藏', + 'Global (User Scope)': '全域(使用者作用域)', + 'Install Extension': '安裝擴展', + 'Install for all collaborators on this repository (project scope)': + '為此儲存庫的所有協作者安裝(專案作用域)', + 'Install for you (user scope)': '僅為你安裝(使用者作用域)', + 'Install for you, in this repo only (local scope)': + '僅為你安裝,且僅在此儲存庫(本地作用域)', + 'Install {{count}} extension(s) to which scope?': + '將 {{count}} 個擴展安裝到哪個作用域?', + Installed: '已安裝', + 'Installed extension "{{name}}".': '已安裝擴展 "{{name}}"。', + 'Installed extensions ({{count}}):': '已安裝的擴展({{count}}):', + 'Installed {{count}} extension(s).': '已安裝 {{count}} 個擴展。', + 'Installed {{ok}}, failed {{fail}}: {{detail}}': + '成功 {{ok}} 個,失敗 {{fail}} 個:{{detail}}', + 'Installing...': '安裝中...', + 'Last updated: {{date}}': '最近更新:{{date}}', + Local: '本地', + 'Local (Only You)': '本地(僅你自己)', + MCP: 'MCP', + 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', + 'MCP Server': 'MCP 伺服器', + 'MCP servers': 'MCP 伺服器', + 'Mark for Update': '標記為待更新', + Marketplaces: '市場來源', + 'No extensions discovered.': '未發現任何擴展。', + 'No extensions match your search.': '沒有與搜尋相符的擴展。', + 'No extensions or marketplaces added yet.': '尚未新增任何擴展或市場來源。', + 'No homepage available.': '沒有可用的主頁。', + 'No installable extensions selected.': '未選取可安裝的擴展。', + 'No plugins or MCP servers installed.': '尚未安裝任何外掛或 MCP 伺服器。', + None: '無', + 'Note: Uninstall permanently removes this extension.': + '注意:卸載將永久移除此擴展。', + 'Open homepage': '開啟主頁', + 'Project (All Collaborators)': '專案(所有協作者)', + 'Remove from Favorites': '從收藏中移除', + 'Remove marketplace': '移除市場來源', + 'Remove marketplace "{{name}}"?': '移除市場來源 "{{name}}"?', + 'Removed "{{name}}" from favorites.': '已將 "{{name}}" 從收藏中移除。', + 'Removed marketplace "{{name}}".': '已移除市場來源 "{{name}}"。', + 'Scope:': '作用域:', + 'Set "{{name}}" scope to {{scope}}.': + '已將 "{{name}}" 的作用域設為 {{scope}}。', + Sources: '來源', + 'Transport:': '傳輸方式:', + 'Type to search · Space to toggle · Enter to view · Esc to go back': + '輸入以搜尋 · Space 切換 · Enter 查看 · Esc 返回', + Uninstall: '卸載', + 'Uninstalled "{{name}}".': '已卸載 "{{name}}"。', + 'Update Now': '立即更新', + 'Update marketplace': '更新市場來源', + 'Update marketplace (last updated {{date}})': + '更新市場來源(最近更新 {{date}})', + 'Updated "{{name}}".': '已更新 "{{name}}"。', + 'Updated marketplace "{{name}}".': '已更新市場來源 "{{name}}"。', + 'Use the Discover tab to find and install plugins.': + '使用「發現」分頁尋找並安裝擴展。', + 'Version: {{v}}': '版本:{{v}}', + 'Will install:': '將安裝:', + 'Would open: {{url}}': '將開啟:{{url}}', + 'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 確認 · N/Esc 取消', + 'from {{marketplace}}': '來自 {{marketplace}}', + installed: '已安裝', + '{{count}} Agents': '{{count}} 個智能體', + '{{count}} Commands': '{{count}} 個命令', + '{{count}} MCP': '{{count}} 個 MCP', + '{{count}} Skills': '{{count}} 個技能', + '{{count}} available extensions': '{{count}} 個可用擴展', + '↑ more above': '↑ 上方更多', + '↑↓ navigate · Enter details · Esc close': + '↑↓ 導覽 · Enter 查看詳情 · Esc 關閉', + '↑↓ navigate · Enter open · d remove marketplace · Esc close': + '↑↓ 導覽 · Enter 開啟 · d 移除市場來源 · Esc 關閉', + '↑↓ navigate · Enter select · Esc close': '↑↓ 導覽 · Enter 選擇 · Esc 關閉', + '↑↓ navigate · Enter select · d remove marketplace · Esc close': + '↑↓ 導覽 · Enter 選擇 · d 移除市場來源 · Esc 關閉', + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close': + '↑↓ 導覽 · Space 啟用/禁用 · f 收藏 · Enter 查看詳情 · Esc 關閉', + '↓ more below': '↓ 下方更多', + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.': + '⚠ 在安裝、更新或使用擴展前,請確保你信任它。我們無法驗證擴展包含哪些 MCP 伺服器、檔案或其他軟體,也無法保證其按預期運作。更多資訊請查看擴展主頁。', + '↑ to manage attachments': '↑ 管理附件', '← → select, Delete to remove, ↓ to exit': '← → 選擇,Delete 刪除,↓ 退出', 'Attachments: ': '附件:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 0cc3634400d..92422fc9471 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -7,6 +7,130 @@ // Chinese translations for Qwen Code CLI export default { + // ========================================================================== + // Extensions manager dialog (Installed / Discover / Sources tabs) + // ========================================================================== + ' · {{marketplace}} (Tab to clear)': ' · {{marketplace}}(Tab 清除)', + '"{{name}}" {{state}}.': '"{{name}}" {{state}}。', + '(Tab / ←→ to switch)': '(Tab / ←→ 切换)', + '+ Add new marketplace': '+ 添加新市场源', + '+ Install new extension': '+ 安装新扩展', + Actions: '操作', + 'Add Marketplace': '添加市场源', + 'Add a marketplace in the Sources tab to discover extensions.': + '在“来源”标签页中添加市场源以发现扩展。', + 'Add new': '新增', + 'Add to Favorites': '添加到收藏', + 'Added "{{name}}" to favorites.': '已将 "{{name}}" 添加到收藏。', + 'Added marketplace "{{name}}".': '已添加市场源 "{{name}}"。', + 'Adding...': '添加中...', + 'Back to extension list': '返回扩展列表', + 'Browse extensions ({{count}})': '浏览扩展({{count}})', + 'By: {{a}}': '作者:{{a}}', + 'Change scope': '更改作用域', + 'Change scope for "{{name}}":': '更改 "{{name}}" 的作用域:', + 'Checked "{{name}}" for updates.': '已检查 "{{name}}" 的更新。', + 'Claude plugin marketplace': 'Claude 插件市场', + Commands: '命令', + 'Components:': '组件:', + 'Could not load this marketplace.': '无法加载该市场源。', + 'Current: {{scope}}': '当前:{{scope}}', + Disabled: '已禁用', + Discover: '发现', + 'Discover extensions': '发现扩展', + 'Discovering extensions...': '正在发现扩展...', + 'Enter extension source:': '输入扩展来源:', + 'Enter marketplace source (Claude format):': + '输入市场源地址(Claude 格式):', + 'Examples:': '示例:', + 'Extension details': '扩展详情', + 'Extension v{{version}}': '扩展 v{{version}}', + 'Extensions are not available in this environment.': '当前环境中扩展不可用。', + 'Failed to open {{url}}': '打开 {{url}} 失败', + Favorites: '收藏', + 'Global (User Scope)': '全局(用户作用域)', + 'Install Extension': '安装扩展', + 'Install for all collaborators on this repository (project scope)': + '为此仓库的所有协作者安装(项目作用域)', + 'Install for you (user scope)': '仅为你安装(用户作用域)', + 'Install for you, in this repo only (local scope)': + '仅为你安装,且仅在此仓库(本地作用域)', + 'Install {{count}} extension(s) to which scope?': + '将 {{count}} 个扩展安装到哪个作用域?', + Installed: '已安装', + 'Installed extension "{{name}}".': '已安装扩展 "{{name}}"。', + 'Installed extensions ({{count}}):': '已安装的扩展({{count}}):', + 'Installed {{count}} extension(s).': '已安装 {{count}} 个扩展。', + 'Installed {{ok}}, failed {{fail}}: {{detail}}': + '成功 {{ok}} 个,失败 {{fail}} 个:{{detail}}', + 'Installing...': '安装中...', + 'Last updated: {{date}}': '最近更新:{{date}}', + Local: '本地', + 'Local (Only You)': '本地(仅你自己)', + MCP: 'MCP', + 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', + 'MCP Server': 'MCP 服务器', + 'MCP servers': 'MCP 服务器', + 'Mark for Update': '标记为待更新', + Marketplaces: '市场源', + 'No extensions discovered.': '未发现任何扩展。', + 'No extensions match your search.': '没有与搜索匹配的扩展。', + 'No extensions or marketplaces added yet.': '尚未添加任何扩展或市场源。', + 'No homepage available.': '没有可用的主页。', + 'No installable extensions selected.': '未选择可安装的扩展。', + 'No plugins or MCP servers installed.': '尚未安装任何插件或 MCP 服务器。', + None: '无', + 'Note: Uninstall permanently removes this extension.': + '注意:卸载将永久移除此扩展。', + 'Open homepage': '打开主页', + 'Project (All Collaborators)': '项目(所有协作者)', + 'Remove from Favorites': '从收藏中移除', + 'Remove marketplace': '移除市场源', + 'Remove marketplace "{{name}}"?': '移除市场源 "{{name}}"?', + 'Removed "{{name}}" from favorites.': '已将 "{{name}}" 从收藏中移除。', + 'Removed marketplace "{{name}}".': '已移除市场源 "{{name}}"。', + 'Scope:': '作用域:', + 'Set "{{name}}" scope to {{scope}}.': + '已将 "{{name}}" 的作用域设为 {{scope}}。', + Sources: '来源', + 'Transport:': '传输方式:', + 'Type to search · Space to toggle · Enter to view · Esc to go back': + '输入以搜索 · Space 切换 · Enter 查看 · Esc 返回', + Uninstall: '卸载', + 'Uninstalled "{{name}}".': '已卸载 "{{name}}"。', + 'Update Now': '立即更新', + 'Update marketplace': '更新市场源', + 'Update marketplace (last updated {{date}})': + '更新市场源(最近更新 {{date}})', + 'Updated "{{name}}".': '已更新 "{{name}}"。', + 'Updated marketplace "{{name}}".': '已更新市场源 "{{name}}"。', + 'Use the Discover tab to find and install plugins.': + '使用“发现”标签页查找并安装扩展。', + 'Version: {{v}}': '版本:{{v}}', + 'Will install:': '将安装:', + 'Would open: {{url}}': '将打开:{{url}}', + 'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 确认 · N/Esc 取消', + 'from {{marketplace}}': '来自 {{marketplace}}', + installed: '已安装', + '{{count}} Agents': '{{count}} 个智能体', + '{{count}} Commands': '{{count}} 个命令', + '{{count}} MCP': '{{count}} 个 MCP', + '{{count}} Skills': '{{count}} 个技能', + '{{count}} available extensions': '{{count}} 个可用扩展', + '↑ more above': '↑ 上方更多', + '↑↓ navigate · Enter details · Esc close': + '↑↓ 导航 · Enter 查看详情 · Esc 关闭', + '↑↓ navigate · Enter open · d remove marketplace · Esc close': + '↑↓ 导航 · Enter 打开 · d 移除市场源 · Esc 关闭', + '↑↓ navigate · Enter select · Esc close': '↑↓ 导航 · Enter 选择 · Esc 关闭', + '↑↓ navigate · Enter select · d remove marketplace · Esc close': + '↑↓ 导航 · Enter 选择 · d 移除市场源 · Esc 关闭', + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close': + '↑↓ 导航 · Space 启用/禁用 · f 收藏 · Enter 查看详情 · Esc 关闭', + '↓ more below': '↓ 下方更多', + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.': + '⚠ 在安装、更新或使用扩展前,请确保你信任它。我们无法验证扩展包含哪些 MCP 服务器、文件或其他软件,也无法保证其按预期工作。更多信息请查看扩展主页。', + // ============================================================================ // Help / UI Components // ============================================================================ diff --git a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx index 90f8bf1a9f8..d8c147900f2 100644 --- a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx @@ -57,8 +57,8 @@ export function UninstallConfirmStep({ name: selectedExtension.name, })} - - {t('This action cannot be undone.')} + + {t('Note: Uninstall permanently removes this extension.')} ); diff --git a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx index 819533069f7..5e9479b996a 100644 --- a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx +++ b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx @@ -149,9 +149,6 @@ export const PluginDetailView = ({ showNumbers={false} onSelect={onAction} /> - - {t('Note: Uninstall permanently removes this extension.')} - ); From 6dcfb3ad466543c1b854f57de7388a08ad178d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 16:11:03 +0800 Subject: [PATCH 15/48] =?UTF-8?q?feat(extensions):=20label=20Installed=20s?= =?UTF-8?q?cope=20groups=20as=20=E7=94=A8=E6=88=B7=E7=BA=A7/=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BA=A7/=E6=9C=AC=E5=9C=B0=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Installed-tab scope group headers from User/Project/Local to 'X level' (用户级/项目级/本地级) so the grouping reads as scope levels. Adds the new keys to en/zh/zh-TW locales. --- packages/cli/src/i18n/locales/en.js | 4 ++++ packages/cli/src/i18n/locales/zh-TW.js | 4 ++++ packages/cli/src/i18n/locales/zh.js | 4 ++++ .../components/extensions/ExtensionsManagerDialog.test.tsx | 2 +- .../cli/src/ui/components/extensions/tabs/InstalledTab.tsx | 6 +++--- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index d134e08dfe9..8d3e2b3b197 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -8,6 +8,10 @@ // The key serves as both the translation key and the default English text export default { + 'User level': 'User level', + 'Project level': 'Project level', + 'Local level': 'Local level', + // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) // ========================================================================== diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 9efd74ad6b1..b850fbefd03 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -9,6 +9,10 @@ // then extensively hand-corrected for Taiwan vocabulary conventions. // This file is the authoritative source — do not overwrite with auto-generated output. export default { + 'User level': '使用者層級', + 'Project level': '專案層級', + 'Local level': '本機層級', + // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) // ========================================================================== diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 92422fc9471..9aee38eeba0 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -7,6 +7,10 @@ // Chinese translations for Qwen Code CLI export default { + 'User level': '用户级', + 'Project level': '项目级', + 'Local level': '本地级', + // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) // ========================================================================== diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 7421bc88812..43ee87ab6f6 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -289,7 +289,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('alpha'); }); const frame = lastFrame(); - expect(frame).toContain('User'); + expect(frame).toContain('User level'); expect(frame).toContain('Disabled'); expect(frame).toContain('beta'); // Plugins show their type + version (parallel to "MCP"), not a bare version. diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 60a500e963c..f098630edd2 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -48,11 +48,11 @@ const groupLabel = (group: InstalledGroup): string => { case 'favorites': return t('Favorites'); case 'local': - return t('Local'); + return t('Local level'); case 'user': - return t('User'); + return t('User level'); case 'project': - return t('Project'); + return t('Project level'); case 'disabled': return t('Disabled'); default: From 229de3acd29a495c6789df4aea36305a8b9fc554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 16:21:38 +0800 Subject: [PATCH 16/48] feat(extensions): reuse /mcp server detail for installed MCP servers The Installed-tab MCP item detail was a read-only view (name/type/scope/ transport/status) with a meaningless 'Enter to select' and no actions. Replace it with McpServerActionsView, which reuses the /mcp dialog's ServerDetailStep, ToolListStep, ToolDetailStep and AuthenticateStep so the behaviour matches /mcp exactly: live connection status, View tools, Enable/Disable, Reconnect (when disconnected), Re-authenticate and Clear authentication. Handlers mirror MCPManagementDialog (mcp.excluded settings + toolRegistry discover/disable/disconnect + MCPOAuthTokenStorage). Delete the now-unused McpDetailView and its obsolete locale keys; add the two new status strings to en/zh/zh-TW. --- packages/cli/src/i18n/locales/en.js | 7 +- packages/cli/src/i18n/locales/zh-TW.js | 6 +- packages/cli/src/i18n/locales/zh.js | 6 +- .../extensions/tabs/InstalledTab.tsx | 22 +- .../extensions/views/McpDetailView.tsx | 60 ---- .../extensions/views/McpServerActionsView.tsx | 288 ++++++++++++++++++ 6 files changed, 311 insertions(+), 78 deletions(-) delete mode 100644 packages/cli/src/ui/components/extensions/views/McpDetailView.tsx create mode 100644 packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 8d3e2b3b197..92d5e51c161 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -8,6 +8,11 @@ // The key serves as both the translation key and the default English text export default { + 'Cannot disable an extension-provided MCP server here.': + 'Cannot disable an extension-provided MCP server here.', + 'Cleared authentication for "{{name}}".': + 'Cleared authentication for "{{name}}".', + 'User level': 'User level', 'Project level': 'Project level', 'Local level': 'Local level', @@ -75,7 +80,6 @@ export default { 'Local (Only You)': 'Local (Only You)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}.', - 'MCP Server': 'MCP Server', 'MCP servers': 'MCP servers', 'Mark for Update': 'Mark for Update', Marketplaces: 'Marketplaces', @@ -100,7 +104,6 @@ export default { 'Scope:': 'Scope:', 'Set "{{name}}" scope to {{scope}}.': 'Set "{{name}}" scope to {{scope}}.', Sources: 'Sources', - 'Transport:': 'Transport:', 'Type to search · Space to toggle · Enter to view · Esc to go back': 'Type to search · Space to toggle · Enter to view · Esc to go back', Uninstall: 'Uninstall', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index b850fbefd03..17d3b399574 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -9,6 +9,10 @@ // then extensively hand-corrected for Taiwan vocabulary conventions. // This file is the authoritative source — do not overwrite with auto-generated output. export default { + 'Cannot disable an extension-provided MCP server here.': + '無法在此處停用擴展提供的 MCP 伺服器。', + 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的認證資訊。', + 'User level': '使用者層級', 'Project level': '專案層級', 'Local level': '本機層級', @@ -75,7 +79,6 @@ export default { 'Local (Only You)': '本地(僅你自己)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', - 'MCP Server': 'MCP 伺服器', 'MCP servers': 'MCP 伺服器', 'Mark for Update': '標記為待更新', Marketplaces: '市場來源', @@ -99,7 +102,6 @@ export default { 'Set "{{name}}" scope to {{scope}}.': '已將 "{{name}}" 的作用域設為 {{scope}}。', Sources: '來源', - 'Transport:': '傳輸方式:', 'Type to search · Space to toggle · Enter to view · Esc to go back': '輸入以搜尋 · Space 切換 · Enter 查看 · Esc 返回', Uninstall: '卸載', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 9aee38eeba0..2bb08d90983 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -7,6 +7,10 @@ // Chinese translations for Qwen Code CLI export default { + 'Cannot disable an extension-provided MCP server here.': + '无法在此处禁用扩展提供的 MCP 服务器。', + 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的认证信息。', + 'User level': '用户级', 'Project level': '项目级', 'Local level': '本地级', @@ -73,7 +77,6 @@ export default { 'Local (Only You)': '本地(仅你自己)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', - 'MCP Server': 'MCP 服务器', 'MCP servers': 'MCP 服务器', 'Mark for Update': '标记为待更新', Marketplaces: '市场源', @@ -97,7 +100,6 @@ export default { 'Set "{{name}}" scope to {{scope}}.': '已将 "{{name}}" 的作用域设为 {{scope}}。', Sources: '来源', - 'Transport:': '传输方式:', 'Type to search · Space to toggle · Enter to view · Esc to go back': '输入以搜索 · Space 切换 · Enter 查看 · Esc 返回', Uninstall: '卸载', diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index f098630edd2..f4868777913 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -28,7 +28,7 @@ import type { InstalledGroup, InstalledMcpInfo, } from '../types.js'; -import { McpDetailView } from '../views/McpDetailView.js'; +import { McpServerActionsView } from '../views/McpServerActionsView.js'; import { ExtensionActionsView } from '../views/ExtensionActionsView.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; @@ -355,16 +355,6 @@ export const InstalledTab = ({ { isActive: isActive && view === 'list' }, ); - // Escape handling for sub-views (the container handles Escape on the list). - useKeypress( - (key) => { - if (key.name === 'escape') { - goToList(); - } - }, - { isActive: isActive && view === 'mcp-detail' }, - ); - if (loading) { return {t('Loading...')}; } @@ -384,7 +374,15 @@ export const InstalledTab = ({ } if (view === 'mcp-detail' && selectedItem?.kind === 'mcp') { - return ; + return ( + + ); } if (items.length === 0) { diff --git a/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx b/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx deleted file mode 100644 index 3ecb95a7261..00000000000 --- a/packages/cli/src/ui/components/extensions/views/McpDetailView.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import { theme } from '../../../semantic-colors.js'; -import { t } from '../../../../i18n/index.js'; -import type { InstalledMcpInfo } from '../types.js'; - -interface McpDetailViewProps { - mcp: InstalledMcpInfo; -} - -const LABEL_WIDTH = 14; - -const InfoRow = ({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) => ( - - - {label} - - - {children} - - -); - -export const McpDetailView = ({ mcp }: McpDetailViewProps) => { - const statusColor = mcp.isDisabled - ? theme.text.secondary - : mcp.status === 'connected' - ? theme.status.success - : mcp.status === 'connecting' - ? theme.status.warning - : theme.status.error; - - return ( - - - {mcp.name} - {t('MCP Server')} - {mcp.scope} - {mcp.transport} - - - {mcp.isDisabled ? t('disabled') : mcp.status} - - - {String(mcp.toolCount)} - - - ); -}; diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx new file mode 100644 index 00000000000..55c40715c72 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -0,0 +1,288 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useEffect } from 'react'; +import { Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { t } from '../../../../i18n/index.js'; +import { + type Config, + getMCPServerStatus, + DiscoveredMCPTool, + MCPOAuthTokenStorage, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; +import { loadSettings, SettingScope } from '../../../../config/settings.js'; +import { getErrorMessage } from '../../../../utils/errors.js'; +import { ServerDetailStep } from '../../mcp/steps/ServerDetailStep.js'; +import { ToolListStep } from '../../mcp/steps/ToolListStep.js'; +import { ToolDetailStep } from '../../mcp/steps/ToolDetailStep.js'; +import { AuthenticateStep } from '../../mcp/steps/AuthenticateStep.js'; +import { isToolValid, getToolInvalidReasons } from '../../mcp/utils.js'; +import type { + MCPServerDisplayInfo, + MCPToolDisplayInfo, +} from '../../mcp/types.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +const debugLogger = createDebugLogger('EXT_MCP_DETAIL'); + +type SubView = 'detail' | 'tools' | 'tool-detail' | 'authenticate'; + +interface McpServerActionsViewProps { + config: Config; + /** Name of the installed MCP server to manage. */ + serverName: string; + onStatus: (status: StatusMessage | null) => void; + /** Ask the parent list to reload (state changed). */ + onReload: () => void; + /** Leave the detail and return to the list. */ + onExit: () => void; +} + +/** + * MCP server detail + actions inside the extensions manager, reusing the + * `/mcp` dialog's ServerDetailStep / ToolListStep / AuthenticateStep so the + * behaviour (live status, view tools, enable/disable, re-authenticate, clear + * auth) stays identical to `/mcp`. + */ +export const McpServerActionsView = ({ + config, + serverName, + onStatus, + onReload, + onExit, +}: McpServerActionsViewProps) => { + const [sub, setSub] = useState('detail'); + const [server, setServer] = useState(null); + const [selectedTool, setSelectedTool] = useState( + null, + ); + const [loading, setLoading] = useState(true); + + const buildServer = + useCallback(async (): Promise => { + const mcpServers = config.getMcpServers() || {}; + const serverConfig = mcpServers[serverName]; + if (!serverConfig) return null; + + const settings = loadSettings(); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + + const status = getMCPServerStatus(serverName); + const toolRegistry = config.getToolRegistry(); + const serverTools = (toolRegistry?.getAllTools() || []).filter( + (tool): tool is DiscoveredMCPTool => + tool instanceof DiscoveredMCPTool && tool.serverName === serverName, + ); + + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } else if (workspaceSettings.mcpServers?.[serverName]) { + source = 'project'; + } else if (userSettings.mcpServers?.[serverName]) { + source = 'user'; + } + + let hasOAuthTokens = false; + try { + const credentials = await new MCPOAuthTokenStorage().getCredentials( + serverName, + ); + hasOAuthTokens = credentials !== null; + } catch { + // ignore token lookup failures + } + + return { + name: serverName, + status, + source, + config: serverConfig, + toolCount: serverTools.length, + invalidToolCount: serverTools.filter((x) => !x.name || !x.description) + .length, + promptCount: 0, + isDisabled: config.isMcpServerDisabled(serverName), + hasOAuthTokens, + }; + }, [config, serverName]); + + const reload = useCallback(async () => { + setLoading(true); + try { + setServer(await buildServer()); + } catch (error) { + debugLogger.error('Failed to load MCP server:', error); + } finally { + setLoading(false); + } + onReload(); + }, [buildServer, onReload]); + + useEffect(() => { + let cancelled = false; + void (async () => { + const info = await buildServer(); + if (!cancelled) { + setServer(info); + setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [buildServer]); + + const getServerTools = useCallback((): MCPToolDisplayInfo[] => { + const toolRegistry = config.getToolRegistry(); + if (!toolRegistry) return []; + const out: MCPToolDisplayInfo[] = []; + for (const tool of toolRegistry.getAllTools()) { + if ( + !(tool instanceof DiscoveredMCPTool) || + tool.serverName !== serverName + ) + continue; + const valid = isToolValid(tool.name, tool.description); + out.push({ + name: tool.name || t('(unnamed)'), + description: tool.description, + serverName: tool.serverName, + schema: tool.parameterSchema as object | undefined, + annotations: tool.annotations, + isValid: valid, + invalidReason: valid + ? undefined + : getToolInvalidReasons(tool.name, tool.description).join(', '), + }); + } + return out; + }, [config, serverName]); + + const handleReconnect = useCallback(async () => { + try { + await config.getToolRegistry()?.discoverToolsForServer(serverName); + await reload(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, [config, serverName, reload, onStatus]); + + const handleToggleDisable = useCallback(async () => { + if (!server) return; + const toolRegistry = config.getToolRegistry(); + try { + if (server.isDisabled) { + // Enable: drop from both exclusion lists + runtime, then rediscover. + const settings = loadSettings(); + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const excluded = + settings.forScope(scope).settings.mcp?.excluded || []; + if (excluded.includes(serverName)) { + settings.setValue( + scope, + 'mcp.excluded', + excluded.filter((n: string) => n !== serverName), + ); + } + } + const runtimeExcluded = config.getExcludedMcpServers() || []; + config.setExcludedMcpServers( + runtimeExcluded.filter((n) => n !== serverName), + ); + await toolRegistry?.discoverToolsForServer(serverName); + } else { + if (server.source === 'extension') { + onStatus({ + type: 'error', + text: t('Cannot disable an extension-provided MCP server here.'), + }); + return; + } + const scope = + server.source === 'project' + ? SettingScope.Workspace + : SettingScope.User; + const settings = loadSettings(); + const excluded = settings.forScope(scope).settings.mcp?.excluded || []; + if (!excluded.includes(serverName)) { + settings.setValue(scope, 'mcp.excluded', [...excluded, serverName]); + } + await toolRegistry?.disableMcpServer(serverName); + } + await reload(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, [server, config, serverName, reload, onStatus]); + + const handleClearAuth = useCallback(async () => { + try { + await new MCPOAuthTokenStorage().deleteCredentials(serverName); + await config.getToolRegistry()?.disconnectServer(serverName); + await reload(); + onStatus({ + type: 'success', + text: t('Cleared authentication for "{{name}}".', { name: serverName }), + }); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } + }, [config, serverName, reload, onStatus]); + + if (loading && !server) { + return {t('Loading...')}; + } + + if (sub === 'authenticate') { + return ( + { + setSub('detail'); + void reload(); + }} + /> + ); + } + + if (sub === 'tool-detail') { + return ( + setSub('tools')} /> + ); + } + + if (sub === 'tools') { + return ( + { + setSelectedTool(tool); + setSub('tool-detail'); + }} + onBack={() => setSub('detail')} + /> + ); + } + + return ( + setSub('tools')} + onReconnect={() => void handleReconnect()} + onDisable={() => void handleToggleDisable()} + onAuthenticate={() => setSub('authenticate')} + onClearAuth={() => void handleClearAuth()} + onBack={onExit} + /> + ); +}; From 2c49f01b180afbde0579cccc18240d8f8e2abc00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 16:33:40 +0800 Subject: [PATCH 17/48] fix(extensions): populate MCP promptCount from prompt registry Review follow-up: buildServer hardcoded promptCount to 0, diverging from /mcp's fetchServerData. Query the prompt registry like the original so the reused MCPServerDisplayInfo is computed consistently. --- .../ui/components/extensions/views/McpServerActionsView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index 55c40715c72..4299ce76192 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -81,6 +81,10 @@ export const McpServerActionsView = ({ (tool): tool is DiscoveredMCPTool => tool instanceof DiscoveredMCPTool && tool.serverName === serverName, ); + const promptRegistry = config.getPromptRegistry(); + const serverPrompts = (promptRegistry?.getAllPrompts() || []).filter( + (p) => 'serverName' in p && p.serverName === serverName, + ); let source: 'user' | 'project' | 'extension' = 'user'; if (serverConfig.extensionName) { @@ -109,7 +113,7 @@ export const McpServerActionsView = ({ toolCount: serverTools.length, invalidToolCount: serverTools.filter((x) => !x.name || !x.description) .length, - promptCount: 0, + promptCount: serverPrompts.length, isDisabled: config.isMcpServerDisabled(serverName), hasOAuthTokens, }; From 6c32c09a92b0765642eeecf9d51e40bf5c846400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 17:35:15 +0800 Subject: [PATCH 18/48] refactor(extensions): rename the source-management layer from marketplace to source The Sources tab treats both single-extension sources and Claude plugin marketplaces as 'sources', so the source-management layer is renamed for consistency: MarketplaceSource -> ExtensionSource marketplaceRegistry(.ts) -> sourceRegistry(.ts) MarketplaceRegistryStore -> SourceRegistryStore add/get/remove/markMarketplaceUpdated -> add/get/remove/markSourceUpdated loadMarketplace/updateMarketplace -> loadSource/updateSource MarketplacesTab -> SourcesTab; EXTENSIONS_TABS.MARKETPLACES -> SOURCES + the source-detail UI handlers. Terms that refer to the Claude marketplace manifest *format* are kept, since a marketplace is one source type: ClaudeMarketplaceConfig, loadMarketplaceConfigFromSource, the .claude-plugin/marketplace.json path, DiscoveredPlugin.marketplaceName, and the in-tab 'Marketplaces' group label. --- .../ExtensionsManagerDialog.test.tsx | 38 +++++----- .../extensions/ExtensionsManagerDialog.tsx | 16 ++--- .../src/ui/components/extensions/TabBar.tsx | 2 +- .../{MarketplacesTab.tsx => SourcesTab.tsx} | 70 +++++++++---------- .../cli/src/ui/components/extensions/types.ts | 2 +- .../src/extension/claude-converter.test.ts | 2 +- .../core/src/extension/claude-converter.ts | 2 +- .../core/src/extension/extensionManager.ts | 47 ++++++------- packages/core/src/extension/index.ts | 2 +- ...egistry.test.ts => sourceRegistry.test.ts} | 32 ++++----- ...rketplaceRegistry.ts => sourceRegistry.ts} | 38 +++++----- 11 files changed, 123 insertions(+), 128 deletions(-) rename packages/cli/src/ui/components/extensions/tabs/{MarketplacesTab.tsx => SourcesTab.tsx} (90%) rename packages/core/src/extension/{marketplaceRegistry.test.ts => sourceRegistry.test.ts} (87%) rename packages/core/src/extension/{marketplaceRegistry.ts => sourceRegistry.ts} (89%) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 43ee87ab6f6..6cdb5eaaaa5 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -20,7 +20,7 @@ import type { Config, Extension, DiscoveredPlugin, - MarketplaceSource, + ExtensionSource, } from '@qwen-code/qwen-code-core'; import type { ExtensionUpdateState } from '../../state/extensions.js'; @@ -44,7 +44,7 @@ const mockExtension = (name: string, isActive = true): Extension => interface ManagerOverrides { extensions?: Extension[]; discovered?: DiscoveredPlugin[]; - marketplaces?: MarketplaceSource[]; + sources?: ExtensionSource[]; favorites?: string[]; scopes?: Record; } @@ -58,7 +58,7 @@ const createManager = (o: ManagerOverrides = {}) => { getExtensionScopes: vi.fn(() => o.scopes ?? {}), isFavorite: vi.fn((name: string) => (o.favorites ?? []).includes(name)), getExtensionScope: vi.fn((name: string) => o.scopes?.[name] ?? 'user'), - getMarketplaces: vi.fn(() => o.marketplaces ?? []), + getSources: vi.fn(() => o.sources ?? []), discoverPlugins: vi.fn().mockResolvedValue(o.discovered ?? []), toggleFavorite: vi.fn(() => true), setExtensionScope: vi.fn(), @@ -67,9 +67,9 @@ const createManager = (o: ManagerOverrides = {}) => { uninstallExtension: vi.fn().mockResolvedValue(undefined), checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined), updateExtension: vi.fn().mockResolvedValue(undefined), - addMarketplace: vi.fn(), - removeMarketplace: vi.fn(() => true), - loadMarketplace: vi.fn().mockResolvedValue(null), + addSource: vi.fn(), + removeSource: vi.fn(() => true), + loadSource: vi.fn().mockResolvedValue(null), }; }; @@ -347,13 +347,13 @@ describe('ExtensionsManagerDialog (tabbed)', () => { it('shows the install/add actions and grouped sections on the Marketplaces tab', async () => { const config = createConfig( createManager({ - marketplaces: [ + sources: [ { name: 'Skills', source: 'anthropics/skills', type: 'github' }, ], }), ); const { lastFrame } = renderDialog(config, { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('Add new marketplace'); @@ -373,7 +373,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { extensions: [mockExtension('alpha', true)], }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('alpha'); // Extensions section row @@ -401,7 +401,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { extensions: [mockExtension('alpha', true)], }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('alpha'); @@ -432,7 +432,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { extensions: [mockExtension('alpha', false)], // disabled }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('alpha'); @@ -487,11 +487,11 @@ describe('ExtensionsManagerDialog (tabbed)', () => { it('shows a CC-style marketplace detail with installed plugins and actions', async () => { const manager = createManager({ extensions: [mockExtension('pdf', true)], // installed, belongs to Skills - marketplaces: [ + sources: [ { name: 'Skills', source: 'anthropics/skills', type: 'github' }, ], }); - manager.loadMarketplace = vi.fn().mockResolvedValue({ + manager.loadSource = vi.fn().mockResolvedValue({ name: 'Skills', owner: { name: 'o', email: 'e' }, plugins: [ @@ -500,7 +500,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ], }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); // Wait until the list has loaded (and the keypress handler re-subscribed). await waitFor(() => { @@ -528,7 +528,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { it('Browse plugins jumps to Discover filtered by the marketplace', async () => { const manager = createManager({ - marketplaces: [ + sources: [ { name: 'Skills', source: 'anthropics/skills', type: 'github' }, ], discovered: [ @@ -552,7 +552,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }, ], }); - manager.loadMarketplace = vi.fn().mockResolvedValue({ + manager.loadSource = vi.fn().mockResolvedValue({ name: 'Skills', owner: { name: 'o', email: 'e' }, plugins: [ @@ -561,7 +561,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { ], }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('Skills'); // list loaded @@ -618,7 +618,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { const manager = createManager(); const { stdin, lastFrame } = renderDialog(createConfig(manager), { onClose, - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('Install new extension'); @@ -636,7 +636,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { it('opens the install-extension input view on Enter', async () => { const { stdin, lastFrame } = renderDialog(createConfig(createManager()), { - initialTab: EXTENSIONS_TABS.MARKETPLACES, + initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { expect(lastFrame()).toContain('Install new extension'); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 3ce8dafb99b..1b3ab96bb54 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -20,7 +20,7 @@ import { import { TabBar } from './TabBar.js'; import { DiscoverTab } from './tabs/DiscoverTab.js'; import { InstalledTab } from './tabs/InstalledTab.js'; -import { MarketplacesTab } from './tabs/MarketplacesTab.js'; +import { SourcesTab } from './tabs/SourcesTab.js'; export interface StatusMessage { type: 'info' | 'success' | 'error'; @@ -30,7 +30,7 @@ export interface StatusMessage { const TABS: ExtensionsTabDef[] = [ { id: EXTENSIONS_TABS.INSTALLED, label: 'Installed' }, { id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' }, - { id: EXTENSIONS_TABS.MARKETPLACES, label: 'Sources' }, + { id: EXTENSIONS_TABS.SOURCES, label: 'Sources' }, ]; // Literal t() calls keep the footer hints extractable for translation. @@ -44,7 +44,7 @@ function footerHint(tab: ExtensionsTab): string { return t( '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', ); - case EXTENSIONS_TABS.MARKETPLACES: + case EXTENSIONS_TABS.SOURCES: return t('↑↓ navigate · Enter select · d remove marketplace · Esc close'); default: return ''; @@ -85,7 +85,7 @@ export function ExtensionsManagerDialog({ }); }, []); - const handleBrowseMarketplace = useCallback((marketplaceName: string) => { + const handleBrowseSource = useCallback((marketplaceName: string) => { setStatus(null); setTabLocked(false); setDiscoverFilter(marketplaceName); @@ -168,14 +168,14 @@ export function ExtensionsManagerDialog({ reloadSignal={reloadSignal} /> )} - {activeTab === EXTENSIONS_TABS.MARKETPLACES && ( - diff --git a/packages/cli/src/ui/components/extensions/TabBar.tsx b/packages/cli/src/ui/components/extensions/TabBar.tsx index bb7de562a1e..ce7b86050c7 100644 --- a/packages/cli/src/ui/components/extensions/TabBar.tsx +++ b/packages/cli/src/ui/components/extensions/TabBar.tsx @@ -27,7 +27,7 @@ function tabLabel(id: ExtensionsTab): string { return t('Discover'); case EXTENSIONS_TABS.INSTALLED: return t('Installed'); - case EXTENSIONS_TABS.MARKETPLACES: + case EXTENSIONS_TABS.SOURCES: return t('Sources'); default: return id; diff --git a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx similarity index 90% rename from packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx rename to packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index 5dc55c1cfd2..4dbc582299e 100644 --- a/packages/cli/src/ui/components/extensions/tabs/MarketplacesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -15,7 +15,7 @@ import { t } from '../../../../i18n/index.js'; import { type Config, type Extension, - type MarketplaceSource, + type ExtensionSource, type ClaudeMarketplaceConfig, parseInstallSource, redactUrlCredentials, @@ -25,25 +25,25 @@ import { getErrorMessage } from '../../../../utils/errors.js'; import { ExtensionActionsView } from '../views/ExtensionActionsView.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; -const debugLogger = createDebugLogger('MARKETPLACES_TAB'); +const debugLogger = createDebugLogger('SOURCES_TAB'); -type MarketplacesView = +type SourcesView = | 'list' | 'install-extension' | 'add' | 'detail' | 'extension-detail' | 'remove-confirm'; -type MarketplaceDetailAction = 'browse' | 'update' | 'remove'; +type SourceDetailAction = 'browse' | 'update' | 'remove'; // Flat, navigable entries shown on the Marketplaces tab list. type Entry = | { kind: 'install-extension' } | { kind: 'add-marketplace' } | { kind: 'extension'; extension: Extension } - | { kind: 'marketplace'; source: MarketplaceSource }; + | { kind: 'marketplace'; source: ExtensionSource }; -interface MarketplacesTabProps { +interface SourcesTabProps { config: Config; isActive: boolean; onLockChange: (locked: boolean) => void; @@ -69,7 +69,7 @@ function extensionSourceLabel(ext: Extension): string { return `${redactUrlCredentials(meta.source)} (${meta.type})`; } -export const MarketplacesTab = ({ +export const SourcesTab = ({ config, isActive, onLockChange, @@ -78,18 +78,18 @@ export const MarketplacesTab = ({ onBrowse, onFooter, reloadSignal, -}: MarketplacesTabProps) => { - const [sources, setSources] = useState([]); +}: SourcesTabProps) => { + const [sources, setSources] = useState([]); const [extensions, setExtensions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); - const [view, setView] = useState('list'); + const [view, setView] = useState('list'); const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [detailConfig, setDetailConfig] = useState(null); const [detailLoading, setDetailLoading] = useState(false); // The marketplace / extension currently being viewed or confirmed. - const [detailSource, setDetailSource] = useState( + const [detailSource, setDetailSource] = useState( null, ); const [detailExtension, setDetailExtension] = useState( @@ -106,14 +106,14 @@ export const MarketplacesTab = ({ debugLogger.error('Failed to refresh extensions:', error); } setExtensions(extensionManager.getLoadedExtensions()); - setSources(extensionManager.getMarketplaces()); + setSources(extensionManager.getSources()); }, [extensionManager]); useEffect(() => { load(); }, [load, reloadSignal]); - // Entries: two action rows, then installed extensions, then marketplaces. + // Entries: two action rows, then installed extensions, then sources. const entries = useMemo( () => [ { kind: 'install-extension' }, @@ -168,7 +168,7 @@ export const MarketplacesTab = ({ if (!extensionManager || !input.trim()) return; setBusy(true); try { - const entry = await extensionManager.addMarketplace(input.trim()); + const entry = await extensionManager.addSource(input.trim()); onStatus({ type: 'success', text: t('Added marketplace "{{name}}".', { name: entry.name }), @@ -209,8 +209,8 @@ export const MarketplacesTab = ({ } }, [extensionManager, input, onStatus, load, onChanged, goToList]); - const openMarketplaceDetail = useCallback( - async (source: MarketplaceSource) => { + const openSourceDetail = useCallback( + async (source: ExtensionSource) => { onStatus(null); setDetailSource(source); setView('detail'); @@ -218,7 +218,7 @@ export const MarketplacesTab = ({ setDetailLoading(true); setDetailConfig(null); try { - const cfg = await extensionManager?.loadMarketplace(source.source); + const cfg = await extensionManager?.loadSource(source.source); setDetailConfig(cfg ?? null); } catch (error) { debugLogger.error('Failed to load marketplace detail:', error); @@ -239,9 +239,9 @@ export const MarketplacesTab = ({ [onLockChange, onStatus], ); - const removeMarketplace = useCallback(() => { + const removeSource = useCallback(() => { if (!extensionManager || !detailSource) return; - const removed = extensionManager.removeMarketplace(detailSource.name); + const removed = extensionManager.removeSource(detailSource.name); if (removed) { onStatus({ type: 'success', @@ -253,13 +253,13 @@ export const MarketplacesTab = ({ goToList(); }, [extensionManager, detailSource, onStatus, load, onChanged, goToList]); - const updateMarketplace = useCallback(async () => { + const updateSource = useCallback(async () => { if (!extensionManager || !detailSource) return; setDetailLoading(true); try { - const cfg = await extensionManager.loadMarketplace(detailSource.source); + const cfg = await extensionManager.loadSource(detailSource.source); setDetailConfig(cfg ?? null); - extensionManager.markMarketplaceUpdated(detailSource.name); + extensionManager.markSourceUpdated(detailSource.name); await load(); onChanged(); onStatus({ @@ -276,18 +276,18 @@ export const MarketplacesTab = ({ } }, [extensionManager, detailSource, load, onChanged, onStatus]); - const handleMarketplaceDetailAction = useCallback( - (action: MarketplaceDetailAction) => { + const handleSourceDetailAction = useCallback( + (action: SourceDetailAction) => { if (!detailSource) return; if (action === 'browse') { onBrowse(detailSource.name); } else if (action === 'update') { - void updateMarketplace(); + void updateSource(); } else if (action === 'remove') { setView('remove-confirm'); } }, - [detailSource, onBrowse, updateMarketplace], + [detailSource, onBrowse, updateSource], ); // List keyboard: navigate entries, Enter dispatches by kind, d removes. @@ -318,7 +318,7 @@ export const MarketplacesTab = ({ openExtensionDetail(selectedEntry.extension); break; case 'marketplace': - void openMarketplaceDetail(selectedEntry.source); + void openSourceDetail(selectedEntry.source); break; default: break; @@ -366,7 +366,7 @@ export const MarketplacesTab = ({ useKeypress( (key) => { if (key.name === 'return' || key.sequence === 'y') { - removeMarketplace(); + removeSource(); } else if (key.name === 'escape' || key.sequence === 'n') { goToList(); } @@ -471,7 +471,7 @@ export const MarketplacesTab = ({ const actions: Array<{ key: string; label: string; - value: MarketplaceDetailAction; + value: SourceDetailAction; }> = [ { key: 'browse', @@ -544,7 +544,7 @@ export const MarketplacesTab = ({ items={actions} isFocused={isActive} showNumbers={false} - onSelect={handleMarketplaceDetailAction} + onSelect={handleSourceDetailAction} /> ) : ( @@ -557,12 +557,12 @@ export const MarketplacesTab = ({ { key: 'remove', label: t('Remove marketplace'), - value: 'remove' as MarketplaceDetailAction, + value: 'remove' as SourceDetailAction, }, ]} isFocused={isActive} showNumbers={false} - onSelect={handleMarketplaceDetailAction} + onSelect={handleSourceDetailAction} /> )} @@ -616,7 +616,7 @@ export const MarketplacesTab = ({ }; const extensionsStart = 2; - const marketplacesStart = 2 + extensions.length; + const sourcesStart = 2 + extensions.length; return ( @@ -651,7 +651,7 @@ export const MarketplacesTab = ({ {sources.map((source, j) => renderRow( - marketplacesStart + j, + sourcesStart + j, source.name, `${redactUrlCredentials(source.source)} (${source.type})`, ), @@ -662,7 +662,7 @@ export const MarketplacesTab = ({ {extensions.length === 0 && sources.length === 0 ? ( - {t('No extensions or marketplaces added yet.')} + {t('No extensions or sources added yet.')} ) : null} diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts index e74f231d579..563d60f64d1 100644 --- a/packages/cli/src/ui/components/extensions/types.ts +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -17,7 +17,7 @@ import type { export const EXTENSIONS_TABS = { DISCOVER: 'discover', INSTALLED: 'installed', - MARKETPLACES: 'marketplaces', + SOURCES: 'sources', } as const; export type ExtensionsTab = diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index d0caada4c2d..2c74dcf95c9 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -148,7 +148,7 @@ describe('isClaudePluginConfig', () => { it('should identify Claude plugin directory', () => { const extensionDir = '/tmp/test-extension'; const marketplace = { - marketplaceSource: 'https://test.com', + extensionSource: 'https://test.com', pluginName: 'test-plugin', }; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 08ad2cd7d66..5ad51ca60c9 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -704,7 +704,7 @@ export function mergeClaudeConfigs( */ export function isClaudePluginConfig( extensionDir: string, - marketplace: { marketplaceSource: string; pluginName: string }, + marketplace: { extensionSource: string; pluginName: string }, ) { const marketplaceConfigFilePath = path.join( extensionDir, diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 79521048dbd..258a7edecdf 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -52,12 +52,12 @@ import { type ExtensionScope, } from './extensionPreferences.js'; import { - MarketplaceRegistryStore, + SourceRegistryStore, discoverPlugins, - parseMarketplaceSourceType, - type MarketplaceSource, + parseExtensionSourceType, + type ExtensionSource, type DiscoveredPlugin, -} from './marketplaceRegistry.js'; +} from './sourceRegistry.js'; import { loadMarketplaceConfigFromSource, parseInstallSource, @@ -320,7 +320,7 @@ export class ExtensionManager { private readonly enabledExtensionNamesOverride: string[]; private readonly workspaceDir: string; private readonly preferencesStore: ExtensionPreferencesStore; - private readonly marketplaceRegistryStore: MarketplaceRegistryStore; + private readonly sourceRegistryStore: SourceRegistryStore; private discoverCache: DiscoveredPlugin[] | null = null; private config?: Config; @@ -345,8 +345,8 @@ export class ExtensionManager { this.preferencesStore = new ExtensionPreferencesStore( path.join(this.configDir, 'extension-preferences.json'), ); - this.marketplaceRegistryStore = new MarketplaceRegistryStore( - path.join(this.configDir, 'marketplaces.json'), + this.sourceRegistryStore = new SourceRegistryStore( + path.join(this.configDir, 'sources.json'), ); this.requestSetting = options.requestSetting; this.requestChoicePlugin = @@ -540,8 +540,8 @@ export class ExtensionManager { // Marketplace registry & discovery // ========================================================================== - getMarketplaces(): MarketplaceSource[] { - return this.marketplaceRegistryStore.read(); + getSources(): ExtensionSource[] { + return this.sourceRegistryStore.read(); } /** @@ -549,7 +549,7 @@ export class ExtensionManager { * human-readable name (falling back to the raw source). Throws if no * marketplace config can be resolved from the source. */ - async addMarketplace(source: string): Promise { + async addSource(source: string): Promise { const trimmed = source.trim(); if (!trimmed) { throw new Error('Marketplace source cannot be empty.'); @@ -579,20 +579,20 @@ export class ExtensionManager { ); } const now = new Date().toISOString(); - const entry: MarketplaceSource = { + const entry: ExtensionSource = { name: config.name || trimmed, source: trimmed, - type: parseMarketplaceSourceType(trimmed), + type: parseExtensionSourceType(trimmed), addedAt: now, lastUpdatedAt: now, }; - this.marketplaceRegistryStore.add(entry); + this.sourceRegistryStore.add(entry); this.discoverCache = null; // sources changed -> refetch on next discover return entry; } - removeMarketplace(name: string): boolean { - const removed = this.marketplaceRegistryStore.remove(name); + removeSource(name: string): boolean { + const removed = this.sourceRegistryStore.remove(name); if (removed) { this.discoverCache = null; } @@ -603,26 +603,26 @@ export class ExtensionManager { * Records a fresh "last updated" timestamp for a marketplace and invalidates * the discovery cache so the next discover re-fetches it. */ - markMarketplaceUpdated(name: string): MarketplaceSource | undefined { - const entry = this.getMarketplaces().find((m) => m.name === name); + markSourceUpdated(name: string): ExtensionSource | undefined { + const entry = this.getSources().find((m) => m.name === name); if (!entry) { return undefined; } - const updated: MarketplaceSource = { + const updated: ExtensionSource = { ...entry, lastUpdatedAt: new Date().toISOString(), }; - this.marketplaceRegistryStore.add(updated); // add() replaces by name + this.sourceRegistryStore.add(updated); // add() replaces by name this.discoverCache = null; return updated; } - loadMarketplace(source: string): Promise { + loadSource(source: string): Promise { return loadMarketplaceConfigFromSource(source); } /** - * Discovers all installable plugins across configured marketplaces, marking + * Discovers all installable plugins across configured sources, marking * which are already installed. The fetched listing is cached for the session; * pass `{ refresh: true }` to force a re-fetch. The cheap `installed` flags are * always recomputed against the current install state. @@ -639,10 +639,7 @@ export class ExtensionManager { installed: installedNames.has(plugin.name), })); } - const result = await discoverPlugins( - this.getMarketplaces(), - installedNames, - ); + const result = await discoverPlugins(this.getSources(), installedNames); this.discoverCache = result; return result; } diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index 4d4729bcd3d..80f7ee5b37a 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -3,7 +3,7 @@ export * from './variables.js'; export * from './github.js'; export * from './extensionSettings.js'; export * from './marketplace.js'; -export * from './marketplaceRegistry.js'; +export * from './sourceRegistry.js'; export * from './extensionPreferences.js'; export * from './npm.js'; export * from './claude-converter.js'; diff --git a/packages/core/src/extension/marketplaceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts similarity index 87% rename from packages/core/src/extension/marketplaceRegistry.test.ts rename to packages/core/src/extension/sourceRegistry.test.ts index ee136d7ac69..f8fc196e635 100644 --- a/packages/core/src/extension/marketplaceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -9,11 +9,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { - MarketplaceRegistryStore, - parseMarketplaceSourceType, + SourceRegistryStore, + parseExtensionSourceType, discoverPlugins, - type MarketplaceSource, -} from './marketplaceRegistry.js'; + type ExtensionSource, +} from './sourceRegistry.js'; import { loadMarketplaceConfigFromSource } from './marketplace.js'; import type { ClaudeMarketplaceConfig } from './claude-converter.js'; @@ -25,7 +25,7 @@ vi.mock('./marketplace.js', async (importOriginal) => { }; }); -describe('parseMarketplaceSourceType', () => { +describe('parseExtensionSourceType', () => { it.each([ ['anthropics/skills', 'github'], ['https://github.com/owner/repo', 'github'], @@ -35,16 +35,16 @@ describe('parseMarketplaceSourceType', () => { ['./local/marketplace', 'local'], ['/abs/path/marketplace', 'local'], ] as const)('classifies %s as %s', (input, expected) => { - expect(parseMarketplaceSourceType(input)).toBe(expected); + expect(parseExtensionSourceType(input)).toBe(expected); }); }); -describe('MarketplaceRegistryStore', () => { +describe('SourceRegistryStore', () => { let tmpDir: string; let filePath: string; - let store: MarketplaceRegistryStore; + let store: SourceRegistryStore; - const make = (name: string, source: string): MarketplaceSource => ({ + const make = (name: string, source: string): ExtensionSource => ({ name, source, type: 'github', @@ -52,8 +52,8 @@ describe('MarketplaceRegistryStore', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-reg-')); - filePath = path.join(tmpDir, 'nested', 'marketplaces.json'); - store = new MarketplaceRegistryStore(filePath); + filePath = path.join(tmpDir, 'nested', 'sources.json'); + store = new SourceRegistryStore(filePath); }); afterEach(() => { @@ -68,7 +68,7 @@ describe('MarketplaceRegistryStore', () => { store.add(make('Skills', 'anthropics/skills')); expect(store.read()).toHaveLength(1); - const reopened = new MarketplaceRegistryStore(filePath); + const reopened = new SourceRegistryStore(filePath); expect(reopened.read()[0].name).toBe('Skills'); }); @@ -105,7 +105,7 @@ describe('discoverPlugins', () => { plugins, }); - it('flattens plugins across marketplaces and marks installed ones', async () => { + it('flattens plugins across sources and marks installed ones', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( async (source: string) => { if (source === 'anthropics/skills') { @@ -130,7 +130,7 @@ describe('discoverPlugins', () => { }, ); - const sources: MarketplaceSource[] = [ + const sources: ExtensionSource[] = [ { name: 'Skills', source: 'anthropics/skills', type: 'github' }, { name: 'Other', source: 'me/other', type: 'github' }, ]; @@ -171,7 +171,7 @@ describe('discoverPlugins', () => { expect(plugin.lastUpdated).toBe('Jun 5, 2026'); }); - it('derives install source from per-plugin source for http marketplaces', async () => { + it('derives install source from per-plugin source for http sources', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( config('Remote', [ { @@ -200,7 +200,7 @@ describe('discoverPlugins', () => { ); }); - it('skips marketplaces that fail to load without throwing', async () => { + it('skips sources that fail to load without throwing', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( async (source: string) => { if (source === 'good/repo') { diff --git a/packages/core/src/extension/marketplaceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts similarity index 89% rename from packages/core/src/extension/marketplaceRegistry.ts rename to packages/core/src/extension/sourceRegistry.ts index e79392e1109..94f118a7ad4 100644 --- a/packages/core/src/extension/marketplaceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -15,19 +15,19 @@ import type { ClaudeMarketplacePluginConfig, } from './claude-converter.js'; -const debugLogger = createDebugLogger('MARKETPLACE_REGISTRY'); +const debugLogger = createDebugLogger('SOURCE_REGISTRY'); -export type MarketplaceSourceType = 'github' | 'git' | 'http' | 'local'; +export type ExtensionSourceType = 'github' | 'git' | 'http' | 'local'; /** * A persisted marketplace source the user has added (Marketplaces tab). */ -export interface MarketplaceSource { +export interface ExtensionSource { /** Display name (from the marketplace config `name`, or derived). */ name: string; /** Original input string used to add the source. */ source: string; - type: MarketplaceSourceType; + type: ExtensionSourceType; /** ISO timestamp recorded when the source was added. */ addedAt?: string; /** ISO timestamp of the last successful (re)fetch / update. */ @@ -115,13 +115,11 @@ function pluginInstalls( } /** - * Classifies a marketplace source string into a {@link MarketplaceSourceType} + * Classifies a marketplace source string into a {@link ExtensionSourceType} * using format heuristics only (no network / filesystem access required for a * confident answer, beyond an optional existence check the caller may do). */ -export function parseMarketplaceSourceType( - source: string, -): MarketplaceSourceType { +export function parseExtensionSourceType(source: string): ExtensionSourceType { const trimmed = source.trim(); if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) { return 'git'; @@ -149,13 +147,13 @@ function isOwnerRepoShorthand(source: string): boolean { /** * Builds the install-source string fed to `parseInstallSource` for a discovered - * plugin. For repo/local marketplaces this is `:`, + * plugin. For repo/local sources this is `:`, * which the existing installer resolves against the marketplace's - * `marketplace.json`. For direct-JSON (`http`) marketplaces it is derived from + * `marketplace.json`. For direct-JSON (`http`) sources it is derived from * the per-plugin `source` field. */ function resolveInstallSource( - marketplace: MarketplaceSource, + marketplace: ExtensionSource, plugin: ClaudeMarketplacePluginConfig, ): string { if (marketplace.type !== 'http') { @@ -175,7 +173,7 @@ function resolveInstallSource( } function pluginsFromConfig( - marketplace: MarketplaceSource, + marketplace: ExtensionSource, config: ClaudeMarketplaceConfig, installedNames: ReadonlySet, ): DiscoveredPlugin[] { @@ -198,14 +196,14 @@ function pluginsFromConfig( /** * Persists the list of marketplace sources the user has added. */ -export class MarketplaceRegistryStore { +export class SourceRegistryStore { constructor(private readonly filePath: string) {} - read(): MarketplaceSource[] { + read(): ExtensionSource[] { try { const content = fs.readFileSync(this.filePath, 'utf-8'); const parsed = JSON.parse(content); - return Array.isArray(parsed) ? (parsed as MarketplaceSource[]) : []; + return Array.isArray(parsed) ? (parsed as ExtensionSource[]) : []; } catch (error) { if ( error instanceof Error && @@ -219,7 +217,7 @@ export class MarketplaceRegistryStore { } } - private write(sources: MarketplaceSource[]): void { + private write(sources: ExtensionSource[]): void { fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); atomicWriteFileSync(this.filePath, JSON.stringify(sources, null, 2)); } @@ -227,7 +225,7 @@ export class MarketplaceRegistryStore { /** * Adds (or replaces, when name/source matches) a marketplace source. */ - add(source: MarketplaceSource): void { + add(source: ExtensionSource): void { const sources = this.read().filter( (existing) => existing.name !== source.name && existing.source !== source.source, @@ -255,11 +253,11 @@ export class MarketplaceRegistryStore { * one bad source does not break discovery. */ export async function discoverPlugins( - marketplaces: readonly MarketplaceSource[], + sources: readonly ExtensionSource[], installedNames: ReadonlySet, ): Promise { const results = await Promise.all( - marketplaces.map(async (marketplace) => { + sources.map(async (marketplace) => { try { const config = await loadMarketplaceConfigFromSource( marketplace.source, @@ -286,7 +284,7 @@ export async function discoverPlugins( ); // De-duplicate by `${marketplaceName}/${pluginName}` to keep distinct plugins - // that happen to share a name across different marketplaces. + // that happen to share a name across different sources. const seen = new Set(); const deduped: DiscoveredPlugin[] = []; for (const plugin of results.flat()) { From 59f850c332712d3485e061f8055c4f55c6caf0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 17:51:29 +0800 Subject: [PATCH 19/48] fix(extensions): keep marketplaces.json filename so saved sources survive the rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source/* rename accidentally renamed the persisted registry file from marketplaces.json to sources.json, so previously added sources (e.g. a Claude marketplace) appeared to vanish — the data was intact in marketplaces.json but the code read sources.json. Restore the marketplaces.json filename for backward compatibility. --- packages/core/src/extension/extensionManager.ts | 4 +++- packages/core/src/extension/sourceRegistry.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 258a7edecdf..bfe60f552e7 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -346,7 +346,9 @@ export class ExtensionManager { path.join(this.configDir, 'extension-preferences.json'), ); this.sourceRegistryStore = new SourceRegistryStore( - path.join(this.configDir, 'sources.json'), + // Keep the on-disk filename as marketplaces.json for backward + // compatibility with sources added before the source/* rename. + path.join(this.configDir, 'marketplaces.json'), ); this.requestSetting = options.requestSetting; this.requestChoicePlugin = diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index f8fc196e635..ae925150026 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -52,7 +52,7 @@ describe('SourceRegistryStore', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-reg-')); - filePath = path.join(tmpDir, 'nested', 'sources.json'); + filePath = path.join(tmpDir, 'nested', 'marketplaces.json'); store = new SourceRegistryStore(filePath); }); From c83dd434aa0356a39b16f61a570ed70891ba27b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 9 Jun 2026 18:13:00 +0800 Subject: [PATCH 20/48] fix(extensions): stay on the Discover detail when an install fails Previously runInstall always returned to the list after attempting an install. Now it only returns to the list on success; on failure it stays on the extension detail page so the error message remains visible and the user can retry without re-navigating. --- .../src/ui/components/extensions/tabs/DiscoverTab.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 4a02b415d9c..893775d4753 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -241,9 +241,16 @@ export const DiscoverTab = ({ } await load(); onInstalled(); - goToList(); + if (errors.length === 0) { + goToList(); + } else { + // On failure, stay on the extension detail so the error remains + // visible and the user can retry, instead of dropping to the list. + setView('detail'); + onLockChange(true); + } }, - [extensionManager, onStatus, load, onInstalled, goToList], + [extensionManager, onStatus, load, onInstalled, goToList, onLockChange], ); const installWithScope = useCallback( From a5a70df24571d1abe323aa473442dd602079e562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 10:43:23 +0800 Subject: [PATCH 21/48] feat(extensions): support 'git-subdir' plugin source in Claude marketplaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some Claude marketplace plugins live in a subdirectory of a git repo and use a 'git-subdir' source ({url, path, ref, sha}), which resolvePluginSource didn't handle — installing failed with 'Unsupported plugin source type'. Add the git-subdir branch: clone the repo (pinned to ref/sha when provided) and return the subdirectory as the plugin source. Verified against github.com/42Crunch-AI/claude-plugins @ v1.5.5: the cloned plugins/api-security-testing subdir is a valid plugin (.claude-plugin/plugin.json). --- .../core/src/extension/claude-converter.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 5ad51ca60c9..98fcf61a732 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -77,7 +77,15 @@ export interface ClaudeAgentConfig { export type ClaudePluginSource = | { source: 'github'; repo: string } - | { source: 'url'; url: string }; + | { source: 'url'; url: string } + | { + // A plugin that lives in a subdirectory of a git repository. + source: 'git-subdir'; + url: string; + path: string; + ref?: string; + sha?: string; + }; export interface ClaudeMarketplacePluginConfig extends ClaudePluginConfig { source: string | ClaudePluginSource; @@ -823,5 +831,24 @@ async function resolvePluginSource( return pluginDir; } + if (source.source === 'git-subdir') { + // The plugin lives in a subdirectory of a git repository. Clone the repo + // (pinned to the provided ref/sha when present) and return the subdir. + const installMetadata: ExtensionInstallMetadata = { + source: source.url, + type: 'git', + ref: source.ref || source.sha, + originSource: 'Claude', + }; + await cloneFromGit(installMetadata, pluginDir); + const subDir = path.join(pluginDir, source.path); + if (!fs.existsSync(subDir)) { + throw new Error( + `Plugin subdirectory "${source.path}" not found in ${source.url}`, + ); + } + return subDir; + } + throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`); } From 0eacf78775179fd58cc7c97c312f2125feb44c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 16:43:18 +0800 Subject: [PATCH 22/48] feat(extensions): drop the unused 'local' install scope Simplify the install/visibility scope model from three options (user / project / local) down to two (user / project). The 'local' option duplicated the workspace-level enablement of 'project' without providing a meaningfully different storage location, so it was UI clutter rather than a real feature. - core: ExtensionScope = 'user' | 'project'; read() filters unknown values via a type guard, so any stale 'local' (or otherwise invalid) entry in extension-preferences.json is dropped and falls back to 'user' downstream. - UI: remove the 'local' option from the Discover install menu, the change- scope picker, and the Installed tab's group ordering. - copy: rename 'Project (All Collaborators)' to 'Project (Workspace)' and 'Install for all collaborators on this repository' to 'Install for the current workspace', matching the new two-tier model. - i18n: clean up the now-unused 'Local *' keys in en / zh / zh-TW and retranslate the renamed keys. - tests: update the scope-change spec and replace the legacy-scope migration test with one that exercises the unknown-value filter. --- packages/cli/src/i18n/locales/en.js | 11 +++----- packages/cli/src/i18n/locales/zh-TW.js | 11 +++----- packages/cli/src/i18n/locales/zh.js | 11 +++----- .../ExtensionsManagerDialog.test.tsx | 9 +++---- .../extensions/tabs/DiscoverTab.tsx | 12 ++------- .../extensions/tabs/InstalledTab.tsx | 4 --- .../cli/src/ui/components/extensions/types.ts | 9 ++----- .../extensions/views/ExtensionActionsView.tsx | 4 +-- .../extension/extensionPreferences.test.ts | 21 ++++++++++++--- .../src/extension/extensionPreferences.ts | 27 +++++++++++-------- 10 files changed, 52 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 92d5e51c161..b62381a4c18 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -15,7 +15,6 @@ export default { 'User level': 'User level', 'Project level': 'Project level', - 'Local level': 'Local level', // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) @@ -61,11 +60,9 @@ export default { Favorites: 'Favorites', 'Global (User Scope)': 'Global (User Scope)', 'Install Extension': 'Install Extension', - 'Install for all collaborators on this repository (project scope)': - 'Install for all collaborators on this repository (project scope)', + 'Install for the current workspace (project scope)': + 'Install for the current workspace (project scope)', 'Install for you (user scope)': 'Install for you (user scope)', - 'Install for you, in this repo only (local scope)': - 'Install for you, in this repo only (local scope)', 'Install {{count}} extension(s) to which scope?': 'Install {{count}} extension(s) to which scope?', Installed: 'Installed', @@ -76,8 +73,6 @@ export default { 'Installed {{ok}}, failed {{fail}}: {{detail}}', 'Installing...': 'Installing...', 'Last updated: {{date}}': 'Last updated: {{date}}', - Local: 'Local', - 'Local (Only You)': 'Local (Only You)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}.', 'MCP servers': 'MCP servers', @@ -95,7 +90,7 @@ export default { 'Note: Uninstall permanently removes this extension.': 'Note: Uninstall permanently removes this extension.', 'Open homepage': 'Open homepage', - 'Project (All Collaborators)': 'Project (All Collaborators)', + 'Project (Workspace)': 'Project (Workspace)', 'Remove from Favorites': 'Remove from Favorites', 'Remove marketplace': 'Remove marketplace', 'Remove marketplace "{{name}}"?': 'Remove marketplace "{{name}}"?', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 17d3b399574..dfc73b446fd 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -15,7 +15,6 @@ export default { 'User level': '使用者層級', 'Project level': '專案層級', - 'Local level': '本機層級', // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) @@ -60,11 +59,9 @@ export default { Favorites: '收藏', 'Global (User Scope)': '全域(使用者作用域)', 'Install Extension': '安裝擴展', - 'Install for all collaborators on this repository (project scope)': - '為此儲存庫的所有協作者安裝(專案作用域)', + 'Install for the current workspace (project scope)': + '為目前工作區安裝(專案作用域)', 'Install for you (user scope)': '僅為你安裝(使用者作用域)', - 'Install for you, in this repo only (local scope)': - '僅為你安裝,且僅在此儲存庫(本地作用域)', 'Install {{count}} extension(s) to which scope?': '將 {{count}} 個擴展安裝到哪個作用域?', Installed: '已安裝', @@ -75,8 +72,6 @@ export default { '成功 {{ok}} 個,失敗 {{fail}} 個:{{detail}}', 'Installing...': '安裝中...', 'Last updated: {{date}}': '最近更新:{{date}}', - Local: '本地', - 'Local (Only You)': '本地(僅你自己)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', 'MCP servers': 'MCP 伺服器', @@ -92,7 +87,7 @@ export default { 'Note: Uninstall permanently removes this extension.': '注意:卸載將永久移除此擴展。', 'Open homepage': '開啟主頁', - 'Project (All Collaborators)': '專案(所有協作者)', + 'Project (Workspace)': '專案(工作區)', 'Remove from Favorites': '從收藏中移除', 'Remove marketplace': '移除市場來源', 'Remove marketplace "{{name}}"?': '移除市場來源 "{{name}}"?', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 2bb08d90983..980947f2685 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -13,7 +13,6 @@ export default { 'User level': '用户级', 'Project level': '项目级', - 'Local level': '本地级', // ========================================================================== // Extensions manager dialog (Installed / Discover / Sources tabs) @@ -58,11 +57,9 @@ export default { Favorites: '收藏', 'Global (User Scope)': '全局(用户作用域)', 'Install Extension': '安装扩展', - 'Install for all collaborators on this repository (project scope)': - '为此仓库的所有协作者安装(项目作用域)', + 'Install for the current workspace (project scope)': + '为当前工作区安装(项目作用域)', 'Install for you (user scope)': '仅为你安装(用户作用域)', - 'Install for you, in this repo only (local scope)': - '仅为你安装,且仅在此仓库(本地作用域)', 'Install {{count}} extension(s) to which scope?': '将 {{count}} 个扩展安装到哪个作用域?', Installed: '已安装', @@ -73,8 +70,6 @@ export default { '成功 {{ok}} 个,失败 {{fail}} 个:{{detail}}', 'Installing...': '安装中...', 'Last updated: {{date}}': '最近更新:{{date}}', - Local: '本地', - 'Local (Only You)': '本地(仅你自己)', MCP: 'MCP', 'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。', 'MCP servers': 'MCP 服务器', @@ -90,7 +85,7 @@ export default { 'Note: Uninstall permanently removes this extension.': '注意:卸载将永久移除此扩展。', 'Open homepage': '打开主页', - 'Project (All Collaborators)': '项目(所有协作者)', + 'Project (Workspace)': '项目(工作区)', 'Remove from Favorites': '从收藏中移除', 'Remove marketplace': '移除市场源', 'Remove marketplace "{{name}}"?': '移除市场源 "{{name}}"?', diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 6cdb5eaaaa5..f20761548a1 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -189,10 +189,9 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('By: 42Crunch'); expect(frame).toContain('Will install:'); expect(frame).toContain('42crunch-audit'); - // Inline action selector with the three CC scopes + homepage + back. + // Inline action selector with the two scopes + homepage + back. expect(frame).toContain('Install for you (user scope)'); expect(frame).toContain('project scope'); - expect(frame).toContain('local scope'); expect(frame).toContain('Open homepage'); expect(frame).toContain('Back to extension list'); }); @@ -450,7 +449,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { stdin.write('\x1B[B'); // highlight Change scope stdin.write('\r'); // enter scope-select await waitFor(() => { - expect(lastFrame()).toContain('Project (All Collaborators)'); + expect(lastFrame()).toContain('Project (Workspace)'); }); stdin.write('\x1B[B'); // highlight Project (index 1) stdin.write('\r'); // select Project @@ -460,7 +459,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { 'project', ); }); - // Project/Local scope => disable at User, enable at Workspace. + // Project scope => disable at User, enable at Workspace. expect(manager.disableExtension).toHaveBeenCalledWith( 'alpha', SettingScope.User, @@ -480,7 +479,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { stdin.write('\x1B[B'); // highlight Change scope stdin.write('\r'); // enter scope-select again await waitFor(() => { - expect(lastFrame()).toContain('Current: Project (All Collaborators)'); + expect(lastFrame()).toContain('Current: Project (Workspace)'); }); }); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 893775d4753..76fa3bc644c 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -64,10 +64,9 @@ function scopeItems(): Array<{ { key: 'user', label: t('Global (User Scope)'), value: 'user' }, { key: 'project', - label: t('Project (All Collaborators)'), + label: t('Project (Workspace)'), value: 'project', }, - { key: 'local', label: t('Local (Only You)'), value: 'local' }, ]; } @@ -310,16 +309,9 @@ export const DiscoverTab = ({ }, { key: 'project', - label: t( - 'Install for all collaborators on this repository (project scope)', - ), + label: t('Install for the current workspace (project scope)'), value: 'project', }, - { - key: 'local', - label: t('Install for you, in this repo only (local scope)'), - value: 'local', - }, ); } if (selected?.homepage) { diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index f4868777913..49b41d15bb2 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -36,7 +36,6 @@ const debugLogger = createDebugLogger('INSTALLED_TAB'); const GROUP_ORDER: InstalledGroup[] = [ 'favorites', - 'local', 'user', 'project', 'disabled', @@ -47,8 +46,6 @@ const groupLabel = (group: InstalledGroup): string => { switch (group) { case 'favorites': return t('Favorites'); - case 'local': - return t('Local level'); case 'user': return t('User level'); case 'project': @@ -79,7 +76,6 @@ function groupFor( if (!isActive) return 'disabled'; if (isFavorite) return 'favorites'; if (scope === 'project') return 'project'; - if (scope === 'local') return 'local'; return 'user'; } diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts index 563d60f64d1..a38b639362d 100644 --- a/packages/cli/src/ui/components/extensions/types.ts +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -29,14 +29,9 @@ export interface ExtensionsTabDef { } /** - * Scope groups used to organize the Installed tab, mirroring Claude Code. + * Scope groups used to organize the Installed tab. */ -export type InstalledGroup = - | 'favorites' - | 'local' - | 'user' - | 'project' - | 'disabled'; +export type InstalledGroup = 'favorites' | 'user' | 'project' | 'disabled'; /** Minimal display info for an MCP server shown in the Installed tab. */ export interface InstalledMcpInfo { diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index 418d4a4d846..245c324cfcf 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -46,7 +46,6 @@ interface ExtensionActionsViewProps { const SCOPE_LABEL: Record = { user: 'User', project: 'Project', - local: 'Local', }; function scopeItems(): Array<{ @@ -58,10 +57,9 @@ function scopeItems(): Array<{ { key: 'user', label: t('Global (User Scope)'), value: 'user' }, { key: 'project', - label: t('Project (All Collaborators)'), + label: t('Project (Workspace)'), value: 'project', }, - { key: 'local', label: t('Local (Only You)'), value: 'local' }, ]; } diff --git a/packages/core/src/extension/extensionPreferences.test.ts b/packages/core/src/extension/extensionPreferences.test.ts index 220973f3a02..1400365a3e1 100644 --- a/packages/core/src/extension/extensionPreferences.test.ts +++ b/packages/core/src/extension/extensionPreferences.test.ts @@ -48,10 +48,25 @@ describe('ExtensionPreferencesStore', () => { it('records and reads per-extension scope intent', () => { store.setScope('alpha', 'project'); - store.setScope('beta', 'local'); + store.setScope('beta', 'user'); expect(store.getScope('alpha')).toBe('project'); - expect(store.getScope('beta')).toBe('local'); - expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'local' }); + expect(store.getScope('beta')).toBe('user'); + expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'user' }); + }); + + it('drops unknown scope values when reading persisted preferences', () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + favorites: [], + scopes: { alpha: 'project', beta: 'user', gamma: 'bogus' }, + }), + ); + expect(store.getScope('alpha')).toBe('project'); + expect(store.getScope('beta')).toBe('user'); + expect(store.getScope('gamma')).toBeUndefined(); + expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'user' }); }); it('clears all preference state for an extension', () => { diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts index bb07dbd767a..beec5fd1358 100644 --- a/packages/core/src/extension/extensionPreferences.ts +++ b/packages/core/src/extension/extensionPreferences.ts @@ -12,18 +12,20 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('EXT_PREFERENCES'); /** - * Install/visibility scope intent recorded for an extension. This mirrors the - * Claude Code plugin scopes so the Installed view can group extensions the way - * the user installed them: + * Install/visibility scope intent recorded for an extension. The Installed + * view uses it to group extensions the way the user installed them: * - `user` -> Global (User Scope), available everywhere. - * - `project` -> Project (All Collaborators), enabled for the current workspace. - * - `local` -> Local (Only You), enabled for the current workspace privately. + * - `project` -> Project (Workspace), enabled for the current workspace only. * * Enable/disable state itself still lives in `extension-enablement.json`; this * value only records *where the user chose to install* an extension so the UI - * can render the same grouping Claude Code does. + * can render the right grouping. */ -export type ExtensionScope = 'user' | 'project' | 'local'; +export type ExtensionScope = 'user' | 'project'; + +function isExtensionScope(value: unknown): value is ExtensionScope { + return value === 'user' || value === 'project'; +} export interface ExtensionPreferences { /** Names of extensions/MCP servers the user has favorited. */ @@ -49,12 +51,15 @@ export class ExtensionPreferencesStore { try { const content = fs.readFileSync(this.filePath, 'utf-8'); const parsed = JSON.parse(content) as Partial; + const rawScopes = + parsed.scopes && typeof parsed.scopes === 'object' ? parsed.scopes : {}; + const scopes: Record = {}; + for (const [name, value] of Object.entries(rawScopes)) { + if (isExtensionScope(value)) scopes[name] = value; + } return { favorites: Array.isArray(parsed.favorites) ? parsed.favorites : [], - scopes: - parsed.scopes && typeof parsed.scopes === 'object' - ? parsed.scopes - : {}, + scopes, }; } catch (error) { if ( From d8919ed1d2a9fa243f81ab4711999a2b26a7aa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 17:48:04 +0800 Subject: [PATCH 23/48] feat(extensions): add Ctrl+R shortcut to refresh Discover tab Press Ctrl+R in the Extensions Manager Discover tab to bypass the discover cache and re-fetch all marketplace sources. The refresh hint is merged into the dialog footer, and a success status is shown after the refresh completes. --- packages/cli/src/i18n/locales/en.js | 5 +- packages/cli/src/i18n/locales/zh-TW.js | 5 +- packages/cli/src/i18n/locales/zh.js | 5 +- .../extensions/ExtensionsManagerDialog.tsx | 2 +- .../extensions/tabs/DiscoverTab.tsx | 54 +++++++++++++------ 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index b62381a4c18..9070fd2bf28 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -91,6 +91,7 @@ export default { 'Note: Uninstall permanently removes this extension.', 'Open homepage': 'Open homepage', 'Project (Workspace)': 'Project (Workspace)', + 'Refreshed {{count}} extension(s).': 'Refreshed {{count}} extension(s).', 'Remove from Favorites': 'Remove from Favorites', 'Remove marketplace': 'Remove marketplace', 'Remove marketplace "{{name}}"?': 'Remove marketplace "{{name}}"?', @@ -99,8 +100,8 @@ export default { 'Scope:': 'Scope:', 'Set "{{name}}" scope to {{scope}}.': 'Set "{{name}}" scope to {{scope}}.', Sources: 'Sources', - 'Type to search · Space to toggle · Enter to view · Esc to go back': - 'Type to search · Space to toggle · Enter to view · Esc to go back', + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back': + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back', Uninstall: 'Uninstall', 'Uninstalled "{{name}}".': 'Uninstalled "{{name}}".', 'Update Now': 'Update Now', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index dfc73b446fd..64db8f020fb 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -88,6 +88,7 @@ export default { '注意:卸載將永久移除此擴展。', 'Open homepage': '開啟主頁', 'Project (Workspace)': '專案(工作區)', + 'Refreshed {{count}} extension(s).': '已刷新 {{count}} 個擴充。', 'Remove from Favorites': '從收藏中移除', 'Remove marketplace': '移除市場來源', 'Remove marketplace "{{name}}"?': '移除市場來源 "{{name}}"?', @@ -97,8 +98,8 @@ export default { 'Set "{{name}}" scope to {{scope}}.': '已將 "{{name}}" 的作用域設為 {{scope}}。', Sources: '來源', - 'Type to search · Space to toggle · Enter to view · Esc to go back': - '輸入以搜尋 · Space 切換 · Enter 查看 · Esc 返回', + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back': + '輸入以搜尋 · Space 切換 · Enter 查看 · Ctrl+R 刷新 · Esc 返回', Uninstall: '卸載', 'Uninstalled "{{name}}".': '已卸載 "{{name}}"。', 'Update Now': '立即更新', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 980947f2685..f2931bad0e4 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -86,6 +86,7 @@ export default { '注意:卸载将永久移除此扩展。', 'Open homepage': '打开主页', 'Project (Workspace)': '项目(工作区)', + 'Refreshed {{count}} extension(s).': '已刷新 {{count}} 个扩展。', 'Remove from Favorites': '从收藏中移除', 'Remove marketplace': '移除市场源', 'Remove marketplace "{{name}}"?': '移除市场源 "{{name}}"?', @@ -95,8 +96,8 @@ export default { 'Set "{{name}}" scope to {{scope}}.': '已将 "{{name}}" 的作用域设为 {{scope}}。', Sources: '来源', - 'Type to search · Space to toggle · Enter to view · Esc to go back': - '输入以搜索 · Space 切换 · Enter 查看 · Esc 返回', + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back': + '输入以搜索 · Space 切换 · Enter 查看 · Ctrl+R 刷新 · Esc 返回', Uninstall: '卸载', 'Uninstalled "{{name}}".': '已卸载 "{{name}}"。', 'Update Now': '立即更新', diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 1b3ab96bb54..a1aa933cc9c 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -38,7 +38,7 @@ function footerHint(tab: ExtensionsTab): string { switch (tab) { case EXTENSIONS_TABS.DISCOVER: return t( - 'Type to search · Space to toggle · Enter to view · Esc to go back', + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back', ); case EXTENSIONS_TABS.INSTALLED: return t( diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 76fa3bc644c..4abd5f06830 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -121,23 +121,38 @@ export const DiscoverTab = ({ setScrollOffset(0); }, [marketplaceFilter]); - const load = useCallback(async () => { - if (!extensionManager) { - setLoading(false); - return; - } - setLoading(true); - try { - const discovered = await extensionManager.discoverPlugins(); - setPlugins(discovered); - setCursor((prev) => (prev < discovered.length ? prev : 0)); - } catch (error) { - debugLogger.error('Failed to discover plugins:', error); - onStatus({ type: 'error', text: getErrorMessage(error) }); - } finally { - setLoading(false); - } - }, [extensionManager, onStatus]); + const load = useCallback( + async (options?: { refresh?: boolean }) => { + if (!extensionManager) { + setLoading(false); + return; + } + setLoading(true); + try { + const discovered = await extensionManager.discoverPlugins(options); + setPlugins(discovered); + setCursor((prev) => (prev < discovered.length ? prev : 0)); + if (options?.refresh) { + onStatus({ + type: 'success', + text: t('Refreshed {{count}} extension(s).', { + count: String(discovered.length), + }), + }); + } + } catch (error) { + debugLogger.error('Failed to discover plugins:', error); + onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + setLoading(false); + } + }, + [extensionManager, onStatus], + ); + + const handleReload = useCallback(() => { + void load({ refresh: true }); + }, [load]); useEffect(() => { load(); @@ -368,6 +383,11 @@ export const DiscoverTab = ({ setQuery((q) => q.slice(0, -1)); return; } + // Ctrl+R: refresh / re-discover all sources. + if (key.ctrl && key.name === 'r') { + handleReload(); + return; + } // Printable character -> append to the search query. if ( !key.ctrl && From 1abc5f8c8da1073ea668c885fea521700abf667e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 20:01:49 +0800 Subject: [PATCH 24/48] fix(extensions): keep j/k typeable in Discover search The Discover tab list reused the global SELECTION_UP/DOWN matchers, which include bare j/k as Vim-style navigation. Combined with type-to-search input, that made it impossible to type j or k into the search query. Switch the Discover list navigation to explicit arrow keys plus Ctrl+P/Ctrl+N, so bare j/k fall through to the printable-character branch and append to the query. Other extension tabs (Installed, Sources) remain pure lists and keep the Vim navigation. --- .../cli/src/ui/components/extensions/tabs/DiscoverTab.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 4abd5f06830..9c6f8d9f79e 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -9,7 +9,6 @@ import { Box, Text } from 'ink'; import open from 'open'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../../keyMatchers.js'; import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import { t } from '../../../../i18n/index.js'; @@ -346,14 +345,17 @@ export const DiscoverTab = ({ // List keyboard: navigate, type-to-search, Space to toggle, Enter to view // (or install the selected set), matching Claude Code's Discover list. + // Note: navigation here intentionally bypasses the global SELECTION_UP/DOWN + // matchers (which include bare j/k) so that j and k stay available as + // printable characters for the type-to-search query. useKeypress( (key) => { - if (keyMatchers[Command.SELECTION_UP](key)) { + if (key.name === 'up' || (key.ctrl && key.name === 'p')) { if (filtered.length > 0) setCursor((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); return; } - if (keyMatchers[Command.SELECTION_DOWN](key)) { + if (key.name === 'down' || (key.ctrl && key.name === 'n')) { if (filtered.length > 0) setCursor((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); return; From a52a5cdb4caec44ebbb17bc2d8aff2243dbc900a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 22:59:52 +0800 Subject: [PATCH 25/48] feat(extensions): install standalone Claude Code plugins from a git URL A repo whose root holds .claude-plugin/plugin.json (no marketplace.json) is a standalone Claude plugin. Previously installing one by git URL failed with "Configuration file not found" because the converter only handled gemini extensions and marketplace-based Claude plugins. Add convertClaudePluginStandalone: read plugin.json, fold MCP servers from a root .mcp.json into the config, collect skills/commands/agents, and write qwen-extension.json. The marketplace path is refactored to share the build step. MCP entries are normalized from Claude's transport shape (type:'http' + url) to Qwen's (httpUrl), and the cloned .git is dropped from the result. Note: cloneFromGit does not init git submodules, so submodule-provided skills are not installed yet. --- .../src/extension/claude-converter.test.ts | 80 +++++++++++ .../core/src/extension/claude-converter.ts | 130 +++++++++++++++--- .../core/src/extension/extensionManager.ts | 14 +- 3 files changed, 200 insertions(+), 24 deletions(-) diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 2c74dcf95c9..601586f1d7f 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -14,6 +14,7 @@ import { mergeClaudeConfigs, isClaudePluginConfig, convertClaudePluginPackage, + convertClaudePluginStandalone, type ClaudePluginConfig, type ClaudeMarketplacePluginConfig, type ClaudeMarketplaceConfig, @@ -669,6 +670,85 @@ describe('convertClaudePluginPackage', () => { }); }); +describe('convertClaudePluginStandalone', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-standalone-')); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('converts a repo with root .claude-plugin/plugin.json, .mcp.json and skills', async () => { + // Mirror the ClickHouse plugin layout: plugin.json metadata only, MCP in + // a root .mcp.json, and a skills/ folder with no commands/agents. + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ + name: 'clickhouse', + version: '1.0.0', + description: 'ClickHouse plugin', + }), + 'utf-8', + ); + fs.writeFileSync( + path.join(testDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + clickhouse: { type: 'http', url: 'https://mcp.clickhouse.cloud/mcp' }, + }, + }), + 'utf-8', + ); + const skillDir = path.join(testDir, 'skills', 'best-practices'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '# best practices', + 'utf-8', + ); + + const result = await convertClaudePluginStandalone(testDir); + + // A qwen-extension.json must exist so the installer can load it. + expect( + fs.existsSync(path.join(result.convertedDir, 'qwen-extension.json')), + ).toBe(true); + expect(result.config.name).toBe('clickhouse'); + expect(result.config.version).toBe('1.0.0'); + // MCP server folded in from .mcp.json and remapped to Qwen's transport + // shape: Claude `type: 'http'` + `url` becomes `httpUrl` (streamable HTTP). + const mcp = result.config.mcpServers?.['clickhouse'] as + | { httpUrl?: string; url?: string; type?: string } + | undefined; + expect(mcp?.httpUrl).toBe('https://mcp.clickhouse.cloud/mcp'); + expect(mcp?.url).toBeUndefined(); + expect(mcp?.type).toBeUndefined(); + // Skills folder preserved. + expect( + fs.existsSync( + path.join(result.convertedDir, 'skills', 'best-practices', 'SKILL.md'), + ), + ).toBe(true); + // VCS metadata is not shipped into the installed extension. + expect(fs.existsSync(path.join(result.convertedDir, '.git'))).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('throws when there is no .claude-plugin/plugin.json', async () => { + await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow( + /Plugin configuration not found/, + ); + }); +}); + describe('performVariableReplacement for Claude extensions', () => { let testDir: string; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 98fcf61a732..87bef005c85 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -305,6 +305,41 @@ ${systemPrompt} } } +/** + * Maps Claude `.mcp.json` server entries to Qwen's MCPServerConfig shape. + * Claude discriminates transport with a `type` field (`http`/`sse`/`stdio`), + * whereas Qwen keys off which field is set: `httpUrl` (streamable HTTP), + * `url` (SSE) or `command` (stdio). A Claude `type: 'http'` entry therefore + * has to move its `url` to `httpUrl`, and the now-meaningless `type` is dropped. + */ +function normalizeClaudeMcpServers( + servers: Record, +): Record { + const normalized: Record = {}; + for (const [name, raw] of Object.entries(servers)) { + const server = raw as unknown as Record; + // stdio / already-Qwen-shaped configs pass through unchanged. + if (server['command'] || server['httpUrl'] || server['tcp']) { + normalized[name] = raw; + continue; + } + if (typeof server['url'] === 'string') { + const rest = { ...server }; + delete rest['type']; + delete rest['url']; + normalized[name] = { + ...rest, + ...(server['type'] === 'http' + ? { httpUrl: server['url'] } + : { url: server['url'] }), + } as unknown as MCPServerConfig; + continue; + } + normalized[name] = raw; + } + return normalized; +} + /** * Converts a Claude plugin config to Qwen Code format. * @param claudeConfig Claude plugin configuration @@ -327,7 +362,7 @@ export function convertClaudeToQwenConfig( `[Claude Converter] MCP servers path not yet supported: ${claudeConfig.mcpServers}`, ); } else { - mcpServers = claudeConfig.mcpServers; + mcpServers = normalizeClaudeMcpServers(claudeConfig.mcpServers); } } @@ -434,7 +469,20 @@ export async function convertClaudePluginPackage( mergedConfig = marketplacePlugin as ClaudePluginConfig; } - // Step 4: Resolve MCP servers from JSON files if needed + return buildQwenExtensionFromPlugin(pluginSource, mergedConfig); +} + +/** + * Builds a converted Qwen extension directory from a resolved Claude plugin + * source directory and its merged config. Shared by the marketplace-based + * (`convertClaudePluginPackage`) and standalone (`convertClaudePluginStandalone`) + * conversion paths. + */ +async function buildQwenExtensionFromPlugin( + pluginSource: string, + mergedConfig: ClaudePluginConfig, +): Promise<{ config: ExtensionConfig; convertedDir: string }> { + // Resolve MCP servers from a JSON file path if needed. if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') { const mcpServersPath = path.isAbsolute(mergedConfig.mcpServers) ? mergedConfig.mcpServers @@ -455,16 +503,20 @@ export async function convertClaudePluginPackage( } } - // Step 5: Create temporary directory for converted extension const tmpDir = await ExtensionStorage.createTmpDir(); try { - // Step 6: Copy plugin files to temporary directory await copyDirectory(pluginSource, tmpDir); - // Step 6.1: Handle commands/skills/agents folders based on configuration - // If configuration specifies resources, only collect those - // If configuration doesn't specify, keep the existing folder (if exists) + // A standalone plugin's source is a full git clone; drop VCS metadata so + // it isn't shipped into the installed extension. + const gitDir = path.join(tmpDir, '.git'); + if (fs.existsSync(gitDir)) { + fs.rmSync(gitDir, { recursive: true, force: true }); + } + + // Handle commands/skills/agents folders: if the config specifies resources + // collect only those, otherwise keep the existing folder from the source. const resourceConfigs = [ { name: 'commands', config: mergedConfig.commands }, { name: 'skills', config: mergedConfig.skills }, @@ -475,22 +527,20 @@ export async function convertClaudePluginPackage( const folderPath = path.join(tmpDir, name); const sourceFolderPath = path.join(pluginSource, name); - // If config explicitly specifies resources, remove existing folder and collect only specified ones if (config) { if (fs.existsSync(folderPath)) { fs.rmSync(folderPath, { recursive: true, force: true }); } await collectResources(config, pluginSource, folderPath); - } - // If config doesn't specify and source folder doesn't exist in pluginSource, - // remove it from tmpDir (it was copied but not needed) - else if (!fs.existsSync(sourceFolderPath) && fs.existsSync(folderPath)) { + } else if ( + !fs.existsSync(sourceFolderPath) && + fs.existsSync(folderPath) + ) { fs.rmSync(folderPath, { recursive: true, force: true }); } - // Otherwise, keep the existing folder from pluginSource (default behavior) } - // Step 7: Handle hooks from file paths if needed + // Handle hooks from a file path if needed. if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') { const hooksPath = path.isAbsolute(mergedConfig.hooks) ? mergedConfig.hooks @@ -501,21 +551,17 @@ export async function convertClaudePluginPackage( const hooksContent = fs.readFileSync(hooksPath, 'utf-8'); const parsedHooks = JSON.parse(hooksContent); - // Check if the file has a top-level "hooks" property (like Claude plugins use) - // or if the entire file content is the hooks object let hooksData; if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') { hooksData = parsedHooks.hooks as { [K in HookEventName]?: HookDefinition[]; }; } else { - // Assume the entire file content is the hooks object hooksData = parsedHooks as { [K in HookEventName]?: HookDefinition[]; }; } - // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} mergedConfig.hooks = substituteHookVariables(hooksData, pluginSource); } catch (error) { debugLogger.warn( @@ -525,14 +571,11 @@ export async function convertClaudePluginPackage( } } - // Step 9: Convert collected agent files from Claude format to Qwen format const agentsDestDir = path.join(tmpDir, 'agents'); await convertAgentFiles(agentsDestDir); - // Step 10: Convert to Qwen format config const qwenConfig = convertClaudeToQwenConfig(mergedConfig); - // Step 11: Write qwen-extension.json const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json'); fs.writeFileSync( qwenConfigPath, @@ -545,7 +588,6 @@ export async function convertClaudePluginPackage( convertedDir: tmpDir, }; } catch (error) { - // Clean up temporary directory on error try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { @@ -555,6 +597,50 @@ export async function convertClaudePluginPackage( } } +/** + * Converts a standalone Claude plugin to Qwen Code format. A standalone plugin + * is a repo whose root holds `.claude-plugin/plugin.json` (no marketplace.json), + * as produced by installing a Claude Code plugin directly from a git URL. + * + * MCP servers declared in a root `.mcp.json` are folded into the config when + * plugin.json does not list them itself. + */ +export async function convertClaudePluginStandalone( + extensionDir: string, +): Promise<{ config: ExtensionConfig; convertedDir: string }> { + const pluginJsonPath = path.join( + extensionDir, + '.claude-plugin', + 'plugin.json', + ); + if (!fs.existsSync(pluginJsonPath)) { + throw new Error(`Plugin configuration not found at ${pluginJsonPath}`); + } + + const mergedConfig: ClaudePluginConfig = JSON.parse( + fs.readFileSync(pluginJsonPath, 'utf-8'), + ); + + if (!mergedConfig.mcpServers) { + const mcpJsonPath = path.join(extensionDir, '.mcp.json'); + if (fs.existsSync(mcpJsonPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')); + mergedConfig.mcpServers = (parsed?.mcpServers ?? parsed) as Record< + string, + MCPServerConfig + >; + } catch (error) { + debugLogger.warn( + `Failed to parse .mcp.json at ${mcpJsonPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + return buildQwenExtensionFromPlugin(extensionDir, mergedConfig); +} + /** * Collects resources (commands, skills, agents) to a destination folder. * Resources are always copied unconditionally — the caller diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index bfe60f552e7..261826de378 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -66,7 +66,10 @@ import { isGeminiExtensionConfig, convertGeminiExtensionPackage, } from './gemini-converter.js'; -import { convertClaudePluginPackage } from './claude-converter.js'; +import { + convertClaudePluginPackage, + convertClaudePluginStandalone, +} from './claude-converter.js'; import { glob } from 'glob'; import { createHash } from 'node:crypto'; import { ExtensionStorage } from './storage.js'; @@ -302,8 +305,15 @@ async function convertGeminiOrClaudeExtension( await convertClaudePluginPackage(extensionDir, pluginName) ).convertedDir; originSource = 'Claude'; + } else if ( + fs.existsSync(path.join(extensionDir, '.claude-plugin', 'plugin.json')) + ) { + // A standalone Claude plugin installed directly from a git URL: its root + // holds `.claude-plugin/plugin.json` with no marketplace.json. + newExtensionDir = (await convertClaudePluginStandalone(extensionDir)) + .convertedDir; + originSource = 'Claude'; } - // Claude plugin conversion not yet implemented return { extensionDir: newExtensionDir, originSource }; } From b98100c1f6f61d52f000f402b21d3577e3f77632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Wed, 10 Jun 2026 23:00:32 +0800 Subject: [PATCH 26/48] =?UTF-8?q?i18n(zh):=20relabel=20user-scope=20instal?= =?UTF-8?q?l=20as=20=E5=85=A8=E5=B1=80=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discover detail's user-scope install option now reads 全局安装(用户作用域) instead of 仅为你安装(用户作用域), which better conveys that user scope installs the extension globally. --- packages/cli/src/i18n/locales/zh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index f2931bad0e4..a2e91470b4b 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -59,7 +59,7 @@ export default { 'Install Extension': '安装扩展', 'Install for the current workspace (project scope)': '为当前工作区安装(项目作用域)', - 'Install for you (user scope)': '仅为你安装(用户作用域)', + 'Install for you (user scope)': '全局安装(用户作用域)', 'Install {{count}} extension(s) to which scope?': '将 {{count}} 个扩展安装到哪个作用域?', Installed: '已安装', From bf469923533db62f1ca6e917586bc6c535b46d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Thu, 11 Jun 2026 00:03:33 +0800 Subject: [PATCH 27/48] fix(extensions): keep the manager mounted during install consent prompts Consent, setting-input and plugin-choice requests raised by an install used to replace the ExtensionsManagerDialog in DialogManager, unmounting it mid-install: the dialog remounted on the default Installed tab with pre-install data, and the completion reload signal hit the dead instance. Render those prompts inside the dialog instead (tab content hidden but mounted), and gate the previously always-active key handlers (MCP detail steps, uninstall confirm) so hidden views can't double-handle keys while a prompt is shown. --- .../cli/src/ui/components/DialogManager.tsx | 18 +++++- .../ExtensionsManagerDialog.test.tsx | 21 +++++++ .../extensions/ExtensionsManagerDialog.tsx | 62 +++++++++++++++++-- .../extensions/steps/UninstallConfirmStep.tsx | 5 +- .../extensions/tabs/InstalledTab.tsx | 1 + .../extensions/views/ExtensionActionsView.tsx | 1 + .../extensions/views/McpServerActionsView.tsx | 12 +++- .../components/mcp/steps/AuthenticateStep.tsx | 3 +- .../components/mcp/steps/ServerDetailStep.tsx | 4 +- .../components/mcp/steps/ToolDetailStep.tsx | 3 +- .../ui/components/mcp/steps/ToolListStep.tsx | 3 +- packages/cli/src/ui/components/mcp/types.ts | 8 +++ 12 files changed, 127 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index aeef195dd4e..b63813a63b1 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -145,7 +145,13 @@ export const DialogManager = ({ /> ); } - if (uiState.confirmUpdateExtensionRequests.length > 0) { + // Extension install/update requests (consent, setting input, plugin choice) + // are rendered inside the ExtensionsManagerDialog when it is open, so the + // dialog keeps its tab/list state instead of being unmounted. + if ( + uiState.confirmUpdateExtensionRequests.length > 0 && + !uiState.isExtensionsManagerDialogOpen + ) { const request = uiState.confirmUpdateExtensionRequests[0]; return ( ); } - if (uiState.settingInputRequests.length > 0) { + if ( + uiState.settingInputRequests.length > 0 && + !uiState.isExtensionsManagerDialogOpen + ) { const request = uiState.settingInputRequests[0]; // Use settingName as key to force re-mount when switching between different settings return ( @@ -178,7 +187,10 @@ export const DialogManager = ({ /> ); } - if (uiState.pluginChoiceRequests.length > 0) { + if ( + uiState.pluginChoiceRequests.length > 0 && + !uiState.isExtensionsManagerDialogOpen + ) { const request = uiState.pluginChoiceRequests[0]; return ( { expect(frame).toContain('owner/repo (GitHub)'); expect(frame).toContain('@scope/name (npm)'); }); + + it('renders a pending install consent prompt in place of the tabs', async () => { + const uiState = { + extensionsUpdateState: new Map(), + confirmUpdateExtensionRequests: [ + { prompt: 'Do you trust this extension?', onConfirm: vi.fn() }, + ], + settingInputRequests: [], + pluginChoiceRequests: [], + } as unknown as UIState; + const { lastFrame } = renderDialog(createConfig(createManager()), { + initialTab: EXTENSIONS_TABS.DISCOVER, + uiState, + }); + await waitFor(() => { + expect(lastFrame()).toContain('Do you trust this extension?'); + }); + // The tab content is hidden while the prompt is shown, but the dialog + // (and its tab state) stays mounted. + expect(lastFrame()).not.toContain('Discover extensions'); + }); }); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index a1aa933cc9c..1440fb1cb5a 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -21,6 +21,9 @@ import { TabBar } from './TabBar.js'; import { DiscoverTab } from './tabs/DiscoverTab.js'; import { InstalledTab } from './tabs/InstalledTab.js'; import { SourcesTab } from './tabs/SourcesTab.js'; +import { ConsentPrompt } from '../ConsentPrompt.js'; +import { SettingInputPrompt } from '../SettingInputPrompt.js'; +import { PluginChoicePrompt } from '../PluginChoicePrompt.js'; export interface StatusMessage { type: 'info' | 'success' | 'error'; @@ -56,10 +59,26 @@ export function ExtensionsManagerDialog({ config, initialTab, }: ExtensionsManagerDialogProps) { - const { extensionsUpdateState } = useUIState(); + const { + extensionsUpdateState, + confirmUpdateExtensionRequests, + settingInputRequests, + pluginChoiceRequests, + } = useUIState(); const { columns } = useTerminalSize(); const boxWidth = columns - 4; + // Install flows raise interactive requests (consent, setting input, plugin + // choice). They are rendered here, inside the dialog, so the dialog stays + // mounted and keeps its tab/list state; DialogManager skips them while this + // dialog is open. (Unmounting would reset the active tab and drop the + // reload signal that refreshes the Installed tab after an install.) + const consentRequest = confirmUpdateExtensionRequests?.[0]; + const settingRequest = settingInputRequests?.[0]; + const pluginChoiceRequest = pluginChoiceRequests?.[0]; + const hasPendingRequest = + !!consentRequest || !!settingRequest || !!pluginChoiceRequest; + const [activeTab, setActiveTab] = useState( initialTab ?? EXTENSIONS_TABS.INSTALLED, ); @@ -113,7 +132,7 @@ export function ExtensionsManagerDialog({ onClose(); } }, - { isActive: !tabLocked }, + { isActive: !tabLocked && !hasPendingRequest }, ); if (!config) { @@ -135,7 +154,34 @@ export function ExtensionsManagerDialog({ return ( + {consentRequest ? ( + + ) : settingRequest ? ( + + ) : pluginChoiceRequest ? ( + + ) : null} Promise; onNavigateBack: () => void; + /** Whether this step should respond to keyboard input (default true). */ + isActive?: boolean; } const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP'); @@ -23,6 +25,7 @@ export function UninstallConfirmStep({ selectedExtension, onConfirm, onNavigateBack, + isActive = true, }: UninstallConfirmStepProps) { useKeypress( async (key) => { @@ -39,7 +42,7 @@ export function UninstallConfirmStep({ onNavigateBack(); } }, - { isActive: true }, + { isActive }, ); if (!selectedExtension) { diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 49b41d15bb2..f5b924652e6 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -374,6 +374,7 @@ export const InstalledTab = ({ setSub('detail')} /> diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index 4299ce76192..47aacd86a2c 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -36,6 +36,8 @@ interface McpServerActionsViewProps { config: Config; /** Name of the installed MCP server to manage. */ serverName: string; + /** Whether this view should respond to keyboard input. */ + isActive: boolean; onStatus: (status: StatusMessage | null) => void; /** Ask the parent list to reload (state changed). */ onReload: () => void; @@ -52,6 +54,7 @@ interface McpServerActionsViewProps { export const McpServerActionsView = ({ config, serverName, + isActive, onStatus, onReload, onExit, @@ -250,6 +253,7 @@ export const McpServerActionsView = ({ return ( { setSub('detail'); void reload(); @@ -260,7 +264,11 @@ export const McpServerActionsView = ({ if (sub === 'tool-detail') { return ( - setSub('tools')} /> + setSub('tools')} + /> ); } @@ -269,6 +277,7 @@ export const McpServerActionsView = ({ { setSelectedTool(tool); setSub('tool-detail'); @@ -281,6 +290,7 @@ export const McpServerActionsView = ({ return ( setSub('tools')} onReconnect={() => void handleReconnect()} onDisable={() => void handleToggleDisable()} diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index 7dbf0b29df6..fbbfecbb13f 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -57,6 +57,7 @@ function copyToClipboardViaOsc52(text: string): boolean { export const AuthenticateStep: React.FC = ({ server, onBack, + isActive = true, }) => { const config = useConfig(); const [authState, setAuthState] = useState('idle'); @@ -202,7 +203,7 @@ export const AuthenticateStep: React.FC = ({ }); } }, - { isActive: true }, + { isActive }, ); useEffect(() => { diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index 3718f5e8786..32e49e39022 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -35,6 +35,7 @@ export const ServerDetailStep: React.FC = ({ onAuthenticate, onClearAuth, onBack, + isActive = true, }) => { const statusColor = server ? server.isDisabled @@ -106,7 +107,7 @@ export const ServerDetailStep: React.FC = ({ onBack(); } }, - { isActive: true }, + { isActive }, ); if (!server) { @@ -218,6 +219,7 @@ export const ServerDetailStep: React.FC = ({ items={actions} + isFocused={isActive} showNumbers={false} onSelect={(value: ServerAction) => { switch (value) { diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx index d864c5732d5..b9ceba38af1 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -81,6 +81,7 @@ const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => { export const ToolDetailStep: React.FC = ({ tool, onBack, + isActive = true, }) => { useKeypress( (key) => { @@ -88,7 +89,7 @@ export const ToolDetailStep: React.FC = ({ onBack(); } }, - { isActive: true }, + { isActive }, ); if (!tool) { diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx index 9aec123b0a2..fc99a9695d5 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -17,6 +17,7 @@ export const ToolListStep: React.FC = ({ tools, onSelect, onBack, + isActive = true, }) => { const [selectedIndex, setSelectedIndex] = useState(0); @@ -63,7 +64,7 @@ export const ToolListStep: React.FC = ({ } } }, - { isActive: true }, + { isActive }, ); if (tools.length === 0) { diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 82d9ab7ba34..f4e19fc3844 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -138,6 +138,8 @@ export interface ServerDetailStepProps { onClearAuth?: () => void; /** 返回回调 */ onBack: () => void; + /** 是否响应键盘输入(默认 true) */ + isActive?: boolean; } /** @@ -164,6 +166,8 @@ export interface ToolListStepProps { onSelect: (tool: MCPToolDisplayInfo) => void; /** 返回回调 */ onBack: () => void; + /** 是否响应键盘输入(默认 true) */ + isActive?: boolean; } /** @@ -174,6 +178,8 @@ export interface ToolDetailStepProps { tool: MCPToolDisplayInfo | null; /** 返回回调 */ onBack: () => void; + /** 是否响应键盘输入(默认 true) */ + isActive?: boolean; } /** @@ -184,6 +190,8 @@ export interface AuthenticateStepProps { server: MCPServerDisplayInfo | null; /** 返回回调 */ onBack: () => void; + /** 是否响应键盘输入(默认 true) */ + isActive?: boolean; } /** From c308b6e7ecb0e676775a2599106841f923a44884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Thu, 11 Jun 2026 00:03:51 +0800 Subject: [PATCH 28/48] feat(extensions): show loading feedback for scope change and toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing an extension's scope now swaps the selector for a "Changing scope..." line (Esc ignored while in flight), and the Installed tab's Space toggle reports "Enabling/Disabling ..." in the status line right away — MCP enable rediscovers tools and can take a while. Overlapping toggle/favorite presses are ignored until the in-flight mutation finishes. --- packages/cli/src/i18n/locales/en.js | 5 ++++ packages/cli/src/i18n/locales/zh-TW.js | 5 ++++ packages/cli/src/i18n/locales/zh.js | 5 ++++ .../extensions/tabs/InstalledTab.tsx | 28 +++++++++++++++++-- .../extensions/views/ExtensionActionsView.tsx | 26 +++++++++++------ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 9070fd2bf28..509f0d96fc9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -38,6 +38,7 @@ export default { 'By: {{a}}': 'By: {{a}}', 'Change scope': 'Change scope', 'Change scope for "{{name}}":': 'Change scope for "{{name}}":', + 'Changing scope...': 'Changing scope...', 'Checked "{{name}}" for updates.': 'Checked "{{name}}" for updates.', 'Claude plugin marketplace': 'Claude plugin marketplace', Commands: 'Commands', @@ -46,8 +47,12 @@ export default { 'Current: {{scope}}': 'Current: {{scope}}', Disabled: 'Disabled', Discover: 'Discover', + 'Disabling "{{name}}"...': 'Disabling "{{name}}"...', + 'Disabling MCP "{{name}}"...': 'Disabling MCP "{{name}}"...', 'Discover extensions': 'Discover extensions', 'Discovering extensions...': 'Discovering extensions...', + 'Enabling "{{name}}"...': 'Enabling "{{name}}"...', + 'Enabling MCP "{{name}}"...': 'Enabling MCP "{{name}}"...', 'Enter extension source:': 'Enter extension source:', 'Enter marketplace source (Claude format):': 'Enter marketplace source (Claude format):', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 64db8f020fb..54c336a7338 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -38,6 +38,7 @@ export default { 'By: {{a}}': '作者:{{a}}', 'Change scope': '變更作用域', 'Change scope for "{{name}}":': '變更 "{{name}}" 的作用域:', + 'Changing scope...': '正在變更作用域...', 'Checked "{{name}}" for updates.': '已檢查 "{{name}}" 的更新。', 'Claude plugin marketplace': 'Claude 外掛市場', Commands: '命令', @@ -46,8 +47,12 @@ export default { 'Current: {{scope}}': '目前:{{scope}}', Disabled: '已禁用', Discover: '發現', + 'Disabling "{{name}}"...': '正在禁用 "{{name}}"...', + 'Disabling MCP "{{name}}"...': '正在禁用 MCP "{{name}}"...', 'Discover extensions': '發現擴展', 'Discovering extensions...': '正在發現擴展...', + 'Enabling "{{name}}"...': '正在啟用 "{{name}}"...', + 'Enabling MCP "{{name}}"...': '正在啟用 MCP "{{name}}"...', 'Enter extension source:': '輸入擴展來源:', 'Enter marketplace source (Claude format):': '輸入市場來源位址(Claude 格式):', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a2e91470b4b..a588caf4377 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -36,6 +36,7 @@ export default { 'By: {{a}}': '作者:{{a}}', 'Change scope': '更改作用域', 'Change scope for "{{name}}":': '更改 "{{name}}" 的作用域:', + 'Changing scope...': '正在更改作用域...', 'Checked "{{name}}" for updates.': '已检查 "{{name}}" 的更新。', 'Claude plugin marketplace': 'Claude 插件市场', Commands: '命令', @@ -44,8 +45,12 @@ export default { 'Current: {{scope}}': '当前:{{scope}}', Disabled: '已禁用', Discover: '发现', + 'Disabling "{{name}}"...': '正在禁用 "{{name}}"...', + 'Disabling MCP "{{name}}"...': '正在禁用 MCP "{{name}}"...', 'Discover extensions': '发现扩展', 'Discovering extensions...': '正在发现扩展...', + 'Enabling "{{name}}"...': '正在启用 "{{name}}"...', + 'Enabling MCP "{{name}}"...': '正在启用 MCP "{{name}}"...', 'Enter extension source:': '输入扩展来源:', 'Enter marketplace source (Claude format):': '输入市场源地址(Claude 格式):', diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index f5b924652e6..507b888de51 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -96,6 +96,9 @@ export const InstalledTab = ({ // group) the cursor — and any open detail view — stays on the SAME item // rather than whatever now sits at the old index. const selectedKeyRef = useRef(null); + // Guards against overlapping mutations (e.g. mashing Space) while an + // enable/disable is still being applied. + const mutatingRef = useRef(false); const extensionManager = config.getExtensionManager(); @@ -231,9 +234,16 @@ export const InstalledTab = ({ const togglePlugin = useCallback( async (item: Extract) => { - if (!extensionManager) return; + if (!extensionManager || mutatingRef.current) return; const scope = item.scope === 'user' ? SettingScope.User : SettingScope.Workspace; + mutatingRef.current = true; + onStatus({ + type: 'info', + text: item.isActive + ? t('Disabling "{{name}}"...', { name: item.name }) + : t('Enabling "{{name}}"...', { name: item.name }), + }); try { if (item.isActive) { await extensionManager.disableExtension(item.name, scope); @@ -250,6 +260,8 @@ export const InstalledTab = ({ await load(); } catch (error) { onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + mutatingRef.current = false; } }, [extensionManager, load, onStatus], @@ -257,7 +269,16 @@ export const InstalledTab = ({ const toggleMcp = useCallback( async (item: Extract) => { + if (mutatingRef.current) return; const toolRegistry = config.getToolRegistry(); + mutatingRef.current = true; + // Enabling rediscovers the server's tools, which can take a while. + onStatus({ + type: 'info', + text: item.isActive + ? t('Disabling MCP "{{name}}"...', { name: item.name }) + : t('Enabling MCP "{{name}}"...', { name: item.name }), + }); try { const settings = loadSettings(); const targetScope = @@ -307,6 +328,8 @@ export const InstalledTab = ({ await load(); } catch (error) { onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + mutatingRef.current = false; } }, [config, load, onStatus], @@ -345,7 +368,8 @@ export const InstalledTab = ({ void toggleMcp(selectedItem); } } else if (key.sequence === 'f' && !key.ctrl && !key.meta) { - if (selectedItem) void toggleFavorite(selectedItem); + if (selectedItem && !mutatingRef.current) + void toggleFavorite(selectedItem); } }, { isActive: isActive && view === 'list' }, diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index 22479ac3f1b..52fd4f1dd80 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -85,6 +85,9 @@ export const ExtensionActionsView = ({ const [scope, setScope] = useState( () => manager?.getExtensionScope(extension.name) ?? 'user', ); + // Changing scope re-writes enablement settings and can take a moment; + // surfaced as a loading line so the selection doesn't look ignored. + const [scopeBusy, setScopeBusy] = useState(false); const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; @@ -164,6 +167,7 @@ export const ExtensionActionsView = ({ async (newScope: ExtensionScope) => { if (!manager) return; const name = extension.name; + setScopeBusy(true); try { manager.setExtensionScope(name, newScope); // Apply enablement: Global -> User; Project/Local -> workspace only. @@ -186,6 +190,7 @@ export const ExtensionActionsView = ({ } catch (error) { onStatus({ type: 'error', text: getErrorMessage(error) }); } + setScopeBusy(false); setSub('detail'); }, [manager, extension, onStatus, onReload], @@ -210,9 +215,10 @@ export const ExtensionActionsView = ({ ); // Escape: from the detail leaves; from a sub-view returns to the detail. + // Ignored while a scope change is in flight so it can't be abandoned midway. useKeypress( (key) => { - if (key.name === 'escape') { + if (key.name === 'escape' && !scopeBusy) { if (sub === 'detail') onExit(); else setSub('detail'); } @@ -238,13 +244,17 @@ export const ExtensionActionsView = ({ {t('Current: {{scope}}', { scope: items[currentIndex].label })} - void handleScope(value)} - /> + {scopeBusy ? ( + {t('Changing scope...')} + ) : ( + void handleScope(value)} + /> + )} ); } From fcdb7c1d9d1b34d2b1bc5010e0435bd213b861a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Thu, 11 Jun 2026 09:09:11 +0800 Subject: [PATCH 29/48] feat(extensions): add --scope to install and a sources CLI command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the qwen extensions CLI up to par with /extensions manage: install --scope user|project (workspace accepted as an alias) records the scope preference and, for project, re-scopes enablement to the current workspace only — mirroring the Discover tab's install flow. New sources add/list/update/remove subcommands manage the Claude marketplace sources that power the Discover tab. --- docs/users/extension/introduction.md | 28 +++ packages/cli/src/commands/extensions.tsx | 2 + .../src/commands/extensions/install.test.ts | 77 ++++++ .../cli/src/commands/extensions/install.ts | 44 +++- .../src/commands/extensions/sources.test.ts | 231 ++++++++++++++++++ .../cli/src/commands/extensions/sources.ts | 168 +++++++++++++ packages/cli/src/i18n/locales/en.js | 23 ++ packages/cli/src/i18n/locales/zh-TW.js | 20 ++ packages/cli/src/i18n/locales/zh.js | 20 ++ 9 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/extensions/sources.test.ts create mode 100644 packages/cli/src/commands/extensions/sources.ts diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index b3e88ca00fb..f9da6da0c51 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -133,6 +133,34 @@ qwen extensions install /path/to/your/extension Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub. +#### Choosing an install scope + +By default, an installed extension is enabled globally (user scope). Pass `--scope project` to enable it only for the current workspace: + +```bash +qwen extensions install --scope project +``` + +`--scope workspace` is accepted as an alias of `--scope project`. This matches the scope choice offered when installing from the `/extensions manage` Discover tab. + +### Managing marketplace sources + +Marketplace sources (Claude plugin marketplaces) power the Discover tab in `/extensions manage`. You can manage them from the CLI as well: + +```bash +# Add a marketplace (owner/repo, git URL, https URL to marketplace.json, or local path) +qwen extensions sources add + +# List configured marketplaces +qwen extensions sources list + +# Re-fetch a marketplace's plugin listing +qwen extensions sources update + +# Remove a marketplace +qwen extensions sources remove +``` + ### Uninstalling an extension To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example: diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index a69a1d85b35..1cb4340be27 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { settingsCommand } from './extensions/settings.js'; +import { sourcesCommand } from './extensions/sources.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -29,6 +30,7 @@ export const extensionsCommand: CommandModule = { .command(linkCommand) .command(newCommand) .command(settingsCommand) + .command(sourcesCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index f49c2d48a79..c37226465b5 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -10,6 +10,9 @@ import yargs from 'yargs'; const mockInstallExtension = vi.hoisted(() => vi.fn()); const mockRefreshCache = vi.hoisted(() => vi.fn()); +const mockSetExtensionScope = vi.hoisted(() => vi.fn()); +const mockEnableExtension = vi.hoisted(() => vi.fn()); +const mockDisableExtension = vi.hoisted(() => vi.fn()); const mockParseInstallSource = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockRequestConsentOrFail = vi.hoisted(() => vi.fn()); @@ -22,6 +25,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ ExtensionManager: vi.fn().mockImplementation(() => ({ installExtension: mockInstallExtension, refreshCache: mockRefreshCache, + setExtensionScope: mockSetExtensionScope, + enableExtension: mockEnableExtension, + disableExtension: mockDisableExtension, })), parseInstallSource: mockParseInstallSource, })); @@ -38,6 +44,12 @@ vi.mock('../../config/trustedFolders.js', () => ({ vi.mock('../../config/settings.js', () => ({ loadSettings: mockLoadSettings, + SettingScope: { + User: 'User', + Workspace: 'Workspace', + System: 'System', + SystemDefaults: 'SystemDefaults', + }, })); vi.mock('../../utils/errors.js', () => ({ @@ -225,4 +237,69 @@ describe('handleInstall', () => { processSpy.mockRestore(); }); + + it('should re-scope enablement to the workspace for a project-scope install', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + + await handleInstall({ source: 'git@some-url', scope: 'project' }); + + expect(mockSetExtensionScope).toHaveBeenCalledWith( + 'scoped-extension', + 'project', + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'scoped-extension', + 'User', + ); + expect(mockEnableExtension).toHaveBeenCalledWith( + 'scoped-extension', + 'Workspace', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Extension "scoped-extension" installed successfully and enabled for the current workspace.', + ); + }); + + it('should accept workspace as an alias of project scope', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + + await handleInstall({ source: 'git@some-url', scope: 'workspace' }); + + expect(mockSetExtensionScope).toHaveBeenCalledWith( + 'scoped-extension', + 'project', + ); + expect(mockEnableExtension).toHaveBeenCalledWith( + 'scoped-extension', + 'Workspace', + ); + }); + + it('should record user scope without re-scoping enablement', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'user-extension' }); + + await handleInstall({ source: 'git@some-url', scope: 'user' }); + + expect(mockSetExtensionScope).toHaveBeenCalledWith( + 'user-extension', + 'user', + ); + expect(mockDisableExtension).not.toHaveBeenCalled(); + expect(mockEnableExtension).not.toHaveBeenCalled(); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Extension "user-extension" installed successfully and enabled.', + ); + }); }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index cc63f6370ce..7bbde265eb1 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -9,11 +9,12 @@ import type { CommandModule } from 'yargs'; import { ExtensionManager, parseInstallSource, + type ExtensionScope, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; -import { loadSettings } from '../../config/settings.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; import { requestConsentOrFail, requestConsentNonInteractive, @@ -28,6 +29,12 @@ interface InstallArgs { allowPreRelease?: boolean; consent?: boolean; registry?: string; + scope?: string; +} + +// "workspace" is accepted as an alias of "project" to match enable/disable. +function normalizeScope(scope: string | undefined): ExtensionScope { + return scope === 'project' || scope === 'workspace' ? 'project' : 'user'; } export async function handleInstall(args: InstallArgs) { @@ -87,10 +94,31 @@ export async function handleInstall(args: InstallArgs) { }, requestConsent, ); + const scope = normalizeScope(args.scope); + if (args.scope) { + extensionManager.setExtensionScope(extension.name, scope); + // installExtension auto-enables at the user (global) scope. For a + // project-scoped install, re-scope enablement to this workspace only. + if (scope === 'project') { + await extensionManager.disableExtension( + extension.name, + SettingScope.User, + ); + await extensionManager.enableExtension( + extension.name, + SettingScope.Workspace, + ); + } + } writeStdoutLine( - t('Extension "{{name}}" installed successfully and enabled.', { - name: extension.name, - }), + scope === 'project' + ? t( + 'Extension "{{name}}" installed successfully and enabled for the current workspace.', + { name: extension.name }, + ) + : t('Extension "{{name}}" installed successfully and enabled.', { + name: extension.name, + }), ); } catch (error) { writeStderrLine(getErrorMessage(error)); @@ -135,6 +163,13 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) + .option('scope', { + describe: t( + 'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).', + ), + type: 'string', + choices: ['user', 'project', 'workspace'], + }) .check((argv) => { if (!argv.source) { throw new Error(t('The source argument must be provided.')); @@ -149,6 +184,7 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, consent: argv['consent'] as boolean | undefined, registry: argv['registry'] as string | undefined, + scope: argv['scope'] as string | undefined, }); }, }; diff --git a/packages/cli/src/commands/extensions/sources.test.ts b/packages/cli/src/commands/extensions/sources.test.ts new file mode 100644 index 00000000000..77938cf51c6 --- /dev/null +++ b/packages/cli/src/commands/extensions/sources.test.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + sourcesCommand, + handleSourcesAdd, + handleSourcesRemove, + handleSourcesList, + handleSourcesUpdate, +} from './sources.js'; +import yargs from 'yargs'; + +const mockAddSource = vi.hoisted(() => vi.fn()); +const mockRemoveSource = vi.hoisted(() => vi.fn()); +const mockGetSources = vi.hoisted(() => vi.fn()); +const mockLoadSource = vi.hoisted(() => vi.fn()); +const mockMarkSourceUpdated = vi.hoisted(() => vi.fn()); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('./utils.js', () => ({ + getExtensionManager: vi.fn().mockResolvedValue({ + addSource: mockAddSource, + removeSource: mockRemoveSource, + getSources: mockGetSources, + loadSource: mockLoadSource, + markSourceUpdated: mockMarkSourceUpdated, + }), +})); + +vi.mock('../../utils/errors.js', () => ({ + getErrorMessage: vi.fn((error: Error) => error.message), +})); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + +describe('extensions sources command', () => { + it('should parse the sources subcommands', () => { + // Benign mock returns: parse() invokes the handlers asynchronously. + mockAddSource.mockResolvedValue({ name: 'my-marketplace' }); + mockRemoveSource.mockReturnValue(true); + mockGetSources.mockReturnValue([ + { name: 'my-marketplace', source: 'owner/repo', type: 'github' }, + ]); + mockLoadSource.mockResolvedValue({ name: 'my-marketplace', plugins: [] }); + // A fresh parser per parse: yargs carries validation state across calls. + const parse = (command: string) => + yargs([]).command(sourcesCommand).fail(false).locale('en').parse(command); + expect(() => parse('sources add owner/repo')).not.toThrow(); + expect(() => parse('sources remove my-marketplace')).not.toThrow(); + expect(() => parse('sources list')).not.toThrow(); + expect(() => parse('sources update my-marketplace')).not.toThrow(); + }); + + it('should fail without a subcommand', () => { + const parser = yargs([]).command(sourcesCommand).fail(false).locale('en'); + expect(() => parser.parse('sources')).toThrow(); + }); +}); + +describe('handleSourcesAdd', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('adds a marketplace and reports its name', async () => { + mockAddSource.mockResolvedValue({ + name: 'my-marketplace', + source: 'owner/repo', + type: 'github', + }); + + await handleSourcesAdd({ source: 'owner/repo' }); + + expect(mockAddSource).toHaveBeenCalledWith('owner/repo'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Added marketplace "my-marketplace".', + ); + }); + + it('reports errors and exits with code 1', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockAddSource.mockRejectedValue(new Error('No marketplace found')); + + await handleSourcesAdd({ source: 'owner/repo' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith('No marketplace found'); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); +}); + +describe('handleSourcesRemove', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('removes a marketplace by name', async () => { + mockRemoveSource.mockReturnValue(true); + + await handleSourcesRemove({ name: 'my-marketplace' }); + + expect(mockRemoveSource).toHaveBeenCalledWith('my-marketplace'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Removed marketplace "my-marketplace".', + ); + }); + + it('errors when the marketplace is unknown', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockRemoveSource.mockReturnValue(false); + + await handleSourcesRemove({ name: 'missing' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Marketplace "missing" not found.', + ); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); +}); + +describe('handleSourcesList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('prints a message when no sources are configured', async () => { + mockGetSources.mockReturnValue([]); + + await handleSourcesList(); + + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'No marketplace sources added yet.', + ); + }); + + it('lists configured sources with source and type', async () => { + mockGetSources.mockReturnValue([ + { + name: 'market-a', + source: 'owner/repo', + type: 'github', + lastUpdatedAt: '2026-06-10T00:00:00.000Z', + }, + { + name: 'market-b', + source: 'https://example.com/marketplace.json', + type: 'http', + }, + ]); + + await handleSourcesList(); + + const output = mockWriteStdoutLine.mock.calls[0][0] as string; + expect(output).toContain('market-a'); + expect(output).toContain('owner/repo'); + expect(output).toContain('market-b'); + expect(output).toContain('https://example.com/marketplace.json'); + }); +}); + +describe('handleSourcesUpdate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('re-fetches the marketplace and reports the plugin count', async () => { + mockGetSources.mockReturnValue([ + { name: 'market-a', source: 'owner/repo', type: 'github' }, + ]); + mockLoadSource.mockResolvedValue({ + name: 'market-a', + plugins: [{ name: 'p1' }, { name: 'p2' }], + }); + + await handleSourcesUpdate({ name: 'market-a' }); + + expect(mockLoadSource).toHaveBeenCalledWith('owner/repo'); + expect(mockMarkSourceUpdated).toHaveBeenCalledWith('market-a'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Updated marketplace "market-a".', + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith('2 available extensions'); + }); + + it('errors when the marketplace is unknown', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockGetSources.mockReturnValue([]); + + await handleSourcesUpdate({ name: 'missing' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Marketplace "missing" not found.', + ); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); + + it('errors when the marketplace cannot be loaded', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockGetSources.mockReturnValue([ + { name: 'market-a', source: 'owner/repo', type: 'github' }, + ]); + mockLoadSource.mockResolvedValue(null); + + await handleSourcesUpdate({ name: 'market-a' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Could not load this marketplace.', + ); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/extensions/sources.ts b/packages/cli/src/commands/extensions/sources.ts new file mode 100644 index 00000000000..5b270910c79 --- /dev/null +++ b/packages/cli/src/commands/extensions/sources.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { redactUrlCredentials } from '@qwen-code/qwen-code-core'; +import { getErrorMessage } from '../../utils/errors.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { getExtensionManager } from './utils.js'; +import { t } from '../../i18n/index.js'; + +export async function handleSourcesAdd(args: { source: string }) { + try { + const extensionManager = await getExtensionManager(); + const entry = await extensionManager.addSource(args.source); + writeStdoutLine(t('Added marketplace "{{name}}".', { name: entry.name })); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export async function handleSourcesRemove(args: { name: string }) { + try { + const extensionManager = await getExtensionManager(); + if (!extensionManager.removeSource(args.name)) { + writeStderrLine( + t('Marketplace "{{name}}" not found.', { name: args.name }), + ); + process.exit(1); + return; + } + writeStdoutLine(t('Removed marketplace "{{name}}".', { name: args.name })); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export async function handleSourcesList() { + try { + const extensionManager = await getExtensionManager(); + const sources = extensionManager.getSources(); + if (sources.length === 0) { + writeStdoutLine(t('No marketplace sources added yet.')); + return; + } + writeStdoutLine( + sources + .map((entry) => { + let output = `${entry.name}`; + output += `\n ${t('Source:')} ${redactUrlCredentials(entry.source)} (${t('Type:')} ${entry.type})`; + const updated = entry.lastUpdatedAt ?? entry.addedAt; + if (updated) { + output += `\n ${t('Last updated: {{date}}', { date: updated })}`; + } + return output; + }) + .join('\n\n'), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +export async function handleSourcesUpdate(args: { name: string }) { + try { + const extensionManager = await getExtensionManager(); + const entry = extensionManager + .getSources() + .find((source) => source.name === args.name); + if (!entry) { + writeStderrLine( + t('Marketplace "{{name}}" not found.', { name: args.name }), + ); + process.exit(1); + return; + } + const config = await extensionManager.loadSource(entry.source); + if (!config) { + writeStderrLine(t('Could not load this marketplace.')); + process.exit(1); + return; + } + extensionManager.markSourceUpdated(entry.name); + writeStdoutLine(t('Updated marketplace "{{name}}".', { name: entry.name })); + writeStdoutLine( + t('{{count}} available extensions', { + count: String(config.plugins?.length ?? 0), + }), + ); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +const addCommand: CommandModule = { + command: 'add ', + describe: t('Adds a marketplace source (Claude format).'), + builder: (yargs) => + yargs.positional('source', { + describe: t( + 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.', + ), + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await handleSourcesAdd({ source: argv['source'] as string }); + }, +}; + +const removeCommand: CommandModule = { + command: 'remove ', + describe: t('Removes a marketplace source.'), + builder: (yargs) => + yargs.positional('name', { + describe: t('The name of the marketplace to remove.'), + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await handleSourcesRemove({ name: argv['name'] as string }); + }, +}; + +const listCommand: CommandModule = { + command: 'list', + describe: t('Lists configured marketplace sources.'), + builder: (yargs) => yargs, + handler: async () => { + await handleSourcesList(); + }, +}; + +const updateCommand: CommandModule = { + command: 'update ', + describe: t('Re-fetches a marketplace source and its plugin listing.'), + builder: (yargs) => + yargs.positional('name', { + describe: t('The name of the marketplace to update.'), + type: 'string', + demandOption: true, + }), + handler: async (argv) => { + await handleSourcesUpdate({ name: argv['name'] as string }); + }, +}; + +export const sourcesCommand: CommandModule = { + command: 'sources ', + describe: t('Manage marketplace sources for discovering extensions.'), + builder: (yargs) => + yargs + .command(addCommand) + .command(removeCommand) + .command(listCommand) + .command(updateCommand) + .demandCommand(1, t('You need at least one command before continuing.')) + .version(false), + handler: () => { + // Yargs shows the help menu when no subcommand is provided. + }, +}; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 509f0d96fc9..eb61e38fbcb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -675,6 +675,29 @@ export default { 'Uninstall an extension': 'Uninstall an extension', 'No extensions installed.': 'No extensions installed.', 'Extension "{{name}}" not found.': 'Extension "{{name}}" not found.', + 'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).': + 'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).', + 'Extension "{{name}}" installed successfully and enabled for the current workspace.': + 'Extension "{{name}}" installed successfully and enabled for the current workspace.', + 'Marketplace "{{name}}" not found.': 'Marketplace "{{name}}" not found.', + 'No marketplace sources added yet.': 'No marketplace sources added yet.', + 'Adds a marketplace source (Claude format).': + 'Adds a marketplace source (Claude format).', + 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': + 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.', + 'Removes a marketplace source.': 'Removes a marketplace source.', + 'The name of the marketplace to remove.': + 'The name of the marketplace to remove.', + 'Lists configured marketplace sources.': + 'Lists configured marketplace sources.', + 'Re-fetches a marketplace source and its plugin listing.': + 'Re-fetches a marketplace source and its plugin listing.', + 'The name of the marketplace to update.': + 'The name of the marketplace to update.', + 'Manage marketplace sources for discovering extensions.': + 'Manage marketplace sources for discovering extensions.', + 'You need at least one command before continuing.': + 'You need at least one command before continuing.', 'No extensions to update.': 'No extensions to update.', 'Usage: /extensions install ': 'Usage: /extensions install ', 'Installing extension from "{{source}}"...': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 54c336a7338..a48896234b4 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -606,6 +606,26 @@ export default { 'Uninstall an extension': '卸載擴展', 'No extensions installed.': '未安裝擴展。', 'Extension "{{name}}" not found.': '未找到擴展 "{{name}}"。', + 'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).': + '安裝擴展的作用域:"user"(全域,預設)或 "project"(僅當前工作區)。', + 'Extension "{{name}}" installed successfully and enabled for the current workspace.': + '擴展 "{{name}}" 安裝成功,並已在當前工作區啟用。', + 'Marketplace "{{name}}" not found.': '未找到市場源 "{{name}}"。', + 'No marketplace sources added yet.': '尚未添加任何市場源。', + 'Adds a marketplace source (Claude format).': + '添加一個市場源(Claude 格式)。', + 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': + '要添加的市場源:owner/repo(GitHub)、git 或 https URL,或本地路徑。', + 'Removes a marketplace source.': '移除一個市場源。', + 'The name of the marketplace to remove.': '要移除的市場源名稱。', + 'Lists configured marketplace sources.': '列出已配置的市場源。', + 'Re-fetches a marketplace source and its plugin listing.': + '重新拉取市場源及其插件列表。', + 'The name of the marketplace to update.': '要更新的市場源名稱。', + 'Manage marketplace sources for discovering extensions.': + '管理用於發現擴展的市場源。', + 'You need at least one command before continuing.': + '需要至少提供一個子命令。', 'No extensions to update.': '沒有可更新的擴展。', 'Usage: /extensions install ': '用法:/extensions install <來源>', 'Installing extension from "{{source}}"...': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a588caf4377..5655a19692a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -647,6 +647,26 @@ export default { 'Uninstall an extension': '卸载扩展', 'No extensions installed.': '未安装扩展。', 'Extension "{{name}}" not found.': '未找到扩展 "{{name}}"。', + 'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).': + '安装扩展的作用域:"user"(全局,默认)或 "project"(仅当前工作区)。', + 'Extension "{{name}}" installed successfully and enabled for the current workspace.': + '扩展 "{{name}}" 安装成功,并已在当前工作区启用。', + 'Marketplace "{{name}}" not found.': '未找到市场源 "{{name}}"。', + 'No marketplace sources added yet.': '尚未添加任何市场源。', + 'Adds a marketplace source (Claude format).': + '添加一个市场源(Claude 格式)。', + 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': + '要添加的市场源:owner/repo(GitHub)、git 或 https URL,或本地路径。', + 'Removes a marketplace source.': '移除一个市场源。', + 'The name of the marketplace to remove.': '要移除的市场源名称。', + 'Lists configured marketplace sources.': '列出已配置的市场源。', + 'Re-fetches a marketplace source and its plugin listing.': + '重新拉取市场源及其插件列表。', + 'The name of the marketplace to update.': '要更新的市场源名称。', + 'Manage marketplace sources for discovering extensions.': + '管理用于发现扩展的市场源。', + 'You need at least one command before continuing.': + '需要至少提供一个子命令。', 'No extensions to update.': '没有可更新的扩展。', 'Usage: /extensions install ': '用法:/extensions install <来源>', 'Installing extension from "{{source}}"...': From e24709d9b738817e6154b23a1bf17c10d4ab7f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Thu, 11 Jun 2026 15:04:40 +0800 Subject: [PATCH 30/48] feat(extensions): trim the Sources tab and add marketplace detail retry/refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the redundant installed-extensions list from the Sources tab (those live on the Installed tab); the marketplace detail still summarizes which of its plugins are installed, now capped with a "… and N more" line so a long list stays short. Add an R key in the marketplace detail that re-fetches the source — surfaced in the footer as a retry on load failure and a refresh once loaded. Reword the install row to "Install a new extension". --- packages/cli/src/i18n/locales/en.js | 8 +- packages/cli/src/i18n/locales/zh-TW.js | 8 +- packages/cli/src/i18n/locales/zh.js | 8 +- .../ExtensionsManagerDialog.test.tsx | 190 ++++++++---------- .../extensions/ExtensionsManagerDialog.tsx | 10 +- .../components/extensions/tabs/SourcesTab.tsx | 172 ++++++++-------- 6 files changed, 183 insertions(+), 213 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index eb61e38fbcb..8f55435f7d9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -23,7 +23,7 @@ export default { '"{{name}}" {{state}}.': '"{{name}}" {{state}}.', '(Tab / ←→ to switch)': '(Tab / ←→ to switch)', '+ Add new marketplace': '+ Add new marketplace', - '+ Install new extension': '+ Install new extension', + '+ Install a new extension': '+ Install a new extension', Actions: 'Actions', 'Add Marketplace': 'Add Marketplace', 'Add a marketplace in the Sources tab to discover extensions.': @@ -122,6 +122,9 @@ export default { 'Would open: {{url}}': 'Would open: {{url}}', 'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter to confirm · N/Esc to cancel', + 'Press R to retry · Esc to go back': 'Press R to retry · Esc to go back', + 'Enter to select · R refresh · Esc to go back': + 'Enter to select · R refresh · Esc to go back', 'from {{marketplace}}': 'from {{marketplace}}', installed: 'installed', '{{count}} Agents': '{{count}} Agents', @@ -130,8 +133,6 @@ export default { '{{count}} Skills': '{{count}} Skills', '{{count}} available extensions': '{{count}} available extensions', '↑ more above': '↑ more above', - '↑↓ navigate · Enter details · Esc close': - '↑↓ navigate · Enter details · Esc close', '↑↓ navigate · Enter open · d remove marketplace · Esc close': '↑↓ navigate · Enter open · d remove marketplace · Esc close', '↑↓ navigate · Enter select · Esc close': @@ -681,6 +682,7 @@ export default { 'Extension "{{name}}" installed successfully and enabled for the current workspace.', 'Marketplace "{{name}}" not found.': 'Marketplace "{{name}}" not found.', 'No marketplace sources added yet.': 'No marketplace sources added yet.', + 'No marketplaces added yet.': 'No marketplaces added yet.', 'Adds a marketplace source (Claude format).': 'Adds a marketplace source (Claude format).', 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a48896234b4..ebe3103be80 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -23,7 +23,7 @@ export default { '"{{name}}" {{state}}.': '"{{name}}" {{state}}。', '(Tab / ←→ to switch)': '(Tab / ←→ 切換)', '+ Add new marketplace': '+ 新增市場來源', - '+ Install new extension': '+ 安裝新擴展', + '+ Install a new extension': '+ 安裝一個新擴展', Actions: '操作', 'Add Marketplace': '新增市場來源', 'Add a marketplace in the Sources tab to discover extensions.': @@ -119,6 +119,9 @@ export default { 'Will install:': '將安裝:', 'Would open: {{url}}': '將開啟:{{url}}', 'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 確認 · N/Esc 取消', + 'Press R to retry · Esc to go back': '按 R 重試 · Esc 返回', + 'Enter to select · R refresh · Esc to go back': + 'Enter 選擇 · R 刷新 · Esc 返回', 'from {{marketplace}}': '來自 {{marketplace}}', installed: '已安裝', '{{count}} Agents': '{{count}} 個智能體', @@ -127,8 +130,6 @@ export default { '{{count}} Skills': '{{count}} 個技能', '{{count}} available extensions': '{{count}} 個可用擴展', '↑ more above': '↑ 上方更多', - '↑↓ navigate · Enter details · Esc close': - '↑↓ 導覽 · Enter 查看詳情 · Esc 關閉', '↑↓ navigate · Enter open · d remove marketplace · Esc close': '↑↓ 導覽 · Enter 開啟 · d 移除市場來源 · Esc 關閉', '↑↓ navigate · Enter select · Esc close': '↑↓ 導覽 · Enter 選擇 · Esc 關閉', @@ -612,6 +613,7 @@ export default { '擴展 "{{name}}" 安裝成功,並已在當前工作區啟用。', 'Marketplace "{{name}}" not found.': '未找到市場源 "{{name}}"。', 'No marketplace sources added yet.': '尚未添加任何市場源。', + 'No marketplaces added yet.': '尚未添加任何市場源。', 'Adds a marketplace source (Claude format).': '添加一個市場源(Claude 格式)。', 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5655a19692a..24a0f8c69d4 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -21,7 +21,7 @@ export default { '"{{name}}" {{state}}.': '"{{name}}" {{state}}。', '(Tab / ←→ to switch)': '(Tab / ←→ 切换)', '+ Add new marketplace': '+ 添加新市场源', - '+ Install new extension': '+ 安装新扩展', + '+ Install a new extension': '+ 安装一个新扩展', Actions: '操作', 'Add Marketplace': '添加市场源', 'Add a marketplace in the Sources tab to discover extensions.': @@ -117,6 +117,9 @@ export default { 'Will install:': '将安装:', 'Would open: {{url}}': '将打开:{{url}}', 'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 确认 · N/Esc 取消', + 'Press R to retry · Esc to go back': '按 R 重试 · Esc 返回', + 'Enter to select · R refresh · Esc to go back': + 'Enter 选择 · R 刷新 · Esc 返回', 'from {{marketplace}}': '来自 {{marketplace}}', installed: '已安装', '{{count}} Agents': '{{count}} 个智能体', @@ -125,8 +128,6 @@ export default { '{{count}} Skills': '{{count}} 个技能', '{{count}} available extensions': '{{count}} 个可用扩展', '↑ more above': '↑ 上方更多', - '↑↓ navigate · Enter details · Esc close': - '↑↓ 导航 · Enter 查看详情 · Esc 关闭', '↑↓ navigate · Enter open · d remove marketplace · Esc close': '↑↓ 导航 · Enter 打开 · d 移除市场源 · Esc 关闭', '↑↓ navigate · Enter select · Esc close': '↑↓ 导航 · Enter 选择 · Esc 关闭', @@ -653,6 +654,7 @@ export default { '扩展 "{{name}}" 安装成功,并已在当前工作区启用。', 'Marketplace "{{name}}" not found.': '未找到市场源 "{{name}}"。', 'No marketplace sources added yet.': '尚未添加任何市场源。', + 'No marketplaces added yet.': '尚未添加任何市场源。', 'Adds a marketplace source (Claude format).': '添加一个市场源(Claude 格式)。', 'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.': diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 9faf7684d45..08cd9984ecc 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -15,7 +15,6 @@ import { SettingsContext } from '../../contexts/SettingsContext.js'; import { ShellFocusContext } from '../../contexts/ShellFocusContext.js'; import { LoadedSettings } from '../../../config/settings.js'; import type { UIState } from '../../contexts/UIStateContext.js'; -import { SettingScope } from '@qwen-code/qwen-code-core'; import type { Config, Extension, @@ -361,168 +360,137 @@ describe('ExtensionsManagerDialog (tabbed)', () => { // 'Add new' is the section title for the action rows; it also appears inside // '+ Add new marketplace', so assert it renders at least twice (title + row). expect((frame?.split('Add new').length ?? 1) - 1).toBeGreaterThanOrEqual(2); - expect(frame).toContain('Install new extension'); + expect(frame).toContain('Install a new extension'); expect(frame).toContain('Claude plugin marketplace'); // (Claude) annotation expect(frame).toContain('Marketplaces'); // section header expect(frame).toContain('Skills'); }); - it('shows extension actions without Favorites in the Sources extension detail', async () => { + it('shows a CC-style marketplace detail with installed plugins and actions', async () => { const manager = createManager({ - extensions: [mockExtension('alpha', true)], + extensions: [mockExtension('pdf', true)], // installed, belongs to Skills + sources: [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + ], + }); + manager.loadSource = vi.fn().mockResolvedValue({ + name: 'Skills', + owner: { name: 'o', email: 'e' }, + plugins: [ + { name: 'pdf', version: '1', description: 'PDF tools', source: 'a/s' }, + { name: 'docx', version: '1', source: 'a/s' }, + ], }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { initialTab: EXTENSIONS_TABS.SOURCES, }); + // Wait until the list has loaded (and the keypress handler re-subscribed). await waitFor(() => { - expect(lastFrame()).toContain('alpha'); // Extensions section row + expect(lastFrame()).toContain('Skills'); }); - // Rows: Install ext (0), Add marketplace (1), alpha extension (2). + // Installed extensions are not listed here; rows are: + // Install ext (0), Add marketplace (1), Skills (2). stdin.write('\x1B[B'); stdin.write('\x1B[B'); await waitFor(() => { - expect(lastFrame()).toContain('● alpha'); + expect(lastFrame()).toContain('● Skills'); }); - stdin.write('\r'); // Enter -> shared extension actions view + stdin.write('\r'); // Enter -> open detail await waitFor(() => { - expect(lastFrame()).toContain('Change scope'); + expect(lastFrame()).toContain('available extensions'); }); const frame = lastFrame(); - expect(frame).toContain('Disable'); // alpha is active - expect(frame).toContain('Mark for Update'); - expect(frame).toContain('Uninstall'); - // Favorites is omitted in the Sources tab. - expect(frame).not.toContain('Favorites'); + expect(frame).toContain('2 available extensions'); + // The detail still reflects which of this marketplace's plugins are + // installed, even though the tab list no longer shows extensions. + expect(frame).toContain('Installed extensions (1):'); + expect(frame).toContain('pdf'); + expect(frame).toContain('Browse extensions (2)'); + expect(frame).toContain('Update marketplace'); + expect(frame).toContain('Remove marketplace'); + // The footer advertises the R refresh shortcut once the detail has loaded. + expect(frame).toContain('R refresh'); }); - it('toggles enable/disable in the Sources extension detail and keeps the label in sync', async () => { + it('collapses a long installed-plugins list in the marketplace detail', async () => { + const installed = Array.from({ length: 8 }, (_, i) => + mockExtension(`ext-${i}`, true), + ); const manager = createManager({ - extensions: [mockExtension('alpha', true)], - }); - const { stdin, lastFrame } = renderDialog(createConfig(manager), { - initialTab: EXTENSIONS_TABS.SOURCES, - }); - await waitFor(() => { - expect(lastFrame()).toContain('alpha'); - }); - stdin.write('\x1B[B'); - stdin.write('\x1B[B'); - await waitFor(() => { - expect(lastFrame()).toContain('● alpha'); - }); - stdin.write('\r'); // open detail - await waitFor(() => { - expect(lastFrame()).toContain('Disable'); // active -> Disable - }); - stdin.write('\r'); // Disable (toggle is the first action) - await waitFor(() => { - expect(manager.disableExtension).toHaveBeenCalled(); - expect(lastFrame()).toContain('Enable'); // label flipped - }); - stdin.write('\r'); // Enable (toggle still first) - await waitFor(() => { - expect(manager.enableExtension).toHaveBeenCalled(); - expect(lastFrame()).toContain('Disable'); // flipped back, not stuck + extensions: installed, + sources: [ + { name: 'Skills', source: 'anthropics/skills', type: 'github' }, + ], }); - }); - - it('change-scope re-scopes and re-enables a disabled extension in the Sources detail', async () => { - const manager = createManager({ - extensions: [mockExtension('alpha', false)], // disabled + manager.loadSource = vi.fn().mockResolvedValue({ + name: 'Skills', + owner: { name: 'o', email: 'e' }, + plugins: installed.map((ext) => ({ + name: ext.name, + version: '1', + source: 'a/s', + })), }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { - expect(lastFrame()).toContain('alpha'); + expect(lastFrame()).toContain('Skills'); }); stdin.write('\x1B[B'); stdin.write('\x1B[B'); await waitFor(() => { - expect(lastFrame()).toContain('● alpha'); + expect(lastFrame()).toContain('● Skills'); }); stdin.write('\r'); // open detail await waitFor(() => { - expect(lastFrame()).toContain('Enable'); // disabled -> Enable label - }); - // Actions (no favorite in Sources): toggle(0), change-scope(1), ... - stdin.write('\x1B[B'); // highlight Change scope - stdin.write('\r'); // enter scope-select - await waitFor(() => { - expect(lastFrame()).toContain('Project (Workspace)'); - }); - stdin.write('\x1B[B'); // highlight Project (index 1) - stdin.write('\r'); // select Project - await waitFor(() => { - expect(manager.setExtensionScope).toHaveBeenCalledWith( - 'alpha', - 'project', - ); - }); - // Project scope => disable at User, enable at Workspace. - expect(manager.disableExtension).toHaveBeenCalledWith( - 'alpha', - SettingScope.User, - ); - expect(manager.enableExtension).toHaveBeenCalledWith( - 'alpha', - SettingScope.Workspace, - ); - // Back on the detail: scope is Project and the extension is now enabled. - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('Project'); - expect(frame).toContain('Disable'); // enabled -> Disable label - }); - // Re-entering the scope selector reflects the now-current scope (Project), - // not a default of Global — so the change is visibly in effect. - stdin.write('\x1B[B'); // highlight Change scope - stdin.write('\r'); // enter scope-select again - await waitFor(() => { - expect(lastFrame()).toContain('Current: Project (Workspace)'); + expect(lastFrame()).toContain('Installed extensions (8):'); }); + const frame = lastFrame(); + // First five are shown; the rest collapse into a summary line. + expect(frame).toContain('ext-0'); + expect(frame).toContain('ext-4'); + expect(frame).not.toContain('ext-5'); + expect(frame).toContain('and 3 more'); }); - it('shows a CC-style marketplace detail with installed plugins and actions', async () => { + it('retries a failed marketplace load when R is pressed', async () => { const manager = createManager({ - extensions: [mockExtension('pdf', true)], // installed, belongs to Skills sources: [ { name: 'Skills', source: 'anthropics/skills', type: 'github' }, ], }); - manager.loadSource = vi.fn().mockResolvedValue({ - name: 'Skills', - owner: { name: 'o', email: 'e' }, - plugins: [ - { name: 'pdf', version: '1', description: 'PDF tools', source: 'a/s' }, - { name: 'docx', version: '1', source: 'a/s' }, - ], - }); + // First load fails (null), retry succeeds. + manager.loadSource = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + name: 'Skills', + owner: { name: 'o', email: 'e' }, + plugins: [{ name: 'pdf', version: '1', source: 'a/s' }], + }); const { stdin, lastFrame } = renderDialog(createConfig(manager), { initialTab: EXTENSIONS_TABS.SOURCES, }); - // Wait until the list has loaded (and the keypress handler re-subscribed). await waitFor(() => { expect(lastFrame()).toContain('Skills'); }); - // Rows: Install ext (0), Add marketplace (1), pdf extension (2), Skills (3). - stdin.write('\x1B[B'); stdin.write('\x1B[B'); stdin.write('\x1B[B'); await waitFor(() => { expect(lastFrame()).toContain('● Skills'); }); - stdin.write('\r'); // Enter -> open detail + stdin.write('\r'); // open detail -> first load fails await waitFor(() => { - expect(lastFrame()).toContain('available extensions'); + expect(lastFrame()).toContain('Could not load this marketplace.'); }); - const frame = lastFrame(); - expect(frame).toContain('2 available extensions'); - expect(frame).toContain('Installed extensions (1):'); - expect(frame).toContain('pdf'); - expect(frame).toContain('Browse extensions (2)'); - expect(frame).toContain('Update marketplace'); - expect(frame).toContain('Remove marketplace'); + // The retry hint shows both inline and in the bottom footer. + expect((lastFrame()?.split('Press R to retry').length ?? 1) - 1).toBe(2); + stdin.write('r'); // retry -> second load succeeds + await waitFor(() => { + expect(lastFrame()).toContain('1 available extensions'); + }); + expect(manager.loadSource).toHaveBeenCalledTimes(2); }); it('Browse plugins jumps to Discover filtered by the marketplace', async () => { @@ -620,7 +588,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { - expect(lastFrame()).toContain('Install new extension'); + expect(lastFrame()).toContain('Install a new extension'); }); stdin.write('\r'); // Enter on row 0 -> install-extension sub-view (locks tabs) await waitFor(() => { @@ -628,7 +596,7 @@ describe('ExtensionsManagerDialog (tabbed)', () => { }); stdin.write('\x1b'); // Escape should return to the list, not close the dialog await waitFor(() => { - expect(lastFrame()).toContain('Install new extension'); + expect(lastFrame()).toContain('Install a new extension'); }); expect(onClose).not.toHaveBeenCalled(); }); @@ -638,9 +606,9 @@ describe('ExtensionsManagerDialog (tabbed)', () => { initialTab: EXTENSIONS_TABS.SOURCES, }); await waitFor(() => { - expect(lastFrame()).toContain('Install new extension'); + expect(lastFrame()).toContain('Install a new extension'); }); - stdin.write('\r'); // Enter on the first row (Install new extension) + stdin.write('\r'); // Enter on the first row (Install a new extension) await waitFor(() => { expect(lastFrame()).toContain('Install Extension'); }); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 1440fb1cb5a..650d9e8ef6d 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -249,9 +249,13 @@ export function ExtensionsManagerDialog({ )} - {tabLocked - ? t('Enter to select · Esc to go back') - : (tabFooter ?? footerHint(activeTab))} + {/* A tab-provided hint wins even while a sub-view is locked, so a + locked view (e.g. a failed marketplace load offering R to retry) + can surface its own footer instead of the generic locked text. */} + {tabFooter ?? + (tabLocked + ? t('Enter to select · Esc to go back') + : footerHint(activeTab))} diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index 4dbc582299e..3282287611b 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -22,25 +22,27 @@ import { createDebugLogger, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; -import { ExtensionActionsView } from '../views/ExtensionActionsView.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('SOURCES_TAB'); +// How many installed plugins to list in the marketplace detail before +// collapsing the rest into a "… and N more" summary (keeps the view short). +const INSTALLED_PREVIEW_LIMIT = 5; + type SourcesView = | 'list' | 'install-extension' | 'add' | 'detail' - | 'extension-detail' | 'remove-confirm'; type SourceDetailAction = 'browse' | 'update' | 'remove'; -// Flat, navigable entries shown on the Marketplaces tab list. +// Flat, navigable entries shown on the Marketplaces tab list. Installed +// extensions are not listed here — they live on the Installed tab. type Entry = | { kind: 'install-extension' } | { kind: 'add-marketplace' } - | { kind: 'extension'; extension: Extension } | { kind: 'marketplace'; source: ExtensionSource }; interface SourcesTabProps { @@ -63,12 +65,6 @@ function formatDate(iso?: string): string | null { return new Date(time).toLocaleDateString(); } -function extensionSourceLabel(ext: Extension): string { - const meta = ext.installMetadata; - if (!meta) return t('local'); - return `${redactUrlCredentials(meta.source)} (${meta.type})`; -} - export const SourcesTab = ({ config, isActive, @@ -88,13 +84,10 @@ export const SourcesTab = ({ const [detailConfig, setDetailConfig] = useState(null); const [detailLoading, setDetailLoading] = useState(false); - // The marketplace / extension currently being viewed or confirmed. + // The marketplace currently being viewed or confirmed. const [detailSource, setDetailSource] = useState( null, ); - const [detailExtension, setDetailExtension] = useState( - null, - ); const extensionManager = config.getExtensionManager(); @@ -113,18 +106,14 @@ export const SourcesTab = ({ load(); }, [load, reloadSignal]); - // Entries: two action rows, then installed extensions, then sources. + // Entries: two action rows, then the configured marketplaces. const entries = useMemo( () => [ { kind: 'install-extension' }, { kind: 'add-marketplace' }, - ...extensions.map((extension) => ({ - kind: 'extension' as const, - extension, - })), ...sources.map((source) => ({ kind: 'marketplace' as const, source })), ], - [extensions, sources], + [sources], ); // Keep the cursor in range as the list changes. @@ -136,9 +125,26 @@ export const SourcesTab = ({ const selectedEntry = entries[selectedIndex]; - // Context-aware footer hint based on the highlighted row (list view only). + // Context-aware footer hint. Mostly list-view only, but the marketplace + // detail surfaces an R-to-retry hint when its load failed. useEffect(() => { - if (!isActive || view !== 'list') { + if (!isActive) { + onFooter(null); + return; + } + if (view === 'detail') { + // R re-fetches in the detail view either way; advertise it in the + // footer (as a retry on failure, a refresh once loaded). + if (detailLoading) { + onFooter(null); + } else if (!detailConfig) { + onFooter(t('Press R to retry · Esc to go back')); + } else { + onFooter(t('Enter to select · R refresh · Esc to go back')); + } + return () => onFooter(null); + } + if (view !== 'list') { onFooter(null); return; } @@ -147,20 +153,24 @@ export const SourcesTab = ({ onFooter( t('↑↓ navigate · Enter open · d remove marketplace · Esc close'), ); - } else if (kind === 'extension') { - onFooter(t('↑↓ navigate · Enter details · Esc close')); } else { onFooter(t('↑↓ navigate · Enter select · Esc close')); } return () => onFooter(null); - }, [isActive, view, selectedEntry?.kind, onFooter]); + }, [ + isActive, + view, + selectedEntry?.kind, + onFooter, + detailLoading, + detailConfig, + ]); const goToList = useCallback(() => { setView('list'); setInput(''); setDetailConfig(null); setDetailSource(null); - setDetailExtension(null); onLockChange(false); }, [onLockChange]); @@ -229,15 +239,21 @@ export const SourcesTab = ({ [extensionManager, onLockChange, onStatus], ); - const openExtensionDetail = useCallback( - (extension: Extension) => { - onStatus(null); - setDetailExtension(extension); - setView('extension-detail'); - onLockChange(true); - }, - [onLockChange, onStatus], - ); + // Re-fetch the marketplace config for the currently-open detail. Used by the + // R key so a failed load can be retried without leaving the detail view. + const refetchDetail = useCallback(async () => { + if (!extensionManager || !detailSource) return; + setDetailLoading(true); + setDetailConfig(null); + try { + const cfg = await extensionManager.loadSource(detailSource.source); + setDetailConfig(cfg ?? null); + } catch (error) { + debugLogger.error('Failed to load marketplace detail:', error); + } finally { + setDetailLoading(false); + } + }, [extensionManager, detailSource]); const removeSource = useCallback(() => { if (!extensionManager || !detailSource) return; @@ -314,9 +330,6 @@ export const SourcesTab = ({ setView('add'); onLockChange(true); break; - case 'extension': - openExtensionDetail(selectedEntry.extension); - break; case 'marketplace': void openSourceDetail(selectedEntry.source); break; @@ -351,12 +364,19 @@ export const SourcesTab = ({ }, ); - // Marketplace detail: Escape goes back; the selector owns Enter. (The - // extension detail view owns its own keyboard handling.) + // Marketplace detail: Escape goes back; R re-fetches (retry on load failure); + // the selector owns Enter. useKeypress( (key) => { if (key.name === 'escape') { goToList(); + } else if ( + (key.name === 'r' || key.sequence === 'r') && + !key.ctrl && + !key.meta && + !detailLoading + ) { + void refetchDetail(); } }, { isActive: isActive && view === 'detail' }, @@ -442,23 +462,6 @@ export const SourcesTab = ({ ); } - if (view === 'extension-detail' && detailExtension) { - return ( - { - void load(); - onChanged(); - }} - onExit={goToList} - /> - ); - } - if (view === 'detail' && detailSource) { const plugins = detailConfig?.plugins ?? []; const availableCount = plugins.length; @@ -520,23 +523,23 @@ export const SourcesTab = ({ count: String(installedHere.length), })} - {installedHere.map((p) => ( - - - - {'●'} - - {p.name} + {installedHere.slice(0, INSTALLED_PREVIEW_LIMIT).map((p) => ( + + + {'●'} - {p.description ? ( - - - {p.description} - - - ) : null} + {p.name} ))} + {installedHere.length > INSTALLED_PREVIEW_LIMIT ? ( + + {t('... and {{count}} more', { + count: String( + installedHere.length - INSTALLED_PREVIEW_LIMIT, + ), + })} + + ) : null} ) : null} @@ -552,6 +555,9 @@ export const SourcesTab = ({ {t('Could not load this marketplace.')} + + {t('Press R to retry · Esc to go back')} + @@ -624,7 +629,7 @@ export const SourcesTab = ({ {t('Add new')} - {renderRow(0, t('+ Install new extension'), undefined, true)} + {renderRow(0, t('+ Install a new extension'), undefined, true)} {renderRow( 1, t('+ Add new marketplace'), @@ -633,17 +638,6 @@ export const SourcesTab = ({ )} - {extensions.length > 0 ? ( - - - {t('Extensions')} ({extensions.length}) - - {extensions.map((ext, i) => - renderRow(extensionsStart + i, ext.name, extensionSourceLabel(ext)), - )} - - ) : null} - {sources.length > 0 ? ( @@ -657,15 +651,13 @@ export const SourcesTab = ({ ), )} - ) : null} - - {extensions.length === 0 && sources.length === 0 ? ( + ) : ( - {t('No extensions or sources added yet.')} + {t('No marketplaces added yet.')} - ) : null} + )} ); }; From c5e49d539e1bf31eaacae0f7818445ac697a31ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Fri, 12 Jun 2026 14:19:47 +0800 Subject: [PATCH 31/48] feat(extensions): nest extension-bundled MCP servers under their extension The Installed tab now lists each extension's bundled MCP servers as indented child rows beneath it, with Enter opening the shared MCP detail view (tools, OAuth authenticate/clear) just like the /mcp panel. - Skip child rows shadowed by a same-named user/project server and ones blocked by the MCP allow-list, matching the runtime merge semantics. - Space only blocks the disable direction for bundled servers; an individually-excluded server under an active extension can be re-enabled. Blocked Space/favorite and Enter on a disabled extension's server give info feedback instead of failing silently. - Hide the always-failing Disable action for active extension-provided servers in ServerDetailStep (also fixes the /mcp panel). - Window the Installed list to the terminal height with scroll hints, clamped offset and group-header anchoring. - Fall back to the list if the open detail's item disappears on reload so the tab can't get stuck locked. - Hermetic tests: stub loadSettings, stabilize two flaky assertions. --- packages/cli/src/i18n/locales/en.js | 4 + packages/cli/src/i18n/locales/zh-TW.js | 4 + packages/cli/src/i18n/locales/zh.js | 4 + .../ExtensionsManagerDialog.test.tsx | 66 +++- .../extensions/tabs/InstalledTab.tsx | 358 +++++++++++++----- .../cli/src/ui/components/extensions/types.ts | 3 + .../extensions/views/McpServerActionsView.tsx | 2 +- .../components/mcp/steps/ServerDetailStep.tsx | 14 +- 8 files changed, 353 insertions(+), 102 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 8f55435f7d9..99fbd8879ff 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -12,6 +12,10 @@ export default { 'Cannot disable an extension-provided MCP server here.', 'Cleared authentication for "{{name}}".': 'Cleared authentication for "{{name}}".', + 'Enable extension "{{name}}" to manage this MCP server.': + 'Enable extension "{{name}}" to manage this MCP server.', + 'Extension-provided MCP servers cannot be favorited.': + 'Extension-provided MCP servers cannot be favorited.', 'User level': 'User level', 'Project level': 'Project level', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index ebe3103be80..2dc96ceaa84 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -12,6 +12,10 @@ export default { 'Cannot disable an extension-provided MCP server here.': '無法在此處停用擴展提供的 MCP 伺服器。', 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的認證資訊。', + 'Enable extension "{{name}}" to manage this MCP server.': + '啟用擴展 "{{name}}" 後才能管理此 MCP 伺服器。', + 'Extension-provided MCP servers cannot be favorited.': + '擴展提供的 MCP 伺服器無法單獨收藏。', 'User level': '使用者層級', 'Project level': '專案層級', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 24a0f8c69d4..17bd0c0a1d7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -10,6 +10,10 @@ export default { 'Cannot disable an extension-provided MCP server here.': '无法在此处禁用扩展提供的 MCP 服务器。', 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的认证信息。', + 'Enable extension "{{name}}" to manage this MCP server.': + '启用扩展 "{{name}}" 后才能管理此 MCP 服务器。', + 'Extension-provided MCP servers cannot be favorited.': + '扩展提供的 MCP 服务器无法单独收藏。', 'User level': '用户级', 'Project level': '项目级', diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 08cd9984ecc..6fe2f8f63a5 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -23,6 +23,20 @@ import type { } from '@qwen-code/qwen-code-core'; import type { ExtensionUpdateState } from '../../state/extensions.js'; +// The Installed tab reads real user/workspace settings from disk when MCP +// servers exist; stub loadSettings to keep these tests hermetic. +vi.mock('../../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(() => ({ + forScope: () => ({ settings: {} }), + setValue: vi.fn(), + })), + }; +}); + const mockExtension = (name: string, isActive = true): Extension => ({ id: name, @@ -72,11 +86,15 @@ const createManager = (o: ManagerOverrides = {}) => { }; }; -const createConfig = (manager: ReturnType): Config => +const createConfig = ( + manager: ReturnType, + overrides: { mcpServers?: Record } = {}, +): Config => ({ getExtensionManager: () => manager, - getMcpServers: () => ({}), + getMcpServers: () => overrides.mcpServers ?? {}, getToolRegistry: () => undefined, + getPromptRegistry: () => undefined, isMcpServerDisabled: () => false, getExcludedMcpServers: () => [], setExcludedMcpServers: vi.fn(), @@ -294,6 +312,42 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Extension v1.0.0'); }); + it('nests extension-bundled MCP servers under their extension on the Installed tab', async () => { + const ext = mockExtension('alpha', true); + (ext as unknown as { mcpServers: Record }).mcpServers = { + 'alpha-mcp': { command: 'node' }, + }; + const config = createConfig( + createManager({ extensions: [ext, mockExtension('beta', true)] }), + { + mcpServers: { + 'alpha-mcp': { command: 'node', extensionName: 'alpha' }, + }, + }, + ); + const { stdin, lastFrame } = renderDialog(config, { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha-mcp'); + }); + const frame = lastFrame()!; + // Bundled MCP renders indented under its parent extension. + expect(frame).toContain('└ alpha-mcp'); + // The group count only includes top-level items (the two extensions). + expect(frame).toContain('User level (2)'); + // Enter on the nested row opens the MCP detail view with Extension source. + stdin.write('\x1B[B'); // down: alpha -> alpha-mcp + await waitFor(() => { + expect(lastFrame()).toMatch(/●\s+└ alpha-mcp/); + }); + stdin.write('\r'); + await waitFor(() => { + expect(lastFrame()).toContain('Source:'); + }); + expect(lastFrame()).toContain('Extension'); + }); + it('toggles favorite when pressing f on the Installed tab', async () => { const manager = createManager({ extensions: [mockExtension('alpha', true)], @@ -409,7 +463,9 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Update marketplace'); expect(frame).toContain('Remove marketplace'); // The footer advertises the R refresh shortcut once the detail has loaded. - expect(frame).toContain('R refresh'); + await waitFor(() => { + expect(lastFrame()).toContain('R refresh'); + }); }); it('collapses a long installed-plugins list in the marketplace detail', async () => { @@ -485,7 +541,9 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('Could not load this marketplace.'); }); // The retry hint shows both inline and in the bottom footer. - expect((lastFrame()?.split('Press R to retry').length ?? 1) - 1).toBe(2); + await waitFor(() => { + expect((lastFrame()?.split('Press R to retry').length ?? 1) - 1).toBe(2); + }); stdin.write('r'); // retry -> second load succeeds await waitFor(() => { expect(lastFrame()).toContain('1 available extensions'); diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 507b888de51..baeebb96199 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -4,16 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; import { keyMatchers, Command } from '../../../keyMatchers.js'; import { t } from '../../../../i18n/index.js'; import { type Config, type Extension, type ExtensionScope, + type MCPServerConfig, SettingScope, getMCPServerStatus, createDebugLogger, @@ -129,20 +131,44 @@ export const InstalledTab = ({ }; }); - // Standalone MCP servers (those owned by extensions are surfaced as the - // plugin's components, so they are excluded here). - const mcpItems: InstalledItem[] = []; + // MCP servers: standalone ones are top-level rows; extension-bundled + // ones are nested under their parent extension. const mcpServers = config.getMcpServers() ?? {}; - const standaloneNames = Object.keys(mcpServers).filter( - (name) => !mcpServers[name].extensionName, - ); - // Only touch settings/tool registry when there are standalone MCP servers. - const workspaceMcp = standaloneNames.length + const hasAnyMcp = + Object.keys(mcpServers).length > 0 || + extensions.some((ext) => Object.keys(ext.mcpServers ?? {}).length > 0); + // Only touch settings/tool registry when there are MCP servers. + const workspaceMcp = hasAnyMcp ? loadSettings().forScope(CliSettingScope.Workspace).settings.mcpServers : undefined; - const toolRegistry = standaloneNames.length - ? config.getToolRegistry() - : undefined; + const toolRegistry = hasAnyMcp ? config.getToolRegistry() : undefined; + + const buildMcpInfo = ( + name: string, + serverConfig: MCPServerConfig, + scope: InstalledMcpInfo['scope'], + isDisabled: boolean, + ): InstalledMcpInfo => ({ + name, + status: getMCPServerStatus(name), + scope, + isDisabled, + transport: serverConfig.command + ? 'stdio' + : serverConfig.httpUrl + ? 'http' + : serverConfig.url + ? 'sse' + : 'unknown', + toolCount: + toolRegistry + ?.getAllTools() + .filter( + (tool) => (tool as { serverName?: string }).serverName === name, + ).length ?? 0, + }); + + const mcpItems: InstalledItem[] = []; for (const [name, serverConfig] of Object.entries(mcpServers)) { if (serverConfig.extensionName) continue; const scope: InstalledMcpInfo['scope'] = workspaceMcp?.[name] @@ -150,46 +176,69 @@ export const InstalledTab = ({ : 'user'; const isDisabled = config.isMcpServerDisabled(name); const isFavorite = favorites.has(name); - const toolCount = - toolRegistry - ?.getAllTools() - .filter( - (tool) => (tool as { serverName?: string }).serverName === name, - ).length ?? 0; - const transport = serverConfig.command - ? 'stdio' - : serverConfig.httpUrl - ? 'http' - : serverConfig.url - ? 'sse' - : 'unknown'; - const mcp: InstalledMcpInfo = { - name, - status: getMCPServerStatus(name), - scope, - isDisabled, - transport, - toolCount, - }; mcpItems.push({ kind: 'mcp', key: `mcp:${name}`, name, - mcp, + mcp: buildMcpInfo(name, serverConfig, scope, isDisabled), isActive: !isDisabled, isFavorite, group: groupFor(!isDisabled, isFavorite, scope), }); } - const all = [...pluginItems, ...mcpItems]; + // Extension-bundled MCP servers, keyed by parent extension name. They + // inherit the parent's group so they always render right under it. + const childMcpItems = new Map(); + for (const item of pluginItems) { + if (item.kind !== 'plugin') continue; + const ext = item.extension; + const children: InstalledItem[] = []; + for (const name of Object.keys(ext.mcpServers ?? {})) { + const merged = mcpServers[name]; + // The merged runtime config wins on name collisions (a user/project + // server, or another extension's, shadows this one) — don't render a + // child row for a server this extension didn't actually contribute. + if (merged && merged.extensionName !== ext.name) continue; + // Active extension but absent from the runtime config: blocked by + // the MCP allow-list. Hide it, matching standalone behavior. + if (!merged && ext.isActive) continue; + const isDisabled = !ext.isActive || config.isMcpServerDisabled(name); + children.push({ + kind: 'mcp', + key: `mcp:${ext.name}:${name}`, + name, + mcp: buildMcpInfo( + name, + merged ?? ext.mcpServers![name], + 'extension', + isDisabled, + ), + isActive: !isDisabled, + isFavorite: false, + group: item.group, + parentExtension: ext.name, + }); + } + if (children.length) childMcpItems.set(ext.name, children); + } + + const topLevel = [...pluginItems, ...mcpItems]; // Stable sort by group order then name. - all.sort((a, b) => { + topLevel.sort((a, b) => { const ga = GROUP_ORDER.indexOf(a.group); const gb = GROUP_ORDER.indexOf(b.group); if (ga !== gb) return ga - gb; return a.name.localeCompare(b.name); }); + // Expand each extension's bundled MCP servers directly beneath it. + const all: InstalledItem[] = []; + for (const item of topLevel) { + all.push(item); + if (item.kind === 'plugin') { + all.push(...(childMcpItems.get(item.name) ?? [])); + } + } setItems(all); // Re-point the cursor at the same item by key (it may have moved groups). const prevKey = selectedKeyRef.current; @@ -218,18 +267,103 @@ export const InstalledTab = ({ selectedKeyRef.current = selectedItem?.key ?? null; }, [selectedItem]); + const { rows: terminalRows } = useTerminalSize(); + // One line per display row; reserve space for the dialog border, tab bar, + // scroll hints, status line (which may wrap) and footer around this tab. + const visibleCount = Math.max(6, (terminalRows || 24) - 12); + const [scrollOffset, setScrollOffset] = useState(0); + + // Flatten the grouped list into display rows (headers, items, gaps) so the + // list can be windowed to the terminal height. + type DisplayRow = + | { type: 'header'; group: InstalledGroup; count: number } + | { type: 'item'; item: InstalledItem } + | { type: 'gap' }; + const displayRows = useMemo(() => { + const out: DisplayRow[] = []; + for (const group of GROUP_ORDER) { + const rows = items.filter((it) => it.group === group); + if (!rows.length) continue; + out.push({ + type: 'header', + group, + // Bundled MCP rows travel with their extension; the header counts + // only top-level entries. + count: rows.filter((it) => !(it.kind === 'mcp' && it.parentExtension)) + .length, + }); + for (const item of rows) out.push({ type: 'item', item }); + out.push({ type: 'gap' }); + } + if (out.at(-1)?.type === 'gap') out.pop(); + return out; + }, [items]); + + // Keep the cursor — and its group header when directly above — visible, + // and re-clamp the offset when the list shrinks or the window grows. + useEffect(() => { + const maxOffset = Math.max(0, displayRows.length - visibleCount); + if (scrollOffset > maxOffset) { + setScrollOffset(maxOffset); + return; + } + const idx = displayRows.findIndex( + (r) => r.type === 'item' && r.item.key === selectedItem?.key, + ); + if (idx < 0) return; + const top = displayRows[idx - 1]?.type === 'header' ? idx - 1 : idx; + if (top < scrollOffset) { + setScrollOffset(top); + } else if (idx >= scrollOffset + visibleCount) { + setScrollOffset(idx - visibleCount + 1); + } + }, [displayRows, selectedItem, scrollOffset, visibleCount]); + const goToList = useCallback(() => { setView('list'); onLockChange(false); }, [onLockChange]); + // If a reload removed (or re-typed) the item whose detail is open, fall back + // to the list — otherwise the tab stays locked with no active key handler. + useEffect(() => { + if (view === 'list' || loading) return; + const matches = + selectedItem && (view === 'mcp-detail') === (selectedItem.kind === 'mcp'); + if (!matches) goToList(); + }, [view, loading, selectedItem, goToList]); + + const isParentExtensionActive = useCallback( + (item: Extract): boolean => + items.some( + (p) => + p.kind === 'plugin' && p.name === item.parentExtension && p.isActive, + ), + [items], + ); + const enterDetail = useCallback( (item: InstalledItem) => { + // A disabled extension's servers are not loaded into the runtime config, + // so the MCP detail view would have nothing to show. + if ( + item.kind === 'mcp' && + item.parentExtension && + !isParentExtensionActive(item) + ) { + onStatus({ + type: 'info', + text: t('Enable extension "{{name}}" to manage this MCP server.', { + name: item.parentExtension, + }), + }); + return; + } onStatus(null); setView(item.kind === 'plugin' ? 'plugin-detail' : 'mcp-detail'); onLockChange(true); }, - [onLockChange, onStatus], + [onLockChange, onStatus, isParentExtensionActive], ); const togglePlugin = useCallback( @@ -270,6 +404,27 @@ export const InstalledTab = ({ const toggleMcp = useCallback( async (item: Extract) => { if (mutatingRef.current) return; + if (item.parentExtension) { + if (!isParentExtensionActive(item)) { + onStatus({ + type: 'info', + text: t('Enable extension "{{name}}" to manage this MCP server.', { + name: item.parentExtension, + }), + }); + return; + } + if (item.isActive) { + // Disabling a bundled server is done by disabling its extension. + onStatus({ + type: 'info', + text: t('Cannot disable an extension-provided MCP server here.'), + }); + return; + } + // An individually-excluded server under an active extension may be + // re-enabled; fall through to the normal enable path. + } const toolRegistry = config.getToolRegistry(); mutatingRef.current = true; // Enabling rediscovers the server's tools, which can take a while. @@ -332,7 +487,7 @@ export const InstalledTab = ({ mutatingRef.current = false; } }, - [config, load, onStatus], + [config, load, onStatus, isParentExtensionActive], ); const toggleFavorite = useCallback( @@ -368,8 +523,17 @@ export const InstalledTab = ({ void toggleMcp(selectedItem); } } else if (key.sequence === 'f' && !key.ctrl && !key.meta) { - if (selectedItem && !mutatingRef.current) - void toggleFavorite(selectedItem); + if (!selectedItem || mutatingRef.current) return; + // Bundled MCP servers stay nested under their extension, so they + // cannot be favorited independently. + if (selectedItem.kind === 'mcp' && selectedItem.parentExtension) { + onStatus({ + type: 'info', + text: t('Extension-provided MCP servers cannot be favorited.'), + }); + return; + } + void toggleFavorite(selectedItem); } }, { isActive: isActive && view === 'list' }, @@ -419,60 +583,72 @@ export const InstalledTab = ({ ); } - // Grouped list rendering. - const groups = GROUP_ORDER.map((group) => ({ - group, - rows: items.filter((it) => it.group === group), - })).filter((g) => g.rows.length > 0); + // Windowed list rendering. A gap row at the window's top edge would render + // as a stray blank line under the "more above" hint, so trim it. + const visibleRows = displayRows.slice( + scrollOffset, + scrollOffset + visibleCount, + ); + while (visibleRows[0]?.type === 'gap') visibleRows.shift(); + const hasAbove = scrollOffset > 0; + const hasBelow = scrollOffset + visibleCount < displayRows.length; return ( - {groups.map(({ group, rows }) => ( - - - {groupLabel(group)} ({rows.length}) - - {rows.map((item) => { - const globalIndex = items.indexOf(item); - const isSelected = globalIndex === selectedIndex; - const marker = isSelected ? '●' : ' '; - const kindBadge = - item.kind === 'mcp' - ? t('MCP') - : t('Extension v{{version}}', { - version: item.extension.version, - }); - const statusColor = item.isActive - ? theme.status.success - : theme.text.secondary; - return ( - - - - {marker} - - - - - {item.name} - - {item.isFavorite ? ( - - ) : null} - - {kindBadge} - - ({item.isActive ? t('active') : t('disabled')}) - - - ); - })} - - ))} + {hasAbove ? ( + {t('↑ more above')} + ) : null} + {visibleRows.map((row, i) => { + if (row.type === 'gap') { + return ; + } + if (row.type === 'header') { + return ( + + {groupLabel(row.group)} ({row.count}) + + ); + } + const item = row.item; + const globalIndex = items.indexOf(item); + const isSelected = globalIndex === selectedIndex; + const marker = isSelected ? '●' : ' '; + const isChild = item.kind === 'mcp' && !!item.parentExtension; + const kindBadge = + item.kind === 'mcp' + ? t('MCP') + : t('Extension v{{version}}', { + version: item.extension.version, + }); + const statusColor = item.isActive + ? theme.status.success + : theme.text.secondary; + return ( + + + + {marker} + + + + + {isChild ? ' └ ' : ''} + {item.name} + + {item.isFavorite ? ( + + ) : null} + + {kindBadge} + + ({item.isActive ? t('active') : t('disabled')}) + + + ); + })} + {hasBelow ? ( + {t('↓ more below')} + ) : null} ); }; diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts index a38b639362d..89c7aa0beeb 100644 --- a/packages/cli/src/ui/components/extensions/types.ts +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -63,6 +63,9 @@ export type InstalledItem = isActive: boolean; isFavorite: boolean; group: InstalledGroup; + /** Set when the MCP server is bundled with an extension; the row is + * rendered indented under that extension. */ + parentExtension?: string; }; /** diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index 47aacd86a2c..100f27d78f8 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -209,7 +209,7 @@ export const McpServerActionsView = ({ } else { if (server.source === 'extension') { onStatus({ - type: 'error', + type: 'info', text: t('Cannot disable an extension-provided MCP server here.'), }); return; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index 32e49e39022..db20e03de8d 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -73,12 +73,14 @@ export const ServerDetailStep: React.FC = ({ }); } - // 始终显示启用/禁用选项 - result.push({ - key: 'toggle-disable', - label: server?.isDisabled ? t('Enable') : t('Disable'), - value: 'toggle-disable', - }); + // 扩展提供的服务器随扩展启停,不提供"禁用"操作(重新启用仍允许) + if (server.source !== 'extension' || server.isDisabled) { + result.push({ + key: 'toggle-disable', + label: server.isDisabled ? t('Enable') : t('Disable'), + value: 'toggle-disable', + }); + } // 已认证的服务器显示"重新认证",未认证的显示"认证" if (!server.isDisabled) { From 12eb6e170496e740f4ac7cc636f272572c7a16bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Fri, 12 Jun 2026 20:16:06 +0800 Subject: [PATCH 32/48] feat(extensions): per-server disable for extension MCPs and live status Extension-bundled MCP servers can now be disabled individually, aligned with Claude Code. The record lives in extension-preferences.json keyed by extension name (not the global mcp.excluded list), so it never affects same-named servers from other sources, survives restarts via config.isMcpServerDisabled, and is cleaned up on uninstall. - All three surfaces support the toggle: Installed tab Space, the extensions MCP detail view, and the /mcp panel; ServerDetailStep offers Disable for extension servers again. - Installed tab MCP rows now show the live connection state (connected / needs authentication / connecting / disconnected) instead of a bare enabled flag; selected rows highlight the badge and status text too. - "Needs authentication" (401 marker or declared OAuth with no stored token) renders consistently in the /mcp list and both detail views; a successful connect clears the sticky 401 marker in core. - All three surfaces subscribe to MCP status changes for live updates, with statuses re-stamped synchronously before setState to close the load/listener race. - Perf: extension preferences are cached by mtime, and the disabled predicate finds the owning extension without rebuilding the merged server map. --- packages/cli/src/i18n/locales/en.js | 3 + packages/cli/src/i18n/locales/zh-TW.js | 3 + packages/cli/src/i18n/locales/zh.js | 3 + .../ExtensionsManagerDialog.test.tsx | 56 ++++++ .../extensions/tabs/InstalledTab.tsx | 185 +++++++++++++++--- .../cli/src/ui/components/extensions/types.ts | 2 + .../extensions/views/McpServerActionsView.tsx | 87 +++++++- .../ui/components/mcp/MCPManagementDialog.tsx | 105 ++++++++-- .../components/mcp/steps/ServerDetailStep.tsx | 30 ++- .../components/mcp/steps/ServerListStep.tsx | 20 +- packages/cli/src/ui/components/mcp/types.ts | 2 + packages/core/src/config/config.test.ts | 32 +++ packages/core/src/config/config.ts | 20 +- .../core/src/extension/extensionManager.ts | 17 ++ .../extension/extensionPreferences.test.ts | 36 ++++ .../src/extension/extensionPreferences.ts | 72 ++++++- packages/core/src/tools/mcp-client.ts | 5 + 17 files changed, 612 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 99fbd8879ff..98ca85e2e5b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -12,6 +12,8 @@ export default { 'Cannot disable an extension-provided MCP server here.', 'Cleared authentication for "{{name}}".': 'Cleared authentication for "{{name}}".', + 'MCP "{{name}}" disabled for all projects.': + 'MCP "{{name}}" disabled for all projects.', 'Enable extension "{{name}}" to manage this MCP server.': 'Enable extension "{{name}}" to manage this MCP server.', 'Extension-provided MCP servers cannot be favorited.': @@ -1173,6 +1175,7 @@ export default { connected: 'connected', connecting: 'connecting', disconnected: 'disconnected', + 'needs authentication': 'needs authentication', // MCP Server List 'User MCPs': 'User MCPs', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 2dc96ceaa84..ddd826dc7d5 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -12,6 +12,8 @@ export default { 'Cannot disable an extension-provided MCP server here.': '無法在此處停用擴展提供的 MCP 伺服器。', 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的認證資訊。', + 'MCP "{{name}}" disabled for all projects.': + 'MCP "{{name}}" 已在所有專案中停用。', 'Enable extension "{{name}}" to manage this MCP server.': '啟用擴展 "{{name}}" 後才能管理此 MCP 伺服器。', 'Extension-provided MCP servers cannot be favorited.': @@ -1022,6 +1024,7 @@ export default { connected: '已連接', connecting: '連接中', disconnected: '已斷開', + 'needs authentication': '需要認證', 'User MCPs': '用戶 MCP', 'Project MCPs': '項目 MCP', 'Extension MCPs': '擴展 MCP', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 17bd0c0a1d7..374f2a9e542 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -10,6 +10,8 @@ export default { 'Cannot disable an extension-provided MCP server here.': '无法在此处禁用扩展提供的 MCP 服务器。', 'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的认证信息。', + 'MCP "{{name}}" disabled for all projects.': + 'MCP "{{name}}" 已在所有项目中禁用。', 'Enable extension "{{name}}" to manage this MCP server.': '启用扩展 "{{name}}" 后才能管理此 MCP 服务器。', 'Extension-provided MCP servers cannot be favorited.': @@ -1111,6 +1113,7 @@ export default { connected: '已连接', connecting: '连接中', disconnected: '已断开', + 'needs authentication': '需要认证', // MCP Server List 'User MCPs': '用户 MCP', diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 6fe2f8f63a5..7cd89068a65 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -21,6 +21,7 @@ import type { DiscoveredPlugin, ExtensionSource, } from '@qwen-code/qwen-code-core'; +import { mcpServerRequiresOAuth } from '@qwen-code/qwen-code-core'; import type { ExtensionUpdateState } from '../../state/extensions.js'; // The Installed tab reads real user/workspace settings from disk when MCP @@ -75,6 +76,8 @@ const createManager = (o: ManagerOverrides = {}) => { discoverPlugins: vi.fn().mockResolvedValue(o.discovered ?? []), toggleFavorite: vi.fn(() => true), setExtensionScope: vi.fn(), + getDisabledMcpServers: vi.fn(() => []), + setMcpServerDisabled: vi.fn(), enableExtension: vi.fn().mockResolvedValue(undefined), disableExtension: vi.fn().mockResolvedValue(undefined), uninstallExtension: vi.fn().mockResolvedValue(undefined), @@ -336,6 +339,8 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('└ alpha-mcp'); // The group count only includes top-level items (the two extensions). expect(frame).toContain('User level (2)'); + // MCP rows show the live connection state, not a bare enabled flag. + expect(frame).toContain('disconnected'); // Enter on the nested row opens the MCP detail view with Extension source. stdin.write('\x1B[B'); // down: alpha -> alpha-mcp await waitFor(() => { @@ -348,6 +353,57 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(lastFrame()).toContain('Extension'); }); + it('shows needs-authentication for an MCP that failed auth instead of a bare active state', async () => { + mcpServerRequiresOAuth.set('oauth-mcp', true); + try { + const config = createConfig(createManager(), { + mcpServers: { 'oauth-mcp': { command: 'node' } }, + }); + const { lastFrame } = renderDialog(config, { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('oauth-mcp'); + }); + expect(lastFrame()).toContain('needs authentication'); + expect(lastFrame()).not.toContain('(active)'); + } finally { + mcpServerRequiresOAuth.delete('oauth-mcp'); + } + }); + + it('disables an extension-bundled MCP server individually with Space', async () => { + const ext = mockExtension('alpha', true); + (ext as unknown as { mcpServers: Record }).mcpServers = { + 'alpha-mcp': { command: 'node' }, + }; + const manager = createManager({ extensions: [ext] }); + const config = createConfig(manager, { + mcpServers: { + 'alpha-mcp': { command: 'node', extensionName: 'alpha' }, + }, + }); + const { stdin, lastFrame } = renderDialog(config, { + initialTab: EXTENSIONS_TABS.INSTALLED, + }); + await waitFor(() => { + expect(lastFrame()).toContain('alpha-mcp'); + }); + stdin.write('\x1B[B'); // down: alpha -> alpha-mcp + await waitFor(() => { + expect(lastFrame()).toMatch(/●\s+└ alpha-mcp/); + }); + stdin.write(' '); // Space -> disable just this server + await waitFor(() => { + // The disable is recorded against the extension, not mcp.excluded. + expect(manager.setMcpServerDisabled).toHaveBeenCalledWith( + 'alpha', + 'alpha-mcp', + true, + ); + }); + }); + it('toggles favorite when pressing f on the Installed tab', async () => { const manager = createManager({ extensions: [mockExtension('alpha', true)], diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index baeebb96199..4f73e41fb6c 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -17,7 +17,13 @@ import { type ExtensionScope, type MCPServerConfig, SettingScope, + MCPServerStatus, getMCPServerStatus, + removeMCPServerStatus, + addMCPStatusChangeListener, + removeMCPStatusChangeListener, + mcpServerRequiresOAuth, + MCPOAuthTokenStorage, createDebugLogger, } from '@qwen-code/qwen-code-core'; import { @@ -143,30 +149,61 @@ export const InstalledTab = ({ : undefined; const toolRegistry = hasAnyMcp ? config.getToolRegistry() : undefined; + // Servers that need (re-)authentication: either the connect attempt hit + // a 401 (runtime signal), or OAuth is declared but no token is stored. + // Connected servers are skipped — they are evidently authenticated. + const needsAuthNames = new Set(); + for (const [name, sc] of Object.entries(mcpServers)) { + if (getMCPServerStatus(name) === MCPServerStatus.CONNECTED) continue; + if (mcpServerRequiresOAuth.get(name)) { + needsAuthNames.add(name); + continue; + } + if (sc.oauth?.enabled) { + try { + const creds = await new MCPOAuthTokenStorage().getCredentials(name); + if (!creds) needsAuthNames.add(name); + } catch { + needsAuthNames.add(name); + } + } + } + const buildMcpInfo = ( name: string, serverConfig: MCPServerConfig, scope: InstalledMcpInfo['scope'], isDisabled: boolean, - ): InstalledMcpInfo => ({ - name, - status: getMCPServerStatus(name), - scope, - isDisabled, - transport: serverConfig.command - ? 'stdio' - : serverConfig.httpUrl - ? 'http' - : serverConfig.url - ? 'sse' - : 'unknown', - toolCount: - toolRegistry - ?.getAllTools() - .filter( - (tool) => (tool as { serverName?: string }).serverName === name, - ).length ?? 0, - }); + ): InstalledMcpInfo => { + // Status (and the status-derived needs-auth flag) are read here, in + // the same synchronous tick as setItems, so a transition during the + // earlier token-check awaits can't leave a stale combination. + const status = getMCPServerStatus(name); + return { + name, + status, + scope, + isDisabled, + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(name) === true || + needsAuthNames.has(name), + transport: serverConfig.command + ? 'stdio' + : serverConfig.httpUrl + ? 'http' + : serverConfig.url + ? 'sse' + : 'unknown', + toolCount: + toolRegistry + ?.getAllTools() + .filter( + (tool) => (tool as { serverName?: string }).serverName === name, + ).length ?? 0, + }; + }; const mcpItems: InstalledItem[] = []; for (const [name, serverConfig] of Object.entries(mcpServers)) { @@ -267,6 +304,34 @@ export const InstalledTab = ({ selectedKeyRef.current = selectedItem?.key ?? null; }, [selectedItem]); + // Live-update MCP rows when a server's connection status changes (e.g. a + // "connecting" server finishing) without re-running the full load(). + useEffect(() => { + const listener = (serverName: string, status?: MCPServerStatus) => { + if (status === undefined) return; // removals are handled by reloads + setItems((prev) => + prev.map((it) => + it.kind === 'mcp' && it.mcp.name === serverName + ? { + ...it, + mcp: { + ...it.mcp, + status, + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(serverName) === true || + it.mcp.requiresAuth, + }, + } + : it, + ), + ); + }; + addMCPStatusChangeListener(listener); + return () => removeMCPStatusChangeListener(listener); + }, []); + const { rows: terminalRows } = useTerminalSize(); // One line per display row; reserve space for the dialog border, tab bar, // scroll hints, status line (which may wrap) and footer around this tab. @@ -415,15 +480,48 @@ export const InstalledTab = ({ return; } if (item.isActive) { - // Disabling a bundled server is done by disabling its extension. + // Disable via the extension-scoped preference (not the global + // mcp.excluded list) so same-named servers from other sources are + // unaffected and uninstalling the extension cleans it up. + if (!extensionManager) return; + mutatingRef.current = true; onStatus({ type: 'info', - text: t('Cannot disable an extension-provided MCP server here.'), + text: t('Disabling MCP "{{name}}"...', { name: item.name }), }); + try { + extensionManager.setMcpServerDisabled( + item.parentExtension, + item.name, + true, + ); + await config.getToolRegistry()?.disconnectServer(item.name); + // Drop the status entry so the footer health pill doesn't keep + // counting an intentionally disabled server as offline. + removeMCPServerStatus(item.name); + // The per-extension disable record is user-global, unlike the + // scope-aware standalone toggle — say so. + onStatus({ + type: 'success', + text: t('MCP "{{name}}" disabled for all projects.', { + name: item.name, + }), + }); + await load(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + mutatingRef.current = false; + } return; } - // An individually-excluded server under an active extension may be - // re-enabled; fall through to the normal enable path. + // Enable: clear the extension-scoped flag, then fall through to the + // shared enable path (which also clears any manual exclusions). + extensionManager?.setMcpServerDisabled( + item.parentExtension, + item.name, + false, + ); } const toolRegistry = config.getToolRegistry(); mutatingRef.current = true; @@ -487,7 +585,7 @@ export const InstalledTab = ({ mutatingRef.current = false; } }, - [config, load, onStatus, isParentExtensionActive], + [config, extensionManager, load, onStatus, isParentExtensionActive], ); const toggleFavorite = useCallback( @@ -620,9 +718,34 @@ export const InstalledTab = ({ : t('Extension v{{version}}', { version: item.extension.version, }); - const statusColor = item.isActive - ? theme.status.success - : theme.text.secondary; + // MCP rows surface the live connection state — "enabled" alone would + // read as usable even when the server failed to connect or still + // needs authentication. + let statusLabel: string; + let statusColor: string; + if (item.kind === 'mcp') { + if (!item.isActive) { + statusLabel = t('disabled'); + statusColor = theme.text.secondary; + } else if (item.mcp.status === MCPServerStatus.CONNECTED) { + statusLabel = t('connected'); + statusColor = theme.status.success; + } else if (item.mcp.requiresAuth) { + statusLabel = t('needs authentication'); + statusColor = theme.status.warning; + } else if (item.mcp.status === MCPServerStatus.CONNECTING) { + statusLabel = t('connecting'); + statusColor = theme.status.warning; + } else { + statusLabel = t('disconnected'); + statusColor = theme.status.error; + } + } else { + statusLabel = item.isActive ? t('active') : t('disabled'); + statusColor = item.isActive + ? theme.status.success + : theme.text.secondary; + } return ( @@ -639,9 +762,11 @@ export const InstalledTab = ({ ) : null} - {kindBadge} - - ({item.isActive ? t('active') : t('disabled')}) + + {kindBadge}{' '} + + + ({statusLabel}) ); diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts index 89c7aa0beeb..bfacb1815b2 100644 --- a/packages/cli/src/ui/components/extensions/types.ts +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -41,6 +41,8 @@ export interface InstalledMcpInfo { isDisabled: boolean; transport: string; toolCount: number; + /** The server failed to connect because it needs (re-)authentication. */ + requiresAuth: boolean; } /** A single row in the Installed tab — either a plugin/extension or an MCP. */ diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index 100f27d78f8..a2bf86f3b16 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -11,6 +11,11 @@ import { t } from '../../../../i18n/index.js'; import { type Config, getMCPServerStatus, + removeMCPServerStatus, + addMCPStatusChangeListener, + removeMCPStatusChangeListener, + mcpServerRequiresOAuth, + MCPServerStatus, DiscoveredMCPTool, MCPOAuthTokenStorage, createDebugLogger, @@ -119,34 +124,85 @@ export const McpServerActionsView = ({ promptCount: serverPrompts.length, isDisabled: config.isMcpServerDisabled(serverName), hasOAuthTokens, + // Needs (re-)authentication: a 401 during connect, or OAuth declared + // with no stored token. Only meaningful while not connected. + requiresAuth: + status !== MCPServerStatus.CONNECTED && + (mcpServerRequiresOAuth.get(serverName) === true || + (Boolean(serverConfig.oauth?.enabled) && !hasOAuthTokens)), }; }, [config, serverName]); + // Re-stamp status (and the status-derived needs-auth flag) synchronously + // right before setState: a status change landing during buildServer's + // awaits would fire the listener against the old state and then be + // overwritten by the stale snapshot. + const freshen = useCallback( + (info: MCPServerDisplayInfo | null): MCPServerDisplayInfo | null => { + if (!info) return info; + const status = getMCPServerStatus(info.name); + return { + ...info, + status, + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(info.name) === true || + info.requiresAuth, + }; + }, + [], + ); + const reload = useCallback(async () => { setLoading(true); try { - setServer(await buildServer()); + setServer(freshen(await buildServer())); } catch (error) { debugLogger.error('Failed to load MCP server:', error); } finally { setLoading(false); } onReload(); - }, [buildServer, onReload]); + }, [buildServer, freshen, onReload]); useEffect(() => { let cancelled = false; void (async () => { const info = await buildServer(); if (!cancelled) { - setServer(info); + setServer(freshen(info)); setLoading(false); } })(); return () => { cancelled = true; }; - }, [buildServer]); + }, [buildServer, freshen]); + + // Live-update the connection status shown in the detail view. + useEffect(() => { + const listener = (name: string, status?: MCPServerStatus) => { + if (name !== serverName || status === undefined) return; + setServer((prev) => + prev + ? { + ...prev, + status, + // Keep needs-auth in step with the live status (the 401 marker + // is written before the DISCONNECTED event fires). + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(name) === true || + prev.requiresAuth, + } + : prev, + ); + }; + addMCPStatusChangeListener(listener); + return () => removeMCPStatusChangeListener(listener); + }, [serverName]); const getServerTools = useCallback((): MCPToolDisplayInfo[] => { const toolRegistry = config.getToolRegistry(); @@ -188,7 +244,14 @@ export const McpServerActionsView = ({ const toolRegistry = config.getToolRegistry(); try { if (server.isDisabled) { - // Enable: drop from both exclusion lists + runtime, then rediscover. + // Enable: clear the extension-scoped disable flag (if any), drop from + // both exclusion lists + runtime, then rediscover. + const extensionName = server.config.extensionName; + if (extensionName) { + config + .getExtensionManager() + ?.setMcpServerDisabled(extensionName, serverName, false); + } const settings = loadSettings(); for (const scope of [SettingScope.User, SettingScope.Workspace]) { const excluded = @@ -206,14 +269,24 @@ export const McpServerActionsView = ({ runtimeExcluded.filter((n) => n !== serverName), ); await toolRegistry?.discoverToolsForServer(serverName); - } else { - if (server.source === 'extension') { + } else if (server.source === 'extension') { + // Disable via the extension-scoped preference so the global + // mcp.excluded list (and same-named servers elsewhere) are untouched. + const extensionName = server.config.extensionName; + const manager = config.getExtensionManager(); + if (!extensionName || !manager) { onStatus({ type: 'info', text: t('Cannot disable an extension-provided MCP server here.'), }); return; } + manager.setMcpServerDisabled(extensionName, serverName, true); + await toolRegistry?.disconnectServer(serverName); + // Drop the status entry so the footer health pill doesn't keep + // counting an intentionally disabled server as offline. + removeMCPServerStatus(serverName); + } else { const scope = server.source === 'project' ? SettingScope.Workspace diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index dfa13901436..cfe666dc3b5 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -24,6 +24,11 @@ import { AuthenticateStep } from './steps/AuthenticateStep.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { getMCPServerStatus, + removeMCPServerStatus, + addMCPStatusChangeListener, + removeMCPStatusChangeListener, + mcpServerRequiresOAuth, + MCPServerStatus, DiscoveredMCPTool, MCPOAuthTokenStorage, type MCPServerConfig, @@ -120,6 +125,13 @@ export const MCPManagementDialog: React.FC = ({ // Ignore errors when checking token existence } + // Needs (re-)authentication: a 401 during connect, or OAuth declared + // with no stored token. Only meaningful while not connected. + const requiresAuth = + status !== MCPServerStatus.CONNECTED && + (mcpServerRequiresOAuth.get(name) === true || + (Boolean(serverConfig.oauth?.enabled) && !hasOAuthTokens)); + serverInfos.push({ name, status, @@ -130,19 +142,37 @@ export const MCPManagementDialog: React.FC = ({ promptCount: serverPrompts.length, isDisabled, hasOAuthTokens, + requiresAuth, }); } return serverInfos; }, [config]); + // Synchronously refresh status + needs-auth on a fetched snapshot. + const restampStatus = useCallback((s: MCPServerDisplayInfo) => { + const status = getMCPServerStatus(s.name); + return { + ...s, + status, + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(s.name) === true || s.requiresAuth, + }; + }, []); + // Load MCP server data on initial render useEffect(() => { const loadServers = async () => { setIsLoading(true); try { const serverInfos = await fetchServerData(); - setServers(serverInfos); + // Re-stamp statuses (and the status-derived needs-auth flag) + // synchronously right before setState: a status change landing + // during fetch's awaits would fire the listener against the OLD + // state and then be overwritten by this snapshot. + setServers(serverInfos.map(restampStatus)); } catch (error) { debugLogger.error('Error loading MCP servers:', error); } finally { @@ -151,7 +181,35 @@ export const MCPManagementDialog: React.FC = ({ }; loadServers(); - }, [fetchServerData]); + }, [fetchServerData, restampStatus]); + + // Live-update server rows (and the derived detail view) when a connection + // status changes, e.g. a "connecting" server finishing. + useEffect(() => { + const listener = (serverName: string, status?: MCPServerStatus) => { + if (status === undefined) return; // removals are handled by reloads + setServers((prev) => + prev.map((s) => + s.name === serverName + ? { + ...s, + status, + // Keep needs-auth in step with the live status: a connect + // proves auth works; a failure may have just set the 401 + // marker (it is written before the DISCONNECTED event). + requiresAuth: + status === MCPServerStatus.CONNECTED + ? false + : mcpServerRequiresOAuth.get(serverName) === true || + s.requiresAuth, + } + : s, + ), + ); + }; + addMCPStatusChangeListener(listener); + return () => removeMCPStatusChangeListener(listener); + }, []); // Selected server const selectedServer = useMemo(() => { @@ -253,13 +311,14 @@ export const MCPManagementDialog: React.FC = ({ setIsLoading(true); try { const serverInfos = await fetchServerData(); - setServers(serverInfos); + // Same synchronous re-stamp as the initial load (see comment there). + setServers(serverInfos.map(restampStatus)); } catch (error) { debugLogger.error('Error reloading MCP servers:', error); } finally { setIsLoading(false); } - }, [fetchServerData]); + }, [fetchServerData, restampStatus]); // Clear OAuth authentication tokens and disconnect the server const handleClearAuth = useCallback(async () => { @@ -323,6 +382,14 @@ export const MCPManagementDialog: React.FC = ({ const server = selectedServer; const settings = loadSettings(); + // Clear the extension-scoped disable flag, if any. + const extensionName = server.config.extensionName; + if (extensionName) { + config + .getExtensionManager() + ?.setMcpServerDisabled(extensionName, server.name, false); + } + // Remove from user and workspace exclusion lists for (const scope of [SettingScope.User, SettingScope.Workspace]) { const scopeSettings = settings.forScope(scope).settings; @@ -376,17 +443,31 @@ export const MCPManagementDialog: React.FC = ({ const server = selectedServer; const settings = loadSettings(); - // Determine the scope based on server configuration location - let targetScope: 'user' | 'workspace' = 'user'; + // Extension servers are disabled via the extension-scoped preference + // instead of user/workspace mcp.excluded settings. if (server.source === 'extension') { - // Extension servers should not be disabled through user/workspace settings - // Show error message and return - debugLogger.warn( - `Cannot disable extension MCP server '${server.name}'`, - ); + const extensionName = server.config.extensionName; + const manager = config.getExtensionManager(); + if (!extensionName || !manager) { + debugLogger.warn( + `Cannot disable extension MCP server '${server.name}'`, + ); + setIsLoading(false); + return; + } + manager.setMcpServerDisabled(extensionName, server.name, true); + await config.getToolRegistry()?.disconnectServer(server.name); + // Drop the status entry so the footer health pill doesn't keep + // counting an intentionally disabled server as offline. + removeMCPServerStatus(server.name); + await reloadServers(); setIsLoading(false); return; - } else if (server.source === 'project') { + } + + // Determine the scope based on server configuration location + let targetScope: 'user' | 'workspace' = 'user'; + if (server.source === 'project') { targetScope = 'workspace'; } diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index db20e03de8d..c6bb8259d00 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -10,6 +10,7 @@ import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import { t } from '../../../../i18n/index.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; import type { ServerDetailStepProps } from '../types.js'; import { getStatusColor, @@ -37,8 +38,15 @@ export const ServerDetailStep: React.FC = ({ onBack, isActive = true, }) => { + // 未连接且需要认证时,状态以"需要认证"展示,避免误导用户去排查连接问题。 + // requiresAuth 是加载时的快照,状态被实时推到 connected 后不再适用。 + const needsAuth = + !!server && + !server.isDisabled && + !!server.requiresAuth && + server.status !== MCPServerStatus.CONNECTED; const statusColor = server - ? server.isDisabled + ? server.isDisabled || needsAuth ? 'yellow' : getStatusColor(server.status) : 'gray'; @@ -73,14 +81,12 @@ export const ServerDetailStep: React.FC = ({ }); } - // 扩展提供的服务器随扩展启停,不提供"禁用"操作(重新启用仍允许) - if (server.source !== 'extension' || server.isDisabled) { - result.push({ - key: 'toggle-disable', - label: server.isDisabled ? t('Enable') : t('Disable'), - value: 'toggle-disable', - }); - } + // 始终显示启用/禁用选项(扩展提供的服务器走扩展级禁用记录) + result.push({ + key: 'toggle-disable', + label: server.isDisabled ? t('Enable') : t('Disable'), + value: 'toggle-disable', + }); // 已认证的服务器显示"重新认证",未认证的显示"认证" if (!server.isDisabled) { @@ -139,7 +145,11 @@ export const ServerDetailStep: React.FC = ({ } > {getStatusIcon(server.status)}{' '} - {server.isDisabled ? t('disabled') : t(server.status)} + {server.isDisabled + ? t('disabled') + : needsAuth + ? t('needs authentication') + : t(server.status)} diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx index 78935779e98..6f77c00276e 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -10,6 +10,7 @@ import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../../keyMatchers.js'; import { t } from '../../../../i18n/index.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js'; import { groupServersBySource, @@ -108,9 +109,16 @@ export const ServerListStep: React.FC = ({ const isSelected = groupIndex === currentPosition.groupIndex && itemIndex === currentPosition.itemIndex; - const statusColor = server.isDisabled - ? 'yellow' - : getStatusColor(server.status); + // 未连接且需要认证时,状态以"需要认证"展示(requiresAuth 是 + // 加载时的快照,状态被实时推到 connected 后不再适用) + const needsAuth = + !server.isDisabled && + !!server.requiresAuth && + server.status !== MCPServerStatus.CONNECTED; + const statusColor = + server.isDisabled || needsAuth + ? 'yellow' + : getStatusColor(server.status); return ( @@ -146,7 +154,11 @@ export const ServerListStep: React.FC = ({ } > {getStatusIcon(server.status)}{' '} - {server.isDisabled ? t('disabled') : t(server.status)} + {server.isDisabled + ? t('disabled') + : needsAuth + ? t('needs authentication') + : t(server.status)} {/* 显示无效工具警告 */} {!!server.invalidToolCount && server.invalidToolCount > 0 && ( diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index f4e19fc3844..a43fef95a94 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -50,6 +50,8 @@ export interface MCPServerDisplayInfo { isDisabled: boolean; /** 是否存储有 OAuth 认证信息 */ hasOAuthTokens?: boolean; + /** 未连接且需要(重新)认证:连接时收到 401,或声明了 OAuth 但无已存 token */ + requiresAuth?: boolean; } /** diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3121856ef60..4f7a5b34857 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -891,6 +891,38 @@ describe('Server Config (config.ts)', () => { } as ConfigParameters); expect(config.getFailedMcpServerNames()).toEqual([]); }); + + it('isMcpServerDisabled consults extension preferences only for the contributing extension', () => { + const config = new Config({ + ...baseParams, + checkpointing: false, + // baseParams pins overrideExtensions to []; lift it so the mocked + // loaded extension is visible to getActiveExtensions(). + overrideExtensions: undefined, + // A user-configured server that shadows the extension's same-named one. + mcpServers: { foo: new MCPServerConfig() }, + } as ConfigParameters); + const manager = config.getExtensionManager(); + vi.spyOn(manager, 'getLoadedExtensions').mockReturnValue([ + { + name: 'my-ext', + isActive: true, + config: { name: 'my-ext', mcpServers: { bar: {}, foo: {} } }, + } as unknown as ReturnType[number], + ]); + vi.spyOn(manager, 'getDisabledMcpServers').mockImplementation( + (extensionName: string) => + extensionName === 'my-ext' ? ['bar', 'foo'] : [], + ); + // `bar` is contributed by the extension and disabled in its preferences. + expect(config.isMcpServerDisabled('bar')).toBe(true); + // `foo` is shadowed by the user config (no extensionName on the merged + // entry), so the extension's disable record must not affect it. + expect(config.isMcpServerDisabled('foo')).toBe(false); + // The global exclusion list still applies to anything. + config.setExcludedMcpServers(['foo']); + expect(config.isMcpServerDisabled('foo')).toBe(true); + }); }); describe('refreshAuth', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b47eb899946..5c4adfa8cec 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2729,7 +2729,25 @@ export class Config { } isMcpServerDisabled(serverName: string): boolean { - return this.excludedMcpServers?.includes(serverName) ?? false; + if (this.excludedMcpServers?.includes(serverName)) return true; + // Extension-bundled servers can be disabled individually via extension + // preferences. Only the extension that actually contributed the server is + // consulted, so a same-named server from another source (e.g. a shadowing + // user config) is never affected. The owner lookup mirrors the + // getMcpServers() merge (user/project config wins, then first active + // extension) without rebuilding the merged map — this predicate runs per + // server in discovery loops and on every resource read. + if (this.mcpServers?.[serverName]) return false; + for (const extension of this.getActiveExtensions()) { + if (extension.config.mcpServers?.[serverName]) { + return ( + this.extensionManager + ?.getDisabledMcpServers(extension.config.name) + .includes(serverName) ?? false + ); + } + } + return false; } addMcpServers(servers: Record): void { diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 261826de378..1c9033e0187 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -548,6 +548,23 @@ export class ExtensionManager { this.preferencesStore.setScope(name, scope); } + /** MCP servers individually disabled inside the given extension. */ + getDisabledMcpServers(extensionName: string): string[] { + return this.preferencesStore.getDisabledMcpServers(extensionName); + } + + setMcpServerDisabled( + extensionName: string, + serverName: string, + disabled: boolean, + ): void { + this.preferencesStore.setMcpServerDisabled( + extensionName, + serverName, + disabled, + ); + } + // ========================================================================== // Marketplace registry & discovery // ========================================================================== diff --git a/packages/core/src/extension/extensionPreferences.test.ts b/packages/core/src/extension/extensionPreferences.test.ts index 1400365a3e1..b8b1ff39774 100644 --- a/packages/core/src/extension/extensionPreferences.test.ts +++ b/packages/core/src/extension/extensionPreferences.test.ts @@ -72,16 +72,52 @@ describe('ExtensionPreferencesStore', () => { it('clears all preference state for an extension', () => { store.toggleFavorite('alpha'); store.setScope('alpha', 'user'); + store.setMcpServerDisabled('alpha', 'srv', true); store.toggleFavorite('beta'); store.clear('alpha'); expect(store.isFavorite('alpha')).toBe(false); expect(store.getScope('alpha')).toBeUndefined(); + expect(store.getDisabledMcpServers('alpha')).toEqual([]); // Unrelated entries are untouched. expect(store.isFavorite('beta')).toBe(true); }); + it('records per-extension disabled MCP servers and persists them', () => { + store.setMcpServerDisabled('alpha', 'srv-a', true); + store.setMcpServerDisabled('alpha', 'srv-b', true); + store.setMcpServerDisabled('beta', 'srv-a', true); + + expect(store.getDisabledMcpServers('alpha')).toEqual(['srv-a', 'srv-b']); + // Namespaced: beta's same-named entry is independent of alpha's. + expect(store.getDisabledMcpServers('beta')).toEqual(['srv-a']); + + const reopened = new ExtensionPreferencesStore(filePath); + expect(reopened.getDisabledMcpServers('alpha')).toEqual(['srv-a', 'srv-b']); + + store.setMcpServerDisabled('alpha', 'srv-a', false); + expect(store.getDisabledMcpServers('alpha')).toEqual(['srv-b']); + // Removing the last entry drops the extension key entirely. + store.setMcpServerDisabled('alpha', 'srv-b', false); + expect(store.getDisabledMcpServers('alpha')).toEqual([]); + expect(store.read().disabledMcpServers['alpha']).toBeUndefined(); + }); + + it('drops malformed disabledMcpServers values when reading', () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + favorites: [], + scopes: {}, + disabledMcpServers: { alpha: ['ok', 42], beta: 'oops' }, + }), + ); + expect(store.getDisabledMcpServers('alpha')).toEqual(['ok']); + expect(store.getDisabledMcpServers('beta')).toEqual([]); + }); + it('does not leak favorites between fresh stores via a shared default array', () => { // Toggling a favorite on a store whose file does not exist must not // mutate a shared module-level default, polluting other instances. diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts index beec5fd1358..a85537bb573 100644 --- a/packages/core/src/extension/extensionPreferences.ts +++ b/packages/core/src/extension/extensionPreferences.ts @@ -32,11 +32,18 @@ export interface ExtensionPreferences { favorites: string[]; /** Per-extension scope intent, keyed by extension name. */ scopes: Record; + /** + * MCP servers the user disabled individually inside an extension, keyed by + * extension name. Namespaced per extension (instead of the global + * `mcp.excluded` list) so a disable can never affect a same-named server + * from another source, and uninstalling the extension cleans it up. + */ + disabledMcpServers: Record; } /** Always returns fresh containers so callers can safely mutate the result. */ function emptyPreferences(): ExtensionPreferences { - return { favorites: [], scopes: {} }; + return { favorites: [], scopes: {}, disabledMcpServers: {} }; } /** @@ -45,10 +52,21 @@ function emptyPreferences(): ExtensionPreferences { * file so it is cheap to read/write and easy to reason about. */ export class ExtensionPreferencesStore { + // Parsed-file cache keyed by mtime. `read()` sits on hot paths now + // (Config.isMcpServerDisabled is consulted per server during discovery and + // resource reads), so avoid re-reading/re-parsing when the file hasn't + // changed; the mtime check keeps cross-process writes visible. + private cache: { prefs: ExtensionPreferences; mtimeMs: number } | null = null; + constructor(private readonly filePath: string) {} read(): ExtensionPreferences { try { + const { mtimeMs } = fs.statSync(this.filePath); + if (this.cache?.mtimeMs === mtimeMs) { + // Clone so callers can mutate the result without corrupting the cache. + return structuredClone(this.cache.prefs); + } const content = fs.readFileSync(this.filePath, 'utf-8'); const parsed = JSON.parse(content) as Partial; const rawScopes = @@ -57,10 +75,27 @@ export class ExtensionPreferencesStore { for (const [name, value] of Object.entries(rawScopes)) { if (isExtensionScope(value)) scopes[name] = value; } - return { + const rawDisabled = + parsed.disabledMcpServers && + typeof parsed.disabledMcpServers === 'object' + ? parsed.disabledMcpServers + : {}; + const disabledMcpServers: Record = {}; + for (const [name, value] of Object.entries(rawDisabled)) { + if (Array.isArray(value)) { + const servers = value.filter( + (v): v is string => typeof v === 'string', + ); + if (servers.length) disabledMcpServers[name] = servers; + } + } + const prefs: ExtensionPreferences = { favorites: Array.isArray(parsed.favorites) ? parsed.favorites : [], scopes, + disabledMcpServers, }; + this.cache = { prefs: structuredClone(prefs), mtimeMs }; + return prefs; } catch (error) { if ( error instanceof Error && @@ -77,6 +112,8 @@ export class ExtensionPreferencesStore { private write(prefs: ExtensionPreferences): void { fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); atomicWriteFileSync(this.filePath, JSON.stringify(prefs, null, 2)); + // Drop the cache; the next read re-stats and re-parses the new file. + this.cache = null; } isFavorite(name: string): boolean { @@ -119,6 +156,33 @@ export class ExtensionPreferencesStore { this.write(prefs); } + /** MCP servers individually disabled inside the given extension. */ + getDisabledMcpServers(extensionName: string): string[] { + return this.read().disabledMcpServers[extensionName] ?? []; + } + + setMcpServerDisabled( + extensionName: string, + serverName: string, + disabled: boolean, + ): void { + const prefs = this.read(); + const current = prefs.disabledMcpServers[extensionName] ?? []; + if (disabled) { + if (current.includes(serverName)) return; + prefs.disabledMcpServers[extensionName] = [...current, serverName]; + } else { + if (!current.includes(serverName)) return; + const next = current.filter((n) => n !== serverName); + if (next.length) { + prefs.disabledMcpServers[extensionName] = next; + } else { + delete prefs.disabledMcpServers[extensionName]; + } + } + this.write(prefs); + } + /** Removes all preference state for an extension (used on uninstall). */ clear(name: string): void { const prefs = this.read(); @@ -132,6 +196,10 @@ export class ExtensionPreferencesStore { delete prefs.scopes[name]; changed = true; } + if (prefs.disabledMcpServers[name]) { + delete prefs.disabledMcpServers[name]; + changed = true; + } if (changed) { this.write(prefs); } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index e6b653d83e5..45727b47c86 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -463,6 +463,11 @@ export function updateMCPServerStatus( status: MCPServerStatus, ): void { serverStatuses.set(serverName, status); + // A successful connect proves authentication works now — clear the sticky + // 401 marker so later unrelated outages aren't mislabeled as auth failures. + if (status === MCPServerStatus.CONNECTED) { + mcpServerRequiresOAuth.delete(serverName); + } // Snapshot the listener list so a listener that detaches itself (or // attaches a new one) during dispatch doesn't mutate the array we're // iterating. From baeaf69cb14b93e925398443d28cde545064fd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 15 Jun 2026 10:26:48 +0800 Subject: [PATCH 33/48] docs(extensions): document interactive manager and backfill missing i18n Add a 'The interactive extension manager' section covering the Discover/Installed/Sources tabs, per-server MCP enable/disable, and keybindings. Backfill en/zh/zh-TW entries for six previously-untranslated strings referenced via t() (npm install flags, the install command description, 'Description', and 'Delete Session') so they render localized instead of falling back to English. --- docs/users/extension/introduction.md | 10 ++++++++++ packages/cli/src/i18n/locales/en.js | 10 ++++++++++ packages/cli/src/i18n/locales/zh-TW.js | 10 ++++++++++ packages/cli/src/i18n/locales/zh.js | 10 ++++++++++ 4 files changed, 40 insertions(+) diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index f9da6da0c51..30b6457bb23 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -18,6 +18,16 @@ You can manage extensions at runtime within the interactive CLI using `/extensio | `/extensions install ` | Install an extension from a git URL, local path, npm package, or marketplace | | `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | +#### The interactive extension manager + +Running `/extensions` (or `/extensions manage`) opens an interactive manager with three tabs. Press `Tab` or the `←`/`→` arrows to switch between them. + +- **Discover** — browse plugins from your configured marketplace sources. Type to search, `Enter` to view a plugin's details, and install it (you'll be asked to choose an install scope). Press `Ctrl+R` to re-fetch the listings, and `Esc` to go back. +- **Installed** — your installed extensions, grouped by scope (**User level**, **Project level**, and favorites). Use `↑`/`↓` to navigate, `Space` to enable/disable an extension, `f` to favorite it, and `Enter` to open its details. MCP servers bundled by an extension appear nested under their parent extension with live connection status; you can enable or disable each server individually from there. +- **Sources** — manage the marketplace sources that feed the Discover tab. Use `↑`/`↓` to navigate, `Enter` to select a source, and `d` to remove one. These are the same sources managed by the `qwen extensions sources` CLI commands described below. + +Changes made here hot-reload immediately, without restarting Qwen Code. + ### CLI Extension Management You can also manage extensions using `qwen extensions` CLI commands. Note that changes made via CLI commands will be reflected in active CLI sessions on restart. diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index cd18905c03f..45a8786705a 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -745,6 +745,16 @@ export default { 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.', 'The git ref to install from.': 'The git ref to install from.', + '--registry is only applicable for npm extensions.': + '--registry is only applicable for npm extensions.', + 'Custom npm registry URL (only for npm extensions).': + 'Custom npm registry URL (only for npm extensions).', + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).': + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).', + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).': + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).', + Description: 'Description', + 'Delete Session': 'Delete Session', 'Enable auto-update for this extension.': 'Enable auto-update for this extension.', 'Enable pre-release versions for this extension.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 7982f042408..587613bc4d3 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -668,6 +668,16 @@ export default { 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': '要安裝的擴展的 GitHub URL、本地路徑或市場源(marketplace-url:plugin-name)。', 'The git ref to install from.': '要安裝的 Git 引用。', + '--registry is only applicable for npm extensions.': + '--registry 僅適用於 npm 擴展。', + 'Custom npm registry URL (only for npm extensions).': + '自訂 npm registry URL(僅適用於 npm 擴展)。', + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).': + '--ref 不適用於 npm 擴展。請改用 @version 後綴(例如 @scope/package@1.2.0)。', + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).': + '從 Git 倉庫 URL、本地路徑、帶作用域的 npm 套件(@scope/name)或 Claude 市場源(marketplace-url:plugin-name)安裝擴展。', + Description: '描述', + 'Delete Session': '刪除會話', 'Enable auto-update for this extension.': '為此擴展啟用自動更新。', 'Enable pre-release versions for this extension.': '為此擴展啟用預發佈版本。', 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 192566a304e..8347825027c 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -710,6 +710,16 @@ export default { 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.': '要安装的扩展的 GitHub URL、本地路径或市场源(marketplace-url:plugin-name)。', 'The git ref to install from.': '要安装的 Git 引用。', + '--registry is only applicable for npm extensions.': + '--registry 仅适用于 npm 扩展。', + 'Custom npm registry URL (only for npm extensions).': + '自定义 npm registry URL(仅适用于 npm 扩展)。', + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).': + '--ref 不适用于 npm 扩展。请改用 @version 后缀(例如 @scope/package@1.2.0)。', + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).': + '从 Git 仓库 URL、本地路径、带作用域的 npm 包(@scope/name)或 Claude 市场源(marketplace-url:plugin-name)安装扩展。', + Description: '描述', + 'Delete Session': '删除会话', 'Enable auto-update for this extension.': '为此扩展启用自动更新。', 'Enable pre-release versions for this extension.': '为此扩展启用预发布版本。', 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.': From 05eb44f6844b990080d4eeb4a1f4e089f3aa6660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 15 Jun 2026 11:03:01 +0800 Subject: [PATCH 34/48] fix(extensions): harden untrusted-marketplace handling from PR review Address review findings on the new marketplace/plugin attack surface: Security - Strip ANSI/control sequences from marketplace-sourced display strings (Discover + Sources) so a hostile source can't manipulate the terminal before install consent. - Confine git-subdir source.path to the cloned repo (reject absolute/.. /empty) to stop path traversal; prefer the immutable SHA pin over a named ref. - Refuse absolute/relative local-path plugin sources from remote marketplaces. - Reject absolute/escaping mcpServers and hooks file paths in plugin.json so the converter can't read arbitrary out-of-tree files. - Only follow http(s) plugin homepages (block file:// etc. via open()). - Cap marketplace HTTP response bodies and add a wall-clock fetch deadline (req.setTimeout is socket-idle only) to prevent OOM / indefinite hangs. Correctness / robustness - A post-install scope/enablement failure no longer marks the install failed. - .mcp.json without an mcpServers object is skipped instead of misparsed. - Guard Ctrl+R refresh against concurrent in-flight discovery. - Log (not swallow) OAuth token-store lookup failures. - InstalledTab: single-pass tool-count map (drop N+1 getAllTools) and O(1) row index lookup (drop per-row items.indexOf). Tests - Make the .git-stripping assertion meaningful and cover the absolute-path mcpServers guard and the .mcp.json fallback. --- .../extensions/tabs/DiscoverTab.tsx | 43 ++++++++-- .../extensions/tabs/InstalledTab.tsx | 28 +++++-- .../components/extensions/tabs/SourcesTab.tsx | 7 +- .../extensions/views/McpServerActionsView.tsx | 7 +- .../src/extension/claude-converter.test.ts | 66 +++++++++++++++ .../core/src/extension/claude-converter.ts | 84 +++++++++++++++---- packages/core/src/extension/marketplace.ts | 28 ++++++- packages/core/src/extension/sourceRegistry.ts | 37 +++++++- 8 files changed, 261 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 9c6f8d9f79e..f5c7480c29d 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -150,8 +150,11 @@ export const DiscoverTab = ({ ); const handleReload = useCallback(() => { + // Ignore repeat presses while a refresh is in flight so rapid Ctrl+R + // doesn't stack concurrent network fetches across every marketplace. + if (loading) return; void load({ refresh: true }); - }, [load]); + }, [load, loading]); useEffect(() => { load(); @@ -209,9 +212,21 @@ export const DiscoverTab = ({ let installed = 0; const errors: string[] = []; for (const plugin of targets) { + let ext; try { const metadata = await parseInstallSource(plugin.installSource); - const ext = await extensionManager.installExtension(metadata); + ext = await extensionManager.installExtension(metadata); + } catch (error) { + errors.push( + `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, + ); + continue; + } + // The extension is installed on disk now. Recording the scope/enablement + // preference below is non-critical: a failure there must not flip a + // successful install to "failed" (which would prompt a confusing retry). + installed++; + try { extensionManager.setExtensionScope(ext.name, scope); // installExtension auto-enables at User (global) scope. For a // workspace-scoped choice, re-scope enablement to this workspace @@ -226,10 +241,10 @@ export const DiscoverTab = ({ SettingScope.Workspace, ); } - installed++; - } catch (error) { - errors.push( - `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, + } catch (scopeError) { + debugLogger.error( + 'Installed extension but failed to apply scope preference:', + scopeError, ); } } @@ -284,6 +299,22 @@ export const DiscoverTab = ({ }); return; } + // homepage comes from untrusted marketplace metadata; only follow web + // links. `open()` would otherwise launch file:// / other schemes in the + // OS default handler (e.g. file:///Users/victim/.ssh/id_rsa). + let protocol: string; + try { + protocol = new URL(plugin.homepage).protocol; + } catch { + protocol = ''; + } + if (protocol !== 'http:' && protocol !== 'https:') { + onStatus({ + type: 'error', + text: t('Failed to open {{url}}', { url: plugin.homepage }), + }); + return; + } try { await open(plugin.homepage); } catch { diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 4f73e41fb6c..4c84e1ad295 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -149,6 +149,18 @@ export const InstalledTab = ({ : undefined; const toolRegistry = hasAnyMcp ? config.getToolRegistry() : undefined; + // Count tools per server in a single pass; buildMcpInfo is called once per + // MCP server (top-level + extension children), so re-scanning getAllTools() + // inside it would be O(servers × tools). + const toolCountByServer = new Map(); + if (toolRegistry) { + for (const tool of toolRegistry.getAllTools()) { + const sn = (tool as { serverName?: string }).serverName; + if (sn) + toolCountByServer.set(sn, (toolCountByServer.get(sn) ?? 0) + 1); + } + } + // Servers that need (re-)authentication: either the connect attempt hit // a 401 (runtime signal), or OAuth is declared but no token is stored. // Connected servers are skipped — they are evidently authenticated. @@ -196,12 +208,7 @@ export const InstalledTab = ({ : serverConfig.url ? 'sse' : 'unknown', - toolCount: - toolRegistry - ?.getAllTools() - .filter( - (tool) => (tool as { serverName?: string }).serverName === name, - ).length ?? 0, + toolCount: toolCountByServer.get(name) ?? 0, }; }; @@ -384,6 +391,13 @@ export const InstalledTab = ({ } }, [displayRows, selectedItem, scrollOffset, visibleCount]); + // O(1) row→index lookup for render, instead of items.indexOf() per visible row. + const indexByKey = useMemo(() => { + const map = new Map(); + items.forEach((it, i) => map.set(it.key, i)); + return map; + }, [items]); + const goToList = useCallback(() => { setView('list'); onLockChange(false); @@ -708,7 +722,7 @@ export const InstalledTab = ({ ); } const item = row.item; - const globalIndex = items.indexOf(item); + const globalIndex = indexByKey.get(item.key) ?? -1; const isSelected = globalIndex === selectedIndex; const marker = isSelected ? '●' : ' '; const isChild = item.kind === 'mcp' && !!item.parentExtension; diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index 3282287611b..eaf18716f8b 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -22,6 +22,7 @@ import { createDebugLogger, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; +import { stripUnsafeCharacters } from '../../../utils/textUtils.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; const debugLogger = createDebugLogger('SOURCES_TAB'); @@ -499,7 +500,7 @@ export const SourcesTab = ({ - {detailSource.name} + {stripUnsafeCharacters(detailSource.name)} {redactUrlCredentials(detailSource.source)} @@ -528,7 +529,9 @@ export const SourcesTab = ({ {'●'} - {p.name} + + {stripUnsafeCharacters(p.name)} + ))} {installedHere.length > INSTALLED_PREVIEW_LIMIT ? ( diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index a2bf86f3b16..1cfe10cafe0 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -109,8 +109,11 @@ export const McpServerActionsView = ({ serverName, ); hasOAuthTokens = credentials !== null; - } catch { - // ignore token lookup failures + } catch (error) { + // A broken credential store (e.g. missing libsecret, locked keychain) + // leaves hasOAuthTokens false, which can mislabel an authenticated + // server as needing auth — log it so that's diagnosable. + debugLogger.warn('OAuth token lookup failed for', serverName, error); } return { diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index e27ad13ec19..f7b6f1ce7d0 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -736,6 +736,15 @@ describe('convertClaudePluginStandalone', () => { '# best practices', 'utf-8', ); + // A real git clone carries a .git directory; create one so the assertion + // below actually exercises the VCS-metadata stripping in the converter. + const gitDir = path.join(testDir, '.git'); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync( + path.join(gitDir, 'HEAD'), + 'ref: refs/heads/main', + 'utf-8', + ); const result = await convertClaudePluginStandalone(testDir); @@ -770,6 +779,63 @@ describe('convertClaudePluginStandalone', () => { /Plugin configuration not found/, ); }); + + it('ignores an absolute mcpServers path so it cannot read out-of-tree files', async () => { + // A hostile plugin.json points mcpServers at an absolute file outside the + // plugin. The converter must NOT read it (path-confinement guard). + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'secret-mcp.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ leaked: { command: 'cat', args: ['/etc/passwd'] } }), + 'utf-8', + ); + + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ + name: 'evil', + version: '1.0.0', + mcpServers: secretFile, + }), + 'utf-8', + ); + + const result = await convertClaudePluginStandalone(testDir); + // The absolute path was not read, so no servers were folded in. + expect(result.config.mcpServers?.['leaked']).toBeUndefined(); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + + it('skips a .mcp.json that has no mcpServers object instead of misparsing it', async () => { + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ name: 'no-servers', version: '1.0.0' }), + 'utf-8', + ); + // No `mcpServers` key — the whole object must not be treated as the map. + fs.writeFileSync( + path.join(testDir, '.mcp.json'), + JSON.stringify({ name: 'foo', other: 'bar' }), + 'utf-8', + ); + + const result = await convertClaudePluginStandalone(testDir); + expect(result.config.mcpServers).toBeUndefined(); + expect( + (result.config.mcpServers as Record | undefined)?.[ + 'name' + ], + ).toBeUndefined(); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); }); describe('performVariableReplacement for Claude extensions', () => { diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 18be9169c9c..ba5a9d835c5 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -485,6 +485,34 @@ export async function convertClaudePluginPackage( return buildQwenExtensionFromPlugin(pluginSource, mergedConfig); } +/** + * Resolves a plugin-relative file reference, refusing absolute paths or any + * path that escapes `pluginSource`. Plugin configs come from untrusted sources + * (arbitrary git repos / marketplaces), so an absolute or `../`-laden value + * could otherwise make the converter read sensitive files outside the plugin. + * Returns the confined absolute path, or null when the reference is unsafe. + */ +function resolvePluginRelativeFile( + pluginSource: string, + relativePath: string, +): string | null { + if (path.isAbsolute(relativePath)) { + debugLogger.warn( + `Ignoring absolute path "${relativePath}" in plugin config; only paths inside the plugin are allowed.`, + ); + return null; + } + const resolved = path.resolve(pluginSource, relativePath); + const base = path.resolve(pluginSource); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + debugLogger.warn( + `Ignoring path "${relativePath}" in plugin config; it escapes the plugin directory.`, + ); + return null; + } + return resolved; +} + /** * Builds a converted Qwen extension directory from a resolved Claude plugin * source directory and its merged config. Shared by the marketplace-based @@ -497,11 +525,12 @@ async function buildQwenExtensionFromPlugin( ): Promise<{ config: ExtensionConfig; convertedDir: string }> { // Resolve MCP servers from a JSON file path if needed. if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') { - const mcpServersPath = path.isAbsolute(mergedConfig.mcpServers) - ? mergedConfig.mcpServers - : path.join(pluginSource, mergedConfig.mcpServers); + const mcpServersPath = resolvePluginRelativeFile( + pluginSource, + mergedConfig.mcpServers, + ); - if (fs.existsSync(mcpServersPath)) { + if (mcpServersPath && fs.existsSync(mcpServersPath)) { try { const mcpContent = fs.readFileSync(mcpServersPath, 'utf-8'); mergedConfig.mcpServers = JSON.parse(mcpContent) as Record< @@ -555,11 +584,12 @@ async function buildQwenExtensionFromPlugin( // Handle hooks from a file path if needed. if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') { - const hooksPath = path.isAbsolute(mergedConfig.hooks) - ? mergedConfig.hooks - : path.join(pluginSource, mergedConfig.hooks); + const hooksPath = resolvePluginRelativeFile( + pluginSource, + mergedConfig.hooks, + ); - if (fs.existsSync(hooksPath)) { + if (hooksPath && fs.existsSync(hooksPath)) { try { const hooksContent = fs.readFileSync(hooksPath, 'utf-8'); const parsedHooks = JSON.parse(hooksContent); @@ -639,10 +669,20 @@ export async function convertClaudePluginStandalone( if (fs.existsSync(mcpJsonPath)) { try { const parsed = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')); - mergedConfig.mcpServers = (parsed?.mcpServers ?? parsed) as Record< - string, - MCPServerConfig - >; + if ( + parsed?.mcpServers && + typeof parsed.mcpServers === 'object' && + !Array.isArray(parsed.mcpServers) + ) { + mergedConfig.mcpServers = parsed.mcpServers as Record< + string, + MCPServerConfig + >; + } else { + debugLogger.warn( + `.mcp.json at ${mcpJsonPath} has no valid "mcpServers" object; skipping.`, + ); + } } catch (error) { debugLogger.warn( `Failed to parse .mcp.json at ${mcpJsonPath}: ${error instanceof Error ? error.message : String(error)}`, @@ -936,14 +976,28 @@ async function resolvePluginSource( const installMetadata: ExtensionInstallMetadata = { source: source.url, type: 'git', - ref: source.ref || source.sha, + // Prefer the immutable SHA pin when present; fall back to a named ref. + ref: source.sha || source.ref, originSource: 'Claude', }; await cloneFromGit(installMetadata, pluginDir); - const subDir = path.join(pluginDir, source.path); + // `source.path` comes from an untrusted manifest. Confine it to the cloned + // repo so a value like "../../.ssh" (or an absolute path) cannot escape. + if (!source.path || source.path === '.' || path.isAbsolute(source.path)) { + throw new Error( + `Invalid plugin subdirectory "${source.path}" for ${source.url}`, + ); + } + const subDir = path.resolve(pluginDir, source.path); + const repoRoot = path.resolve(pluginDir); + if (!subDir.startsWith(repoRoot + path.sep)) { + throw new Error( + `Plugin subdirectory "${source.path}" escapes the repository root of ${source.url}`, + ); + } if (!fs.existsSync(subDir)) { throw new Error( - `Plugin subdirectory "${source.path}" not found in ${source.url}`, + `Plugin subdirectory "${source.path}" not found in ${source.url} (ref: ${source.ref ?? source.sha ?? 'HEAD'})`, ); } return subDir; diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 44c83156e8c..d403a958758 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -122,9 +122,14 @@ function isGitUrl(source: string): boolean { /** Max time to wait for a single marketplace network request. */ const MARKETPLACE_FETCH_TIMEOUT_MS = 10000; +/** Max marketplace response body. A marketplace.json is tiny; this guards + * against a hostile source streaming unbounded data to exhaust memory. */ +const MARKETPLACE_MAX_BODY_BYTES = 10 * 1024 * 1024; + /** - * Fetch content from a URL. Resolves to null on non-200, error, or timeout so a - * slow/unreachable marketplace can never hang discovery indefinitely. + * Fetch content from a URL. Resolves to null on non-200, error, timeout, or + * oversized body so a slow/unreachable/hostile marketplace can never hang + * discovery indefinitely or exhaust process memory. */ function fetchUrl( url: string, @@ -135,8 +140,16 @@ function fetchUrl( const done = (value: string | null) => { if (settled) return; settled = true; + clearTimeout(hardDeadline); resolve(value); }; + // `req.setTimeout` only fires on socket inactivity and resets on every + // chunk, so a server trickling bytes can keep the request alive forever. + // Pair it with an absolute wall-clock deadline. + const hardDeadline = setTimeout(() => { + req.destroy(); + done(null); + }, MARKETPLACE_FETCH_TIMEOUT_MS); const req = https.get(url, { headers }, (res) => { if (res.statusCode !== 200) { res.resume(); // drain so the socket can be freed @@ -144,7 +157,16 @@ function fetchUrl( return; } const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); + let total = 0; + res.on('data', (chunk) => { + total += chunk.length; + if (total > MARKETPLACE_MAX_BODY_BYTES) { + req.destroy(); + done(null); + return; + } + chunks.push(chunk); + }); res.on('end', () => done(Buffer.concat(chunks).toString())); res.on('error', () => done(null)); }); diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 94f118a7ad4..7842f24ce53 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; @@ -161,6 +162,14 @@ function resolveInstallSource( } const src = plugin.source; if (typeof src === 'string') { + // A remote marketplace must not be able to point the installer at an + // arbitrary local filesystem path (e.g. "/opt/secret" or "../../etc"). + if (path.isAbsolute(src) || src.startsWith('.') || src.startsWith('~')) { + debugLogger.warn( + `Ignoring local path source "${src}" from remote marketplace "${marketplace.source}".`, + ); + return plugin.name; + } return src.includes(':') ? src : `${src}:${plugin.name}`; } if (src && src.source === 'github') { @@ -172,17 +181,37 @@ function resolveInstallSource( return plugin.name; } +// C0/C1 control chars left behind after escape-sequence stripping. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Strips terminal escape/control sequences from untrusted marketplace text. + * Plugin metadata is rendered in the TUI before the user consents to install, + * so a hostile source could otherwise embed ANSI/OSC sequences to move the + * cursor, clear lines, or spoof UI. Install resolution uses the raw plugin + * fields, so sanitizing the display copy here is safe. + */ + +function sanitizeDisplay(text: string): string; +function sanitizeDisplay(text: string | undefined): string | undefined; +function sanitizeDisplay(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + // Drop ANSI/VT escape sequences, then any remaining C0/C1 control chars. + return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, ''); +} + function pluginsFromConfig( marketplace: ExtensionSource, config: ClaudeMarketplaceConfig, installedNames: ReadonlySet, ): DiscoveredPlugin[] { return (config.plugins ?? []).map((plugin) => ({ - marketplaceName: config.name || marketplace.name, - name: plugin.name, - description: plugin.description, + marketplaceName: sanitizeDisplay(config.name || marketplace.name), + name: sanitizeDisplay(plugin.name), + description: sanitizeDisplay(plugin.description), version: plugin.version, - author: plugin.author?.name, + author: sanitizeDisplay(plugin.author?.name), homepage: plugin.homepage, category: plugin.category, lastUpdated: pluginLastUpdated(plugin), From 811e61bb2b600c4ad84d5397bfa524e2f39afed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 15 Jun 2026 16:44:27 +0800 Subject: [PATCH 35/48] fix(extensions): consent layout, per-extension update check, uninstall progress - ConsentPrompt: render the markdown prompt in a column. The inner Box defaulted to flexDirection=row, so a multi-paragraph consent (e.g. an extension bundling several MCP servers) tiled its blocks into narrow vertical columns. It now stacks vertically. - "Mark for Update" now checks only the selected extension via checkForExtensionUpdate (previously ran the full checkForAllExtensionUpdates with a discarded result), stores the result, and reports "update available" vs "already up to date" so the "Update Now" action shows up immediately. - Uninstall: show an "Uninstalling ..." line while removal runs so the confirm prompt no longer looks frozen after Enter. - i18n (en/zh/zh-TW): add the new strings; drop the now-unused "Checked ... for updates." key. --- packages/cli/src/i18n/locales/en.js | 4 +- packages/cli/src/i18n/locales/zh-TW.js | 4 +- packages/cli/src/i18n/locales/zh.js | 4 +- .../cli/src/ui/components/ConsentPrompt.tsx | 2 +- .../extensions/views/ExtensionActionsView.tsx | 38 +++++++++++++++++-- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 45a8786705a..a027fc4755c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -45,7 +45,9 @@ export default { 'Change scope': 'Change scope', 'Change scope for "{{name}}":': 'Change scope for "{{name}}":', 'Changing scope...': 'Changing scope...', - 'Checked "{{name}}" for updates.': 'Checked "{{name}}" for updates.', + 'Uninstalling "{{name}}"...': 'Uninstalling "{{name}}"...', + 'Update available for "{{name}}".': 'Update available for "{{name}}".', + '"{{name}}" is already up to date.': '"{{name}}" is already up to date.', 'Claude plugin marketplace': 'Claude plugin marketplace', Commands: 'Commands', 'Components:': 'Components:', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 587613bc4d3..11578293a9d 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -45,7 +45,9 @@ export default { 'Change scope': '變更作用域', 'Change scope for "{{name}}":': '變更 "{{name}}" 的作用域:', 'Changing scope...': '正在變更作用域...', - 'Checked "{{name}}" for updates.': '已檢查 "{{name}}" 的更新。', + 'Uninstalling "{{name}}"...': '正在卸載 "{{name}}"...', + 'Update available for "{{name}}".': '"{{name}}" 有可用更新。', + '"{{name}}" is already up to date.': '"{{name}}" 已是最新。', 'Claude plugin marketplace': 'Claude 外掛市場', Commands: '命令', 'Components:': '元件:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8347825027c..1b1c1ecf7bd 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -43,7 +43,9 @@ export default { 'Change scope': '更改作用域', 'Change scope for "{{name}}":': '更改 "{{name}}" 的作用域:', 'Changing scope...': '正在更改作用域...', - 'Checked "{{name}}" for updates.': '已检查 "{{name}}" 的更新。', + 'Uninstalling "{{name}}"...': '正在卸载 "{{name}}"...', + 'Update available for "{{name}}".': '"{{name}}" 有可用更新。', + '"{{name}}" is already up to date.': '"{{name}}" 已是最新。', 'Claude plugin marketplace': 'Claude 插件市场', Commands: '命令', 'Components:': '组件:', diff --git a/packages/cli/src/ui/components/ConsentPrompt.tsx b/packages/cli/src/ui/components/ConsentPrompt.tsx index c623fe9d9bb..0fe5aeb291c 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.tsx @@ -49,7 +49,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => { height={constrainedHeight} overflow="hidden" > - + {typeof prompt === 'string' ? ( (undefined); + const hasUpdate = + (checkedUpdateState ?? updateState) === + ExtensionUpdateState.UPDATE_AVAILABLE; const settingScopeFor = (s: ExtensionScope) => s === 'user' ? SettingScope.User : SettingScope.Workspace; @@ -131,13 +143,21 @@ export const ExtensionActionsView = ({ case 'change-scope': setSub('scope-select'); break; - case 'mark-update': - await manager.checkForAllExtensionUpdates(() => {}); + case 'mark-update': { + // Check only the selected extension (not every installed one), and + // surface the result so "Update Now" can appear right away. + const checked = await checkForExtensionUpdate(extension, manager); + setCheckedUpdateState(checked); + const updateAvailable = + (checked as string) === ExtensionUpdateState.UPDATE_AVAILABLE; onStatus({ type: 'info', - text: t('Checked "{{name}}" for updates.', { name }), + text: updateAvailable + ? t('Update available for "{{name}}".', { name }) + : t('"{{name}}" is already up to date.', { name }), }); break; + } case 'update': await manager.updateExtension( extension, @@ -199,6 +219,7 @@ export const ExtensionActionsView = ({ const handleUninstall = useCallback( async (ext: Extension) => { if (!manager) return; + setUninstallBusy(true); try { await manager.uninstallExtension(ext.name, false); onStatus({ @@ -208,6 +229,8 @@ export const ExtensionActionsView = ({ onReload(); } catch (error) { onStatus({ type: 'error', text: getErrorMessage(error) }); + } finally { + setUninstallBusy(false); } onExit(); }, @@ -260,6 +283,13 @@ export const ExtensionActionsView = ({ } if (sub === 'uninstall-confirm') { + if (uninstallBusy) { + return ( + + {t('Uninstalling "{{name}}"...', { name: extension.name })} + + ); + } return ( Date: Mon, 15 Jun 2026 17:06:53 +0800 Subject: [PATCH 36/48] feat(extensions): clearer update-check feedback for "Mark for Update" Distinguish the four check outcomes instead of collapsing everything to "up to date": update-available, up-to-date, not-updatable, and error. For not-updatable Claude marketplace plugins, spell out the reason and workaround (they are install-time conversions with no git remote, so they update by reinstalling). Also show a "Checking ... for updates..." line first, since git/github-release/npm checks hit the network. --- packages/cli/src/i18n/locales/en.js | 7 +++ packages/cli/src/i18n/locales/zh-TW.js | 5 ++ packages/cli/src/i18n/locales/zh.js | 5 ++ .../extensions/views/ExtensionActionsView.tsx | 47 +++++++++++++++---- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index a027fc4755c..e0396a3254b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -48,6 +48,13 @@ export default { 'Uninstalling "{{name}}"...': 'Uninstalling "{{name}}"...', 'Update available for "{{name}}".': 'Update available for "{{name}}".', '"{{name}}" is already up to date.': '"{{name}}" is already up to date.', + 'Checking "{{name}}" for updates...': 'Checking "{{name}}" for updates...', + '"{{name}}" does not support update checks.': + '"{{name}}" does not support update checks.', + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).': + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).', + 'Failed to check "{{name}}" for updates.': + 'Failed to check "{{name}}" for updates.', 'Claude plugin marketplace': 'Claude plugin marketplace', Commands: 'Commands', 'Components:': 'Components:', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 11578293a9d..55b90f2408b 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -48,6 +48,11 @@ export default { 'Uninstalling "{{name}}"...': '正在卸載 "{{name}}"...', 'Update available for "{{name}}".': '"{{name}}" 有可用更新。', '"{{name}}" is already up to date.': '"{{name}}" 已是最新。', + 'Checking "{{name}}" for updates...': '正在檢查 "{{name}}" 的更新...', + '"{{name}}" does not support update checks.': '"{{name}}" 不支援檢查更新。', + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).': + '"{{name}}" 無法檢查更新(Claude 市場源插件需卸載後重裝來更新)。', + 'Failed to check "{{name}}" for updates.': '檢查 "{{name}}" 的更新失敗。', 'Claude plugin marketplace': 'Claude 外掛市場', Commands: '命令', 'Components:': '元件:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 1b1c1ecf7bd..4029029ed43 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -46,6 +46,11 @@ export default { 'Uninstalling "{{name}}"...': '正在卸载 "{{name}}"...', 'Update available for "{{name}}".': '"{{name}}" 有可用更新。', '"{{name}}" is already up to date.': '"{{name}}" 已是最新。', + 'Checking "{{name}}" for updates...': '正在检查 "{{name}}" 的更新...', + '"{{name}}" does not support update checks.': '"{{name}}" 不支持检查更新。', + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).': + '"{{name}}" 无法检查更新(Claude 市场源插件需卸载后重装来更新)。', + 'Failed to check "{{name}}" for updates.': '检查 "{{name}}" 的更新失败。', 'Claude plugin marketplace': 'Claude 插件市场', Commands: '命令', 'Components:': '组件:', diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index 706158e2018..0901d83f1e1 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -145,17 +145,48 @@ export const ExtensionActionsView = ({ break; case 'mark-update': { // Check only the selected extension (not every installed one), and - // surface the result so "Update Now" can appear right away. - const checked = await checkForExtensionUpdate(extension, manager); - setCheckedUpdateState(checked); - const updateAvailable = - (checked as string) === ExtensionUpdateState.UPDATE_AVAILABLE; + // surface the result so "Update Now" can appear right away. Git / + // GitHub-release / npm checks hit the network, so show a pending + // line first; other types resolve instantly. onStatus({ type: 'info', - text: updateAvailable - ? t('Update available for "{{name}}".', { name }) - : t('"{{name}}" is already up to date.', { name }), + text: t('Checking "{{name}}" for updates...', { name }), }); + const checked = (await checkForExtensionUpdate( + extension, + manager, + )) as string; + setCheckedUpdateState(checked); + if (checked === ExtensionUpdateState.UPDATE_AVAILABLE) { + onStatus({ + type: 'info', + text: t('Update available for "{{name}}".', { name }), + }); + } else if (checked === ExtensionUpdateState.ERROR) { + onStatus({ + type: 'error', + text: t('Failed to check "{{name}}" for updates.', { name }), + }); + } else if (checked === ExtensionUpdateState.NOT_UPDATABLE) { + onStatus({ + type: 'info', + // Claude marketplace plugins are install-time conversions with + // no git remote, so there's nothing to diff against — spell out + // the reason and the workaround. + text: + extension.installMetadata?.originSource === 'Claude' + ? t( + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).', + { name }, + ) + : t('"{{name}}" does not support update checks.', { name }), + }); + } else { + onStatus({ + type: 'info', + text: t('"{{name}}" is already up to date.', { name }), + }); + } break; } case 'update': From 34f8252be4658d0225ed4fa4605275c35b814eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 15 Jun 2026 18:58:11 +0800 Subject: [PATCH 37/48] fix(extensions): confine resource/source paths, sanitize homepage (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-converter: route commands/skills/agents resource paths (collectResources) and the relative string plugin-source through path-confinement — reject absolute / ..-escaping values — matching the existing mcpServers/hooks guard. - sourceRegistry: sanitize plugin.homepage like the other untrusted marketplace display fields (it otherwise reaches status toasts unsanitized). - InstalledTab: construct MCPOAuthTokenStorage once instead of once per server. --- .../extensions/tabs/InstalledTab.tsx | 3 ++- .../core/src/extension/claude-converter.ts | 20 +++++++++++++++---- packages/core/src/extension/sourceRegistry.ts | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 4c84e1ad295..7f1030eca30 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -165,6 +165,7 @@ export const InstalledTab = ({ // a 401 (runtime signal), or OAuth is declared but no token is stored. // Connected servers are skipped — they are evidently authenticated. const needsAuthNames = new Set(); + const tokenStorage = new MCPOAuthTokenStorage(); for (const [name, sc] of Object.entries(mcpServers)) { if (getMCPServerStatus(name) === MCPServerStatus.CONNECTED) continue; if (mcpServerRequiresOAuth.get(name)) { @@ -173,7 +174,7 @@ export const InstalledTab = ({ } if (sc.oauth?.enabled) { try { - const creds = await new MCPOAuthTokenStorage().getCredentials(name); + const creds = await tokenStorage.getCredentials(name); if (!creds) needsAuthNames.add(name); } catch { needsAuthNames.add(name); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index ba5a9d835c5..651881b02d7 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -719,9 +719,10 @@ async function collectResources( const destFolderName = path.basename(destDir); for (const resourcePath of paths) { - const resolvedPath = path.isAbsolute(resourcePath) - ? resourcePath - : path.join(pluginRoot, resourcePath); + // Resource paths come from an untrusted manifest; confine them to the + // plugin so a value like "/etc/ssh" or "../../secrets" can't be copied in. + const resolvedPath = resolvePluginRelativeFile(pluginRoot, resourcePath); + if (!resolvedPath) continue; if (!fs.existsSync(resolvedPath)) { debugLogger.warn(`Resource path not found: ${resolvedPath}`); @@ -924,9 +925,20 @@ async function resolvePluginSource( return pluginDir; } - // Relative path within marketplace + // Relative path within marketplace. Confine it: a manifest source like + // "../../../../etc/ssh" must not resolve outside the marketplace dir. const pluginRoot = marketplaceDir; const sourcePath = path.join(pluginRoot, source); + const resolvedSource = path.resolve(sourcePath); + const marketplaceBase = path.resolve(marketplaceDir); + if ( + resolvedSource !== marketplaceBase && + !resolvedSource.startsWith(marketplaceBase + path.sep) + ) { + throw new Error( + `Plugin source "${source}" escapes the marketplace directory`, + ); + } if (!fs.existsSync(sourcePath)) { throw new Error(`Plugin source not found at ${sourcePath}`); diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 7842f24ce53..b44097a238b 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -212,7 +212,7 @@ function pluginsFromConfig( description: sanitizeDisplay(plugin.description), version: plugin.version, author: sanitizeDisplay(plugin.author?.name), - homepage: plugin.homepage, + homepage: sanitizeDisplay(plugin.homepage), category: plugin.category, lastUpdated: pluginLastUpdated(plugin), installs: pluginInstalls(plugin), From b050fe051820dc322676041475b6114756660970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 15 Jun 2026 21:43:23 +0800 Subject: [PATCH 38/48] fix(extensions): confine symlink targets when converting untrusted plugins A plugin source from an untrusted marketplace/git repo can embed a symlink whose name stays inside the package but whose target points at a host file (e.g. skills/leak.txt -> ~/.ssh/id_rsa). The path-confinement checks were purely lexical (path.resolve), while the downstream copy/read calls follow symlinks, so the target's content could be shipped into the installed extension. - copyDirectory: pin the package real-path root and skip symlinks whose resolved target escapes it (this is the bulk-copy vector at buildQwenExtensionFromPlugin). - resolvePluginRelativeFile: re-verify the real path with realpathSync after the lexical check (covers mcpServers/hooks/single-file resources). - collectResources: skip symlinked files in a collected folder whose target escapes the resource dir. - resolvePluginSource: reject a string source that resolves outside the marketplace dir through a symlink. Adds regression tests for the bulk-copy and collected-folder paths. --- .../src/extension/claude-converter.test.ts | 80 +++++++++++++++++++ .../core/src/extension/claude-converter.ts | 49 ++++++++++++ .../core/src/extension/gemini-converter.ts | 28 ++++++- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index f7b6f1ce7d0..3dd19a07bc3 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -283,6 +283,51 @@ describe('convertClaudePluginPackage', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('skips a symlink inside a collected resource folder that escapes the plugin', async () => { + const pluginSourceDir = path.join(testDir, 'plugin-symlink'); + const skillDir = path.join(pluginSourceDir, 'skills', 'mine'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# mine', 'utf-8'); + + // A host file outside the plugin, reachable via a symlink whose name stays + // inside the collected folder. collectResources must not copy its content. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'id_rsa'); + fs.writeFileSync(secretFile, 'TOP SECRET', 'utf-8'); + fs.symlinkSync(secretFile, path.join(skillDir, 'leak.txt')); + + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'leaky', + version: '1.0.0', + description: 'Leaky plugin', + source: './', + strict: false, + skills: ['./skills/mine'], + }, + ], + }; + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + const result = await convertClaudePluginPackage(pluginSourceDir, 'leaky'); + + const dest = path.join(result.convertedDir, 'skills', 'mine'); + expect(fs.existsSync(path.join(dest, 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(dest, 'leak.txt'))).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + it('should use all skills from folder when config does not specify skills', async () => { // Setup: Create a plugin source with skills but no skills config const pluginSourceDir = path.join(testDir, 'plugin-source-default'); @@ -811,6 +856,41 @@ describe('convertClaudePluginStandalone', () => { fs.rmSync(secretDir, { recursive: true, force: true }); }); + it('does not copy a symlink whose target escapes the plugin directory', async () => { + // git preserves symlinks, so a hostile repo can embed one pointing at a + // host file. The bulk copy dereferences symlinks; without confinement the + // target's content would be shipped inside the converted extension. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'id_rsa'); + fs.writeFileSync(secretFile, 'TOP SECRET KEY', 'utf-8'); + + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ name: 'evil', version: '1.0.0' }), + 'utf-8', + ); + const skillsDir = path.join(testDir, 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '# ok', 'utf-8'); + // A symlink whose name stays inside the package but points outside it. + fs.symlinkSync(secretFile, path.join(skillsDir, 'leak.txt')); + + const result = await convertClaudePluginStandalone(testDir); + + // The legitimate file is copied; the escaping symlink is dropped. + expect( + fs.existsSync(path.join(result.convertedDir, 'skills', 'SKILL.md')), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'skills', 'leak.txt')), + ).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + it('skips a .mcp.json that has no mcpServers object instead of misparsing it', async () => { const pluginDir = path.join(testDir, '.claude-plugin'); fs.mkdirSync(pluginDir, { recursive: true }); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 651881b02d7..fd805e8925a 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -510,9 +510,37 @@ function resolvePluginRelativeFile( ); return null; } + // The lexical check above is purely string-based; a symlink whose name stays + // inside the plugin can still point its target outside it (e.g. + // `skills/leak.txt -> ~/.ssh/id_rsa`). Downstream reads/copies follow + // symlinks, so re-verify the real path when the target exists. + if (fs.existsSync(resolved) && !realPathWithin(resolved, pluginSource)) { + debugLogger.warn( + `Ignoring path "${relativePath}" in plugin config; it resolves through a symlink outside the plugin directory.`, + ); + return null; + } return resolved; } +/** + * True when `target` exists and its real (symlink-resolved) path stays within + * `root`'s real path. Both sides are resolved with `fs.realpathSync` so a + * symlink inside an untrusted plugin source cannot point the converter at a + * file outside the package. Returns false for missing/broken paths. + */ +function realPathWithin(target: string, root: string): boolean { + try { + const realTarget = fs.realpathSync(target); + const realRoot = fs.realpathSync(root); + return ( + realTarget === realRoot || realTarget.startsWith(realRoot + path.sep) + ); + } catch { + return false; + } +} + /** * Builds a converted Qwen extension directory from a resolved Claude plugin * source directory and its merged config. Shared by the marketplace-based @@ -765,6 +793,19 @@ async function collectResources( // Check if the source is a regular file (skip sockets, FIFOs, directories behind symlinks, etc.) try { + // A symlink inside the resource folder can point its target outside + // the plugin; statSync would follow it and copy the host file. Skip + // any symlink whose real target escapes the resource directory. + const fileLstat = fs.lstatSync(srcFile); + if ( + fileLstat.isSymbolicLink() && + !realPathWithin(srcFile, resolvedPath) + ) { + debugLogger.warn( + `Skipping symlink that escapes the plugin: ${srcFile}`, + ); + continue; + } const fileStat = fs.statSync(srcFile); if (!fileStat.isFile()) { debugLogger.debug(`Skipping non-regular file: ${srcFile}`); @@ -944,6 +985,14 @@ async function resolvePluginSource( throw new Error(`Plugin source not found at ${sourcePath}`); } + // The lexical check is string-only; reject a source that reaches outside + // the marketplace dir through a symlink before copying it in. + if (!realPathWithin(sourcePath, marketplaceDir)) { + throw new Error( + `Plugin source "${source}" resolves through a symlink outside the marketplace directory`, + ); + } + // If source path equals marketplace dir (source is '.' or ''), // return marketplaceDir directly to avoid copying to subdirectory of self if (path.resolve(sourcePath) === path.resolve(marketplaceDir)) { diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index b5461369e37..b7f331537c6 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -116,12 +116,27 @@ export async function convertGeminiExtensionPackage( export async function copyDirectory( source: string, destination: string, + confineRoot?: string, ): Promise { // Create destination directory if it doesn't exist if (!fs.existsSync(destination)) { fs.mkdirSync(destination, { recursive: true }); } + // Symlinks in an (untrusted) source are dereferenced and their *target* + // content is copied below, so a link escaping the package — e.g. + // `skills/leak.txt -> ~/.ssh/id_rsa` — would otherwise pull host files into + // the output. Pin a confinement root (the package's real path) on the first + // call and thread it through recursion to reject escaping symlink targets. + let root = confineRoot; + if (root === undefined) { + try { + root = fs.realpathSync(source); + } catch { + root = path.resolve(source); + } + } + const entries = fs.readdirSync(source, { withFileTypes: true }); for (const entry of entries) { @@ -129,14 +144,21 @@ export async function copyDirectory( const destPath = path.join(destination, entry.name); if (entry.isDirectory()) { - await copyDirectory(sourcePath, destPath); + await copyDirectory(sourcePath, destPath, root); } else if (entry.isSymbolicLink()) { - // Resolve symlink and copy the target content + // Resolve symlink and copy the target content, but only when the target + // stays inside the package root. try { const realPath = fs.realpathSync(sourcePath); + if (realPath !== root && !realPath.startsWith(root + path.sep)) { + debugLogger.warn( + `Skipping symlink that escapes the package: ${sourcePath} -> ${realPath}`, + ); + continue; + } const targetStat = fs.statSync(realPath); if (targetStat.isDirectory()) { - await copyDirectory(realPath, destPath); + await copyDirectory(realPath, destPath, root); } else if (targetStat.isFile()) { fs.copyFileSync(realPath, destPath); } From 1f302acf775c8715198955ff4294510ced7ecc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 16 Jun 2026 10:20:48 +0800 Subject: [PATCH 39/48] fix(extensions): confine manifest reads, share path-containment helpers Follow-up to the symlink confinement work (review round 3): - Guard the three manifest reads that bypassed the new checks: a hostile clone could make marketplace.json / plugin.json / .mcp.json themselves symlinks to JSON-shaped host files (e.g. ~/.docker/config.json), whose content was parsed into the merged config. Each read now verifies the real path stays inside the package before reading; .mcp.json is treated as absent, plugin.json/marketplace.json throw. - Hoist the containment logic into gemini-converter as exported isPathWithin (lexical) + realPathWithin (symlink-resolved) so the rule lives in one place instead of being duplicated across both converters. - Document copyDirectory's confineRoot parameter in its JSDoc. Adds targeted tests for the resolvePluginRelativeFile and resolvePluginSource symlink-rejection branches and the plugin.json manifest guard. --- .../src/extension/claude-converter.test.ts | 94 +++++++++++++++++++ .../core/src/extension/claude-converter.ts | 57 ++++++----- .../core/src/extension/gemini-converter.ts | 28 +++++- 3 files changed, 156 insertions(+), 23 deletions(-) diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 3dd19a07bc3..0133df53f8b 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -328,6 +328,43 @@ describe('convertClaudePluginPackage', () => { fs.rmSync(secretDir, { recursive: true, force: true }); }); + it('throws when a marketplace source is a symlink resolving outside the marketplace dir', async () => { + // A host directory reachable via a symlink whose relative name stays inside + // the marketplace dir. resolvePluginSource must reject it before copying. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + fs.writeFileSync(path.join(secretDir, 'SKILL.md'), 'secret', 'utf-8'); + + const pluginSourceDir = path.join(testDir, 'plugin-evil-source'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.symlinkSync(secretDir, path.join(pluginSourceDir, 'evil-link')); + + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'evil', + version: '1.0.0', + description: 'Evil plugin', + source: './evil-link', + strict: false, + }, + ], + }; + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + await expect( + convertClaudePluginPackage(pluginSourceDir, 'evil'), + ).rejects.toThrow(/resolves through a symlink outside the marketplace/); + + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + it('should use all skills from folder when config does not specify skills', async () => { // Setup: Create a plugin source with skills but no skills config const pluginSourceDir = path.join(testDir, 'plugin-source-default'); @@ -856,6 +893,63 @@ describe('convertClaudePluginStandalone', () => { fs.rmSync(secretDir, { recursive: true, force: true }); }); + it('throws when plugin.json is a symlink resolving outside the plugin', async () => { + // A hostile clone makes the manifest itself a symlink to a JSON-shaped host + // file. The converter must refuse to follow it rather than read the target. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'config.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ name: 'leaked', version: '9.9.9' }), + 'utf-8', + ); + + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.symlinkSync(secretFile, path.join(pluginDir, 'plugin.json')); + + await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow( + /resolves through a symlink outside/, + ); + + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + + it('does not load mcpServers from a relative path that is a symlink escaping the plugin', async () => { + // mcpServers is a relative path whose name stays inside the plugin, but the + // file is a symlink to a host secret. resolvePluginRelativeFile must reject + // it so the target is never read into the config. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'servers.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ leaked: { command: 'cat', args: ['/etc/passwd'] } }), + 'utf-8', + ); + + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ + name: 'evil', + version: '1.0.0', + mcpServers: './servers.json', + }), + 'utf-8', + ); + fs.symlinkSync(secretFile, path.join(testDir, 'servers.json')); + + const result = await convertClaudePluginStandalone(testDir); + const servers = result.config.mcpServers as + | Record + | undefined; + expect(servers?.['leaked']).toBeUndefined(); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + it('does not copy a symlink whose target escapes the plugin directory', async () => { // git preserves symlinks, so a hostile repo can embed one pointing at a // host file. The bulk copy dereferences symlinks; without confinement the diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index fd805e8925a..56d6cc2eccd 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -19,7 +19,11 @@ import type { import type { HookEventName, HookDefinition } from '../hooks/types.js'; import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; import { createHash } from 'node:crypto'; -import { copyDirectory } from './gemini-converter.js'; +import { + copyDirectory, + isPathWithin, + realPathWithin, +} from './gemini-converter.js'; import { parse as parseYaml, stringify as stringifyYaml, @@ -432,6 +436,13 @@ export async function convertClaudePluginPackage( `Marketplace configuration not found at ${marketplaceJsonPath}`, ); } + // The manifest itself can be a symlink in an untrusted clone; refuse to read + // it when it resolves outside the plugin (would leak a JSON-shaped host file). + if (!realPathWithin(marketplaceJsonPath, extensionDir)) { + throw new Error( + `Marketplace configuration at ${marketplaceJsonPath} resolves through a symlink outside the plugin`, + ); + } const marketplaceContent = fs.readFileSync(marketplaceJsonPath, 'utf-8'); const marketplaceConfig: ClaudeMarketplaceConfig = @@ -474,11 +485,21 @@ export async function convertClaudePluginPackage( if (strict && !fs.existsSync(pluginJsonPath)) { throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`); } - if (fs.existsSync(pluginJsonPath)) { + // Treat a symlinked plugin.json (pointing outside the source) as absent + // rather than reading an arbitrary host file into the merged config. + const pluginJsonSafe = + fs.existsSync(pluginJsonPath) && + realPathWithin(pluginJsonPath, pluginSource); + if (pluginJsonSafe) { const pluginContent = fs.readFileSync(pluginJsonPath, 'utf-8'); const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent); mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig); } else { + if (fs.existsSync(pluginJsonPath)) { + debugLogger.warn( + `Ignoring plugin.json at ${pluginJsonPath}; it resolves through a symlink outside the plugin.`, + ); + } mergedConfig = marketplacePlugin as ClaudePluginConfig; } @@ -504,7 +525,7 @@ function resolvePluginRelativeFile( } const resolved = path.resolve(pluginSource, relativePath); const base = path.resolve(pluginSource); - if (resolved !== base && !resolved.startsWith(base + path.sep)) { + if (!isPathWithin(resolved, base)) { debugLogger.warn( `Ignoring path "${relativePath}" in plugin config; it escapes the plugin directory.`, ); @@ -523,24 +544,6 @@ function resolvePluginRelativeFile( return resolved; } -/** - * True when `target` exists and its real (symlink-resolved) path stays within - * `root`'s real path. Both sides are resolved with `fs.realpathSync` so a - * symlink inside an untrusted plugin source cannot point the converter at a - * file outside the package. Returns false for missing/broken paths. - */ -function realPathWithin(target: string, root: string): boolean { - try { - const realTarget = fs.realpathSync(target); - const realRoot = fs.realpathSync(root); - return ( - realTarget === realRoot || realTarget.startsWith(realRoot + path.sep) - ); - } catch { - return false; - } -} - /** * Builds a converted Qwen extension directory from a resolved Claude plugin * source directory and its merged config. Shared by the marketplace-based @@ -687,6 +690,13 @@ export async function convertClaudePluginStandalone( if (!fs.existsSync(pluginJsonPath)) { throw new Error(`Plugin configuration not found at ${pluginJsonPath}`); } + // The manifest may be a symlink in an untrusted clone; refuse to follow it + // outside the package (would read an arbitrary JSON-shaped host file). + if (!realPathWithin(pluginJsonPath, extensionDir)) { + throw new Error( + `Plugin configuration at ${pluginJsonPath} resolves through a symlink outside the plugin`, + ); + } const mergedConfig: ClaudePluginConfig = JSON.parse( fs.readFileSync(pluginJsonPath, 'utf-8'), @@ -694,7 +704,10 @@ export async function convertClaudePluginStandalone( if (!mergedConfig.mcpServers) { const mcpJsonPath = path.join(extensionDir, '.mcp.json'); - if (fs.existsSync(mcpJsonPath)) { + if ( + fs.existsSync(mcpJsonPath) && + realPathWithin(mcpJsonPath, extensionDir) + ) { try { const parsed = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')); if ( diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index b7f331537c6..a7c266d4460 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -108,10 +108,36 @@ export async function convertGeminiExtensionPackage( } } +/** + * True when `child` equals or is nested under `parent`. Both must already be + * absolute, resolved paths. Shared containment primitive for the symlink + * confinement guards (kept in one place so the rule can't drift between files). + */ +export function isPathWithin(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent + path.sep); +} + +/** + * True when `target` exists and its real (symlink-resolved) path stays within + * `root`'s real path. Both sides are resolved with `fs.realpathSync` so a + * symlink in an untrusted source cannot point a read/copy at a file outside + * the package. Returns false for missing or broken paths. + */ +export function realPathWithin(target: string, root: string): boolean { + try { + return isPathWithin(fs.realpathSync(target), fs.realpathSync(root)); + } catch { + return false; + } +} + /** * Recursively copies a directory and its contents. * @param source Source directory path * @param destination Destination directory path + * @param confineRoot If set, any symlink whose real target escapes this + * directory is skipped. Defaults to `fs.realpathSync(source)` when omitted. + * Always pass this explicitly when `source` originates from untrusted input. */ export async function copyDirectory( source: string, @@ -150,7 +176,7 @@ export async function copyDirectory( // stays inside the package root. try { const realPath = fs.realpathSync(sourcePath); - if (realPath !== root && !realPath.startsWith(root + path.sep)) { + if (!isPathWithin(realPath, root)) { debugLogger.warn( `Skipping symlink that escapes the package: ${sourcePath} -> ${realPath}`, ); From 7199a173218a5a2d10e05593adbfe871a265bfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 16 Jun 2026 15:54:57 +0800 Subject: [PATCH 40/48] fix(extensions): cap manager dialog width to the main content area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a wide terminal the /extensions manager clipped its right-aligned status column to a sliver (e.g. "扩…"). Root cause: the dialog sized itself to boxWidth = columns - 4 with no cap, while the app's main content area is capped at 100 columns (AppContainer's mainAreaWidth). Past ~104 columns the dialog grew wider than its container and the right edge — including the status column flexGrow pushes to the far right — was clipped off-screen. Narrow terminals were unaffected because columns - 4 stayed within the cap. Cap boxWidth at Math.min(columns - 4, 100) to match the main content area, mirroring the existing DiffDialog idiom. Adds a wide-terminal regression test that renders through a 200-column stdout and asserts no line exceeds the content area (the uncapped dialog produced ~196-col lines). --- .../ExtensionsManagerDialog.test.tsx | 100 ++++++++++++++++++ .../extensions/ExtensionsManagerDialog.tsx | 5 +- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 7cd89068a65..242106b5528 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -5,6 +5,8 @@ */ import { render } from 'ink-testing-library'; +import { render as inkRender } from 'ink'; +import { EventEmitter } from 'node:events'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { waitFor } from '@testing-library/react'; import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; @@ -140,6 +142,63 @@ const renderDialog = ( , ); +const stripAnsi = (s: string): string => + // eslint-disable-next-line no-control-regex + s.replace(/\u001b\[[0-9;]*m/g, ''); + +// ink-testing-library hard-codes an 80/100-column buffer, which is too narrow +// to reproduce wide-terminal layout bugs. Render through ink directly with a +// custom wide stdout so the dialog lays out at the requested width. +const renderWide = (config: Config, columns: number) => { + let lastFrame = ''; + const stdout = Object.assign(new EventEmitter(), { + columns, + rows: 50, + write: (frame: string) => { + lastFrame = frame; + }, + }); + const stderr = Object.assign(new EventEmitter(), { + columns, + rows: 50, + write: () => {}, + }); + // A TTY-like stdin so KeypressProvider can enable raw mode (ink-testing- + // library supplies one; ink's real render against a custom stdout does not). + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setRawMode: () => {}, + setEncoding: () => {}, + resume: () => {}, + pause: () => {}, + ref: () => {}, + unref: () => {}, + read: () => null, + }); + const instance = inkRender( + + + + + + + + + , + { + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + // debug:true makes ink write the full frame synchronously (as + // ink-testing-library does) instead of throttled cursor-diff output. + debug: true, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + return { lastFrame: () => lastFrame, unmount: instance.unmount }; +}; + describe('ExtensionsManagerDialog (tabbed)', () => { beforeEach(() => { vi.clearAllMocks(); @@ -153,6 +212,47 @@ describe('ExtensionsManagerDialog (tabbed)', () => { expect(frame).toContain('Sources'); }); + it('caps its width on a wide terminal so the status column is not clipped', async () => { + // Regression: the dialog computed boxWidth = columns - 4 with no cap, while + // the app's main content area is capped at 100 cols (AppContainer). On a + // wide terminal the dialog overflowed its container and the right-aligned + // status column ("Extension v… (…)") was clipped off-screen to a sliver + // ("扩…"). The dialog must stay within ~100 columns regardless of terminal + // width. Rendered through a 200-column stdout to exercise the wide case. + const original = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); + Object.defineProperty(process.stdout, 'columns', { + value: 200, + configurable: true, + }); + let unmount: (() => void) | undefined; + try { + const r = renderWide( + createConfig( + createManager({ + extensions: [mockExtension('alpha'), mockExtension('beta', false)], + }), + ), + 200, + ); + unmount = r.unmount; + await waitFor(() => { + expect(stripAnsi(r.lastFrame())).toContain('alpha'); + }); + const frame = stripAnsi(r.lastFrame()); + // No rendered line spills past the ~100-col content area (the uncapped + // dialog produced ~196-col lines and clipped the status column). + const widest = Math.max(...frame.split('\n').map((line) => line.length)); + expect(widest).toBeLessThanOrEqual(102); + // And the status column is fully present, not truncated to a sliver. + expect(frame).toMatch(/v1\.0\.0\s*\([^)]+\)/); + } finally { + unmount?.(); + if (original) { + Object.defineProperty(process.stdout, 'columns', original); + } + } + }); + it('shows discovered plugins on the Discover tab', async () => { const discovered: DiscoveredPlugin[] = [ { diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 650d9e8ef6d..0aa19c12944 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -66,7 +66,10 @@ export function ExtensionsManagerDialog({ pluginChoiceRequests, } = useUIState(); const { columns } = useTerminalSize(); - const boxWidth = columns - 4; + // Cap the width to the app's main content area (AppContainer caps it at 100). + // Without this the dialog grows to the full terminal width on wide terminals + // and overflows its container, clipping the right-aligned status column. + const boxWidth = Math.min(columns - 4, 100); // Install flows raise interactive requests (consent, setting input, plugin // choice). They are rendered here, inside the dialog, so the dialog stays From cd5a11e20cb1fe1f443815e0b7a62328cca92cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Tue, 16 Jun 2026 17:14:10 +0800 Subject: [PATCH 41/48] fix(diff): cap /diff dialog width to the main content area Same wide-terminal clipping as the extensions manager: DiffDialog sized itself to Math.min(columns - 4, 110), but the app's main content area is capped at 100 cols (AppContainer's mainAreaWidth). On a wide terminal the dialog grew to 110 columns inside the 100-column container and its right border/edge was clipped off-screen. Narrow terminals were unaffected because columns - 4 stayed within the cap. Cap dialogWidth at Math.min(columns - 4, 100) to match the container. Adds a wide-terminal regression test (200-column stdout) asserting no rendered line exceeds the content area. --- .../cli/src/ui/components/DiffDialog.test.tsx | 109 ++++++++++++++++++ packages/cli/src/ui/components/DiffDialog.tsx | 5 +- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/components/DiffDialog.test.tsx diff --git a/packages/cli/src/ui/components/DiffDialog.test.tsx b/packages/cli/src/ui/components/DiffDialog.test.tsx new file mode 100644 index 00000000000..32ae62a0b16 --- /dev/null +++ b/packages/cli/src/ui/components/DiffDialog.test.tsx @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render as inkRender } from 'ink'; +import { EventEmitter } from 'node:events'; +import { describe, it, expect, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { DiffDialog } from './DiffDialog.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { ShellFocusContext } from '../contexts/ShellFocusContext.js'; + +// Keep the dialog hermetic: a clean working tree and no turn diffs, matching +// the "Working tree is clean." state, so no git/filesystem access is needed. +vi.mock('../hooks/useDiffData.js', () => ({ + useDiffData: () => ({ result: null, hunks: new Map(), loading: false }), +})); +vi.mock('../hooks/useTurnDiffs.js', () => ({ + useTurnDiffs: () => ({ turns: [], loading: false }), +})); + +const stripAnsi = (s: string): string => + // eslint-disable-next-line no-control-regex + s.replace(/\u001b\[[0-9;]*m/g, ''); + +// ink-testing-library hard-codes a 100-column buffer, too narrow to reproduce +// wide-terminal layout bugs. Render through ink directly with a custom wide +// stdout so the dialog lays out at the requested width. +const renderWide = (columns: number) => { + let lastFrame = ''; + const stdout = Object.assign(new EventEmitter(), { + columns, + rows: 50, + write: (frame: string) => { + lastFrame = frame; + }, + }); + const stderr = Object.assign(new EventEmitter(), { + columns, + rows: 50, + write: () => {}, + }); + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setRawMode: () => {}, + setEncoding: () => {}, + resume: () => {}, + pause: () => {}, + ref: () => {}, + unref: () => {}, + read: () => null, + }); + const instance = inkRender( + + + + + , + { + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + // debug:true writes the full frame synchronously (true widths) instead of + // throttled cursor-diff output, so line widths can be measured. + debug: true, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + return { lastFrame: () => lastFrame, unmount: instance.unmount }; +}; + +describe('DiffDialog', () => { + it('caps its width on a wide terminal so the right border is not clipped', async () => { + // Regression: dialogWidth was Math.min(columns - 4, 110), but the app's + // main content area is capped at 100 cols (AppContainer). On a wide + // terminal the dialog overflowed its container and its right border was + // clipped off-screen. + const original = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); + Object.defineProperty(process.stdout, 'columns', { + value: 200, + configurable: true, + }); + let unmount: (() => void) | undefined; + try { + const r = renderWide(200); + unmount = r.unmount; + await waitFor(() => { + expect(stripAnsi(r.lastFrame())).toContain('Working tree vs HEAD'); + }); + const frame = stripAnsi(r.lastFrame()); + const widest = Math.max(...frame.split('\n').map((line) => line.length)); + expect(widest).toBeLessThanOrEqual(102); + } finally { + unmount?.(); + if (original) { + Object.defineProperty(process.stdout, 'columns', original); + } + } + }); +}); diff --git a/packages/cli/src/ui/components/DiffDialog.tsx b/packages/cli/src/ui/components/DiffDialog.tsx index 5c89d2dfe8b..b8d27a380db 100644 --- a/packages/cli/src/ui/components/DiffDialog.tsx +++ b/packages/cli/src/ui/components/DiffDialog.tsx @@ -235,7 +235,10 @@ export function DiffDialog({ useKeypress(handleKeypress, { isActive: true }); const { columns, rows } = useTerminalSize(); - const dialogWidth = Math.min(columns - 4, 110); + // Cap to the app's main content area (AppContainer caps it at 100). The old + // 110 cap exceeded that container, so on wide terminals the dialog overflowed + // and its right border/edge was clipped off-screen. + const dialogWidth = Math.min(columns - 4, 100); const detailHeight = Math.max(8, rows - 12); const headerTitle = From e08694f1b7618ae93f74a9a5143efd59b79c63be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 09:14:12 +0800 Subject: [PATCH 42/48] =?UTF-8?q?fix(extensions):=20address=20review=20rou?= =?UTF-8?q?nd=203=20=E2=80=94=20symlink/ANSI=20confinement,=20crash=20&=20?= =?UTF-8?q?data-loss=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - claude-converter: confine git-subdir subdir against symlink escape; fail strict mode on a symlinked plugin.json; sanitize untrusted source/path in conversion error messages - sourceRegistry: sanitize version/category/lastUpdated and component names before TUI render - gemini-converter: guard gemini-extension.json reads with realPathWithin - SourcesTab: sanitize persisted marketplace name in list + remove-confirm Robustness: - SourcesTab: wrap sync removeSource in try/catch (was crashing the TUI); only mark a marketplace updated when the refresh actually loaded - InstalledTab: move bundled-MCP enable write inside try/catch - DiscoverTab: return to the list (not an arbitrary plugin's detail) after a failed batch install - install.ts: roll back the User-scope disable when the Workspace enable fails - claude-converter: reject a null/non-object plugin.json with a clear error; warn instead of silently skipping a symlinked .mcp.json - extensionPreferences/sourceRegistry: quarantine a corrupt state file to ${path}.corrupted so the next write can't clobber recoverable data Tests: - cover marketplace.json/plugin.json/.mcp.json symlink guards, strict-mode rejection, and null plugin.json - delete the process.stdout.columns override on non-TTY in dialog width tests --- .../cli/src/commands/extensions/install.ts | 23 ++- packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/zh-TW.js | 2 +- packages/cli/src/i18n/locales/zh.js | 1 + .../cli/src/ui/components/DiffDialog.test.tsx | 7 + .../ExtensionsManagerDialog.test.tsx | 7 + .../extensions/tabs/DiscoverTab.tsx | 22 ++- .../extensions/tabs/InstalledTab.tsx | 22 ++- .../components/extensions/tabs/SourcesTab.tsx | 47 ++++-- .../src/extension/claude-converter.test.ts | 151 ++++++++++++++++++ .../core/src/extension/claude-converter.ts | 71 +++++++- packages/core/src/extension/corruptFile.ts | 38 +++++ .../src/extension/extensionPreferences.ts | 5 + .../src/extension/gemini-converter.test.ts | 5 + .../core/src/extension/gemini-converter.ts | 12 ++ packages/core/src/extension/sourceRegistry.ts | 25 ++- 16 files changed, 398 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/extension/corruptFile.ts diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index a8024bdca22..18ca3ce2e19 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -104,10 +104,25 @@ export async function handleInstall(args: InstallArgs) { extension.name, SettingScope.User, ); - await extensionManager.enableExtension( - extension.name, - SettingScope.Workspace, - ); + try { + await extensionManager.enableExtension( + extension.name, + SettingScope.Workspace, + ); + } catch (enableError) { + // The User-scope disable already landed. If the Workspace enable + // fails, the extension would be left disabled everywhere — roll the + // User enable back so it isn't silently dead, then surface the error. + try { + await extensionManager.enableExtension( + extension.name, + SettingScope.User, + ); + } catch { + // Best-effort rollback; report the original failure below. + } + throw enableError; + } } } writeStdoutLine( diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 932e9437ff4..07e1b886ff3 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -128,6 +128,8 @@ export default { 'Update marketplace': 'Update marketplace', 'Update marketplace (last updated {{date}})': 'Update marketplace (last updated {{date}})', + 'Could not update marketplace "{{name}}".': + 'Could not update marketplace "{{name}}".', 'Updated "{{name}}".': 'Updated "{{name}}".', 'Updated marketplace "{{name}}".': 'Updated marketplace "{{name}}".', 'Use the Discover tab to find and install plugins.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 293c7b0ea0e..c61c2a9383e 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -124,6 +124,7 @@ export default { 'Update marketplace': '更新市場來源', 'Update marketplace (last updated {{date}})': '更新市場來源(最近更新 {{date}})', + 'Could not update marketplace "{{name}}".': '無法更新市場來源 "{{name}}"。', 'Updated "{{name}}".': '已更新 "{{name}}"。', 'Updated marketplace "{{name}}".': '已更新市場來源 "{{name}}"。', 'Use the Discover tab to find and install plugins.': @@ -154,7 +155,6 @@ export default { '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.': '⚠ 在安裝、更新或使用擴展前,請確保你信任它。我們無法驗證擴展包含哪些 MCP 伺服器、檔案或其他軟體,也無法保證其按預期運作。更多資訊請查看擴展主頁。', - // Tool display names (chat-stream badge labels) // ---------------------------------------------------------------------------- // Keyed by `toolDisplayName.` (from core diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 66fcbd202cb..79ff24419f7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -122,6 +122,7 @@ export default { 'Update marketplace': '更新市场源', 'Update marketplace (last updated {{date}})': '更新市场源(最近更新 {{date}})', + 'Could not update marketplace "{{name}}".': '无法更新市场源 "{{name}}"。', 'Updated "{{name}}".': '已更新 "{{name}}"。', 'Updated marketplace "{{name}}".': '已更新市场源 "{{name}}"。', 'Use the Discover tab to find and install plugins.': diff --git a/packages/cli/src/ui/components/DiffDialog.test.tsx b/packages/cli/src/ui/components/DiffDialog.test.tsx index 32ae62a0b16..f96bf45f9fc 100644 --- a/packages/cli/src/ui/components/DiffDialog.test.tsx +++ b/packages/cli/src/ui/components/DiffDialog.test.tsx @@ -103,6 +103,13 @@ describe('DiffDialog', () => { unmount?.(); if (original) { Object.defineProperty(process.stdout, 'columns', original); + } else { + // Non-TTY (CI/piped stdout): `columns` is inherited from the prototype, + // so there was no own-property to restore. Delete the override we added + // so it doesn't leak into later test files via useTerminalSize. + delete (process.stdout as unknown as Record)[ + 'columns' + ]; } } }); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx index 242106b5528..e528d98be45 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx @@ -249,6 +249,13 @@ describe('ExtensionsManagerDialog (tabbed)', () => { unmount?.(); if (original) { Object.defineProperty(process.stdout, 'columns', original); + } else { + // Non-TTY (CI/piped stdout): `columns` is inherited from the prototype, + // so there was no own-property to restore. Delete the override we added + // so it doesn't leak `200` into later test files via useTerminalSize. + delete (process.stdout as unknown as Record)[ + 'columns' + ]; } } }); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index f5c7480c29d..28c65b341c1 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -206,7 +206,11 @@ export const DiscoverTab = ({ }, [pendingInstall, onLockChange, onStatus]); const runInstall = useCallback( - async (targets: DiscoveredPlugin[], scope: ExtensionScope) => { + async ( + targets: DiscoveredPlugin[], + scope: ExtensionScope, + origin: 'detail' | 'list', + ) => { if (!extensionManager || targets.length === 0) return; setInstalling(true); let installed = 0; @@ -271,18 +275,24 @@ export const DiscoverTab = ({ onInstalled(); if (errors.length === 0) { goToList(); - } else { - // On failure, stay on the extension detail so the error remains - // visible and the user can retry, instead of dropping to the list. + } else if (origin === 'detail') { + // Single install from a plugin's detail: stay on detail so the error + // remains visible over the right plugin and the user can retry. setView('detail'); onLockChange(true); + } else { + // Batch install started from the list: the detail view renders + // filtered[cursor] — an arbitrary row unrelated to what failed — so + // returning there would offer a misleading retry. Keep the error over + // the list instead. + goToList(); } }, [extensionManager, onStatus, load, onInstalled, goToList, onLockChange], ); const installWithScope = useCallback( - (scope: ExtensionScope) => void runInstall(pendingInstall(), scope), + (scope: ExtensionScope) => void runInstall(pendingInstall(), scope, 'list'), [runInstall, pendingInstall], ); @@ -336,7 +346,7 @@ export const DiscoverTab = ({ } else if (action === 'homepage') { if (selected) void openHomepage(selected); } else if (selected) { - void runInstall([selected], action); + void runInstall([selected], action, 'detail'); } }, [selected, goToList, openHomepage, runInstall], diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 7f1030eca30..c25cb5c1eff 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -530,14 +530,15 @@ export const InstalledTab = ({ } return; } - // Enable: clear the extension-scoped flag, then fall through to the - // shared enable path (which also clears any manual exclusions). - extensionManager?.setMcpServerDisabled( - item.parentExtension, - item.name, - false, - ); + // Enable: clear the extension-scoped flag inside the try below (so a + // write failure routes to the error toast instead of crashing as an + // unhandled rejection), then fall through to the shared enable path + // (which also clears any manual exclusions). } + // Whether this is the bundled-MCP enable fall-through that still needs the + // extension-scoped disable flag cleared. + const clearBundledDisable = + Boolean(item.parentExtension) && !item.isActive; const toolRegistry = config.getToolRegistry(); mutatingRef.current = true; // Enabling rediscovers the server's tools, which can take a while. @@ -548,6 +549,13 @@ export const InstalledTab = ({ : t('Enabling MCP "{{name}}"...', { name: item.name }), }); try { + if (clearBundledDisable && extensionManager && item.parentExtension) { + extensionManager.setMcpServerDisabled( + item.parentExtension, + item.name, + false, + ); + } const settings = loadSettings(); const targetScope = item.mcp.scope === 'project' diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index eaf18716f8b..88da343fd0b 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -258,14 +258,25 @@ export const SourcesTab = ({ const removeSource = useCallback(() => { if (!extensionManager || !detailSource) return; - const removed = extensionManager.removeSource(detailSource.name); - if (removed) { - onStatus({ - type: 'success', - text: t('Removed marketplace "{{name}}".', { name: detailSource.name }), - }); - void load(); - onChanged(); + // removeSource() -> atomicWriteFileSync can throw (EACCES/EROFS/ENOSPC, or + // a Windows lock on marketplaces.json). Unlike the async sibling handlers, + // this runs synchronously inside the keypress broadcast loop, so an + // unguarded throw would tear down the whole TUI session. Degrade to an + // error toast instead. + try { + const removed = extensionManager.removeSource(detailSource.name); + if (removed) { + onStatus({ + type: 'success', + text: t('Removed marketplace "{{name}}".', { + name: detailSource.name, + }), + }); + void load(); + onChanged(); + } + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); } goToList(); }, [extensionManager, detailSource, onStatus, load, onChanged, goToList]); @@ -276,6 +287,19 @@ export const SourcesTab = ({ try { const cfg = await extensionManager.loadSource(detailSource.source); setDetailConfig(cfg ?? null); + // loadSource returns null when the marketplace is unreachable / invalid. + // Only advance the lastUpdated timestamp and report success on a real + // refresh — otherwise a failed update would show "Updated marketplace X". + if (cfg === null) { + onStatus({ + type: 'error', + text: t('Could not update marketplace "{{name}}".', { + name: detailSource.name, + }), + }); + await load(); + return; + } extensionManager.markSourceUpdated(detailSource.name); await load(); onChanged(); @@ -584,7 +608,7 @@ export const SourcesTab = ({ {t('Remove marketplace "{{name}}"?', { - name: detailSource?.name ?? '', + name: stripUnsafeCharacters(detailSource?.name ?? ''), })} @@ -649,7 +673,10 @@ export const SourcesTab = ({ {sources.map((source, j) => renderRow( sourcesStart + j, - source.name, + // Persisted marketplace name is stored raw from untrusted config; + // scrub it at the render site (also defends already-persisted + // entries) like the detail header does. + stripUnsafeCharacters(source.name), `${redactUrlCredentials(source.source)} (${source.type})`, ), )} diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 0133df53f8b..f60c33c7a2b 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -773,6 +773,112 @@ describe('convertClaudePluginPackage', () => { // Clean up converted directory fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + + it('throws when marketplace.json itself is a symlink resolving outside the plugin', async () => { + // A hostile clone makes the marketplace manifest a symlink to a JSON-shaped + // host file. The converter must refuse to follow it (realPathWithin guard). + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'marketplace.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ + name: 'leaked', + owner: { name: 'x', email: 'x@x' }, + plugins: [{ name: 'evil', version: '1.0.0', source: './' }], + }), + 'utf-8', + ); + + const pluginSourceDir = path.join(testDir, 'plugin-mp-symlink'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.symlinkSync(secretFile, path.join(marketplaceDir, 'marketplace.json')); + + await expect( + convertClaudePluginPackage(pluginSourceDir, 'evil'), + ).rejects.toThrow(/resolves through a symlink outside the plugin/); + + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + + it('throws in strict mode when plugin.json is a symlink escaping the plugin', async () => { + // existsSync follows the symlink so the strict-missing check passes, but the + // target is untrusted — strict mode must fail instead of silently falling + // back to the marketplace entry. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'plugin.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ name: 'leaked', version: '9.9.9' }), + 'utf-8', + ); + + const pluginSourceDir = path.join(testDir, 'plugin-strict-symlink'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [{ name: 'evil', version: '1.0.0', source: './', strict: true }], + }; + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + // plugin.json lives at pluginSource/.claude-plugin/plugin.json (source './' + // resolves the plugin source to the package root). + fs.symlinkSync(secretFile, path.join(marketplaceDir, 'plugin.json')); + + await expect( + convertClaudePluginPackage(pluginSourceDir, 'evil'), + ).rejects.toThrow(/Strict mode requires a trusted plugin\.json/); + + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + + it('ignores a symlinked plugin.json (non-strict) and uses the marketplace entry', async () => { + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'plugin.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ + name: 'leaked', + version: '9.9.9', + mcpServers: { leaked: { command: 'cat', args: ['/etc/passwd'] } }, + }), + 'utf-8', + ); + + const pluginSourceDir = path.join(testDir, 'plugin-nonstrict-symlink'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { name: 'evil', version: '1.0.0', source: './', strict: false }, + ], + }; + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + fs.symlinkSync(secretFile, path.join(marketplaceDir, 'plugin.json')); + + const result = await convertClaudePluginPackage(pluginSourceDir, 'evil'); + // The marketplace entry is used; the symlinked target is never read. + expect(result.config.name).toBe('evil'); + expect( + (result.config.mcpServers as Record | undefined)?.[ + 'leaked' + ], + ).toBeUndefined(); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); }); describe('convertClaudePluginStandalone', () => { @@ -1010,6 +1116,51 @@ describe('convertClaudePluginStandalone', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + + it('does not load a .mcp.json that is a symlink escaping the plugin', async () => { + // .mcp.json's name stays inside the plugin but it's a symlink to a host + // file. realPathWithin must reject it so the target servers are never read. + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + const secretFile = path.join(secretDir, 'servers.json'); + fs.writeFileSync( + secretFile, + JSON.stringify({ + mcpServers: { leaked: { command: 'cat', args: ['/etc/passwd'] } }, + }), + 'utf-8', + ); + + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ name: 'evil', version: '1.0.0' }), + 'utf-8', + ); + fs.symlinkSync(secretFile, path.join(testDir, '.mcp.json')); + + const result = await convertClaudePluginStandalone(testDir); + expect( + (result.config.mcpServers as Record | undefined)?.[ + 'leaked' + ], + ).toBeUndefined(); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(secretDir, { recursive: true, force: true }); + }); + + it('throws a clear error when plugin.json parses to null', async () => { + // A plugin.json whose body is the JSON literal `null` would otherwise throw + // an opaque "Cannot read properties of null" on the mcpServers deref. + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'plugin.json'), 'null', 'utf-8'); + + await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow( + /Invalid plugin configuration/, + ); + }); }); describe('performVariableReplacement for Claude extensions', () => { diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index c751fc5c707..19c6e9edc98 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -31,9 +31,24 @@ import { import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; import { substituteHookVariables } from './variables.js'; +import { stripVTControlCharacters } from 'node:util'; const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); +// C0/C1 control chars left behind after escape-sequence stripping. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Strips terminal escape/control sequences from untrusted values before they + * are interpolated into error messages. Conversion errors here propagate to the + * TUI install status area, so a hostile plugin `source`/`path` could otherwise + * smuggle ANSI/OSC sequences to the terminal during a failed install. + */ +function sanitizeForError(text: string): string { + return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, ''); +} + export interface ClaudePluginConfig { name: string; version: string; @@ -495,6 +510,15 @@ export async function convertClaudePluginPackage( const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent); mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig); } else { + // `existsSync` follows symlinks, so the strict check at line 500 passes + // when plugin.json is a symlink to an existing host file — but the file is + // not trusted (`realPathWithin` rejected it). Strict mode must fail here + // rather than silently fall back to the marketplace entry. + if (strict) { + throw new Error( + `Strict mode requires a trusted plugin.json at ${pluginJsonPath}`, + ); + } if (fs.existsSync(pluginJsonPath)) { debugLogger.warn( `Ignoring plugin.json at ${pluginJsonPath}; it resolves through a symlink outside the plugin.`, @@ -698,9 +722,23 @@ export async function convertClaudePluginStandalone( ); } - const mergedConfig: ClaudePluginConfig = JSON.parse( + const parsedConfig: unknown = JSON.parse( fs.readFileSync(pluginJsonPath, 'utf-8'), ); + // A plugin.json whose body is `null`, an array, or a scalar would otherwise + // throw an opaque `Cannot read properties of null` on the deref below. Fail + // with a clear message instead (the marketplace path tolerates this via + // `mergeClaudeConfigs`, so guard the standalone path to match). + if ( + typeof parsedConfig !== 'object' || + parsedConfig === null || + Array.isArray(parsedConfig) + ) { + throw new Error( + `Invalid plugin configuration at ${pluginJsonPath}: expected a JSON object`, + ); + } + const mergedConfig = parsedConfig as ClaudePluginConfig; if (!mergedConfig.mcpServers) { const mcpJsonPath = path.join(extensionDir, '.mcp.json'); @@ -729,6 +767,13 @@ export async function convertClaudePluginStandalone( `Failed to parse .mcp.json at ${mcpJsonPath}: ${error instanceof Error ? error.message : String(error)}`, ); } + } else if (fs.existsSync(mcpJsonPath)) { + // The file exists but resolves through a symlink outside the plugin. + // Mirror the plugin.json skip-warning so a missing-MCP-servers + // investigation has a breadcrumb instead of a silent drop. + debugLogger.warn( + `Ignoring .mcp.json at ${mcpJsonPath}; it resolves through a symlink outside the plugin.`, + ); } } @@ -990,19 +1035,21 @@ async function resolvePluginSource( !resolvedSource.startsWith(marketplaceBase + path.sep) ) { throw new Error( - `Plugin source "${source}" escapes the marketplace directory`, + `Plugin source "${sanitizeForError(source)}" escapes the marketplace directory`, ); } if (!fs.existsSync(sourcePath)) { - throw new Error(`Plugin source not found at ${sourcePath}`); + throw new Error( + `Plugin source not found at ${sanitizeForError(sourcePath)}`, + ); } // The lexical check is string-only; reject a source that reaches outside // the marketplace dir through a symlink before copying it in. if (!realPathWithin(sourcePath, marketplaceDir)) { throw new Error( - `Plugin source "${source}" resolves through a symlink outside the marketplace directory`, + `Plugin source "${sanitizeForError(source)}" resolves through a symlink outside the marketplace directory`, ); } @@ -1059,19 +1106,29 @@ async function resolvePluginSource( // repo so a value like "../../.ssh" (or an absolute path) cannot escape. if (!source.path || source.path === '.' || path.isAbsolute(source.path)) { throw new Error( - `Invalid plugin subdirectory "${source.path}" for ${source.url}`, + `Invalid plugin subdirectory "${sanitizeForError(String(source.path))}" for ${sanitizeForError(source.url)}`, ); } const subDir = path.resolve(pluginDir, source.path); const repoRoot = path.resolve(pluginDir); if (!subDir.startsWith(repoRoot + path.sep)) { throw new Error( - `Plugin subdirectory "${source.path}" escapes the repository root of ${source.url}`, + `Plugin subdirectory "${sanitizeForError(source.path)}" escapes the repository root of ${sanitizeForError(source.url)}`, ); } if (!fs.existsSync(subDir)) { throw new Error( - `Plugin subdirectory "${source.path}" not found in ${source.url} (ref: ${source.ref ?? source.sha ?? 'HEAD'})`, + `Plugin subdirectory "${sanitizeForError(source.path)}" not found in ${sanitizeForError(source.url)} (ref: ${source.ref ?? source.sha ?? 'HEAD'})`, + ); + } + // The lexical `startsWith` check above is string-only; `cloneFromGit` + // checks out symlinks on macOS/Linux, so a hostile repo can commit the + // subdir as a symlink whose name stays inside the clone but whose target + // escapes it (e.g. `evil -> /etc`). Re-verify the real path before + // returning it as the copy source. + if (!realPathWithin(subDir, pluginDir)) { + throw new Error( + `Plugin subdirectory "${sanitizeForError(source.path)}" resolves through a symlink outside the repository root of ${sanitizeForError(source.url)}`, ); } return subDir; diff --git a/packages/core/src/extension/corruptFile.ts b/packages/core/src/extension/corruptFile.ts new file mode 100644 index 00000000000..b9457745473 --- /dev/null +++ b/packages/core/src/extension/corruptFile.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('CORRUPT_FILE'); + +/** + * Moves a corrupt (un-parseable) JSON state file aside to `${filePath}.corrupted` + * so the next write does not clobber recoverable user data. + * + * The extension state stores (favorites/scopes, the marketplace source list) + * fall back to an empty default when their backing file fails to parse, then + * persist that empty default on the next mutation — silently wiping data that a + * truncated/partial third-party write (disk-full, editor save error, cloud + * partial sync) left recoverable. Renaming the bad file aside keeps the + * original bytes for recovery and lets the next write start cleanly. Best + * effort: any rename failure is logged and swallowed. + */ +export function quarantineCorruptFile(filePath: string): void { + const quarantinePath = `${filePath}.corrupted`; + try { + fs.renameSync(filePath, quarantinePath); + debugLogger.warn( + `Corrupt file ${filePath} could not be parsed; moved aside to ${quarantinePath}.`, + ); + } catch (error) { + debugLogger.warn( + `Corrupt file ${filePath} could not be parsed and could not be moved aside: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts index a85537bb573..e9b54c5ce63 100644 --- a/packages/core/src/extension/extensionPreferences.ts +++ b/packages/core/src/extension/extensionPreferences.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { quarantineCorruptFile } from './corruptFile.js'; const debugLogger = createDebugLogger('EXT_PREFERENCES'); @@ -105,6 +106,10 @@ export class ExtensionPreferencesStore { return emptyPreferences(); } debugLogger.error('Error reading extension preferences:', error); + // Move the unreadable file aside so the next toggleFavorite/setScope/ + // setMcpServerDisabled write doesn't clobber recoverable favorites/scopes + // with the empty default returned below. + quarantineCorruptFile(this.filePath); return emptyPreferences(); } } diff --git a/packages/core/src/extension/gemini-converter.test.ts b/packages/core/src/extension/gemini-converter.test.ts index b65556b3137..cb08cd2d670 100644 --- a/packages/core/src/extension/gemini-converter.test.ts +++ b/packages/core/src/extension/gemini-converter.test.ts @@ -20,6 +20,11 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, existsSync: vi.fn(), readFileSync: vi.fn(), + // The symlink-confinement guard (realPathWithin) resolves both the config + // path and its dir via realpathSync. These unit tests use in-memory mock + // dirs that don't exist on disk, so resolve paths to themselves — keeping + // the config inside its (mock) extension dir so the guard passes. + realpathSync: vi.fn((p: string) => p), }; }); diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index a7c266d4460..bc8b44e985f 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -36,6 +36,14 @@ export function convertGeminiToQwenConfig( extensionDir: string, ): ExtensionConfig { const configFilePath = path.join(extensionDir, 'gemini-extension.json'); + // The manifest may be a symlink in an untrusted clone; refuse to follow it + // outside the extension (would read an arbitrary JSON-shaped host file), + // matching the Claude-format manifest guards. + if (!realPathWithin(configFilePath, extensionDir)) { + throw new Error( + `Gemini extension config at ${configFilePath} resolves through a symlink outside the extension`, + ); + } const configContent = fs.readFileSync(configFilePath, 'utf-8'); const geminiConfig: GeminiExtensionConfig = JSON.parse(configContent); // Validate required fields @@ -250,6 +258,10 @@ export function isGeminiExtensionConfig(extensionDir: string) { if (!fs.existsSync(configFilePath)) { return false; } + // Don't read through a symlink that escapes the extension during detection. + if (!realPathWithin(configFilePath, extensionDir)) { + return false; + } const configContent = fs.readFileSync(configFilePath, 'utf-8'); const parsedConfig = JSON.parse(configContent); diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index b44097a238b..22e8cd4c488 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -11,6 +11,7 @@ import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import { quarantineCorruptFile } from './corruptFile.js'; import type { ClaudeMarketplaceConfig, ClaudeMarketplacePluginConfig, @@ -86,11 +87,13 @@ function asMcpNames( function pluginComponents( plugin: ClaudeMarketplacePluginConfig, ): DiscoveredPluginComponents | undefined { + // Component names render raw in the Discover "Will install" summary, so scrub + // them like the other untrusted marketplace fields to block ANSI injection. const components: DiscoveredPluginComponents = { - skills: asNameList(plugin.skills), - commands: asNameList(plugin.commands), - agents: asNameList(plugin.agents), - mcpServers: asMcpNames(plugin.mcpServers), + skills: asNameList(plugin.skills)?.map((s) => sanitizeDisplay(s)), + commands: asNameList(plugin.commands)?.map((s) => sanitizeDisplay(s)), + agents: asNameList(plugin.agents)?.map((s) => sanitizeDisplay(s)), + mcpServers: asMcpNames(plugin.mcpServers)?.map((s) => sanitizeDisplay(s)), }; return Object.values(components).some(Boolean) ? components : undefined; } @@ -210,11 +213,15 @@ function pluginsFromConfig( marketplaceName: sanitizeDisplay(config.name || marketplace.name), name: sanitizeDisplay(plugin.name), description: sanitizeDisplay(plugin.description), - version: plugin.version, + // `version` and `lastUpdated` render in the pre-consent Discover detail via + // `t()` (no escaping), so they need the same scrubbing as the other + // untrusted display fields. `category` has no sink today but is wrapped for + // consistency / future-proofing. + version: sanitizeDisplay(plugin.version), author: sanitizeDisplay(plugin.author?.name), homepage: sanitizeDisplay(plugin.homepage), - category: plugin.category, - lastUpdated: pluginLastUpdated(plugin), + category: sanitizeDisplay(plugin.category), + lastUpdated: sanitizeDisplay(pluginLastUpdated(plugin)), installs: pluginInstalls(plugin), components: pluginComponents(plugin), installSource: resolveInstallSource(marketplace, plugin), @@ -242,6 +249,10 @@ export class SourceRegistryStore { return []; } debugLogger.error('Error reading marketplace registry:', error); + // Move the unreadable file aside so the next `add`/`remove` write doesn't + // clobber a recoverable (e.g. truncated) source list with the empty + // default returned below. + quarantineCorruptFile(this.filePath); return []; } } From 61a69e2729e0e21f97eeac164f7833d5557967d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 09:15:51 +0800 Subject: [PATCH 43/48] fix(extensions): guard toggleFavorite write against unhandled rejection --- .../extensions/tabs/InstalledTab.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index c25cb5c1eff..7eba1640075 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -614,14 +614,21 @@ export const InstalledTab = ({ const toggleFavorite = useCallback( async (item: InstalledItem) => { if (!extensionManager) return; - const nowFavorite = extensionManager.toggleFavorite(item.name); - onStatus({ - type: 'info', - text: nowFavorite - ? t('Added "{{name}}" to favorites.', { name: item.name }) - : t('Removed "{{name}}" from favorites.', { name: item.name }), - }); - await load(); + // toggleFavorite() -> atomicWriteFileSync can throw; this runs in a + // void-invoked handler, so an unguarded throw surfaces as the alarming + // "Unhandled Promise Rejection" banner. Route it to an error toast. + try { + const nowFavorite = extensionManager.toggleFavorite(item.name); + onStatus({ + type: 'info', + text: nowFavorite + ? t('Added "{{name}}" to favorites.', { name: item.name }) + : t('Removed "{{name}}" from favorites.', { name: item.name }), + }); + await load(); + } catch (error) { + onStatus({ type: 'error', text: getErrorMessage(error) }); + } }, [extensionManager, load, onStatus], ); From a7b2df2fb4dfd66cc764288dc5b45eb83ac5c1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 09:50:55 +0800 Subject: [PATCH 44/48] test(extensions): normalize realpathSync mock so gemini guard passes on Windows --- packages/core/src/extension/gemini-converter.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extension/gemini-converter.test.ts b/packages/core/src/extension/gemini-converter.test.ts index cb08cd2d670..e3eb631f0ed 100644 --- a/packages/core/src/extension/gemini-converter.test.ts +++ b/packages/core/src/extension/gemini-converter.test.ts @@ -16,15 +16,20 @@ import { // Mock fs module vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); + // Imported inside the (hoisted) factory — the top-level `path` import isn't + // initialized yet when this runs. + const nodePath = await import('node:path'); return { ...actual, existsSync: vi.fn(), readFileSync: vi.fn(), // The symlink-confinement guard (realPathWithin) resolves both the config // path and its dir via realpathSync. These unit tests use in-memory mock - // dirs that don't exist on disk, so resolve paths to themselves — keeping - // the config inside its (mock) extension dir so the guard passes. - realpathSync: vi.fn((p: string) => p), + // dirs that don't exist on disk; normalize via path.resolve (as the real + // realpathSync would) so the config path and its dir share separators — + // otherwise on Windows path.join() backslashes vs. the raw forward-slash + // mock dir make the containment startsWith() spuriously fail. + realpathSync: vi.fn((p: string) => nodePath.resolve(p)), }; }); From b5fa71a3f9e16c68373c6de09225a91e37ac02e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 10:23:50 +0800 Subject: [PATCH 45/48] =?UTF-8?q?fix(extensions):=20address=20review=20rou?= =?UTF-8?q?nd=204=20=E2=80=94=20shared=20sanitizer,=20narrowed=20quarantin?= =?UTF-8?q?e,=20visible=20warnings,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consolidate the three drifted ANSI/control-char strippers into a single shared stripAnsiAndControl in core textUtils (fixes the C1-range gap in workflow-orchestrator's copy); sourceRegistry/claude-converter now delegate - claude-converter: also sanitize the git-subdir ref/sha in the not-found error - corruptFile: surface the quarantine on stderr (debug log is gated off, so a silent move would still look like data loss) - extensionPreferences/sourceRegistry: only quarantine on a JSON parse failure, not on transient read errors (EACCES/EMFILE/EISDIR) that leave a valid file - install: surface a failed scope-change rollback instead of swallowing it Tests: - gemini-converter: negative-path coverage for the realPathWithin guards - new corruptFile.test (rename-aside + best-effort failure) - stripAnsiAndControl unit tests (ANSI/OSC/C0/C1) - install rollback + rollback-also-fails cases --- .../src/commands/extensions/install.test.ts | 64 +++++++++++++++++ .../cli/src/commands/extensions/install.ts | 9 ++- .../agents/runtime/workflow-orchestrator.ts | 8 ++- .../core/src/extension/claude-converter.ts | 16 ++--- .../core/src/extension/corruptFile.test.ts | 68 +++++++++++++++++++ packages/core/src/extension/corruptFile.ts | 11 +++ .../src/extension/extensionPreferences.ts | 20 ++++-- .../src/extension/gemini-converter.test.ts | 24 +++++++ packages/core/src/extension/sourceRegistry.ts | 30 ++++---- packages/core/src/utils/textUtils.test.ts | 33 ++++++++- packages/core/src/utils/textUtils.ts | 20 ++++++ 11 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/extension/corruptFile.test.ts diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index c37226465b5..2bd3431cf0c 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -264,6 +264,70 @@ describe('handleInstall', () => { ); }); + it('rolls back the User-scope disable when the Workspace enable fails', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + // Workspace enable (first call) fails; the rollback User enable succeeds. + mockEnableExtension.mockRejectedValueOnce( + new Error('workspace enable failed'), + ); + mockEnableExtension.mockResolvedValueOnce(undefined); + + await handleInstall({ source: 'git@some-url', scope: 'project' }); + + expect(mockDisableExtension).toHaveBeenCalledWith( + 'scoped-extension', + 'User', + ); + // Both the failed Workspace enable and the rollback User enable were attempted. + expect(mockEnableExtension).toHaveBeenNthCalledWith( + 1, + 'scoped-extension', + 'Workspace', + ); + expect(mockEnableExtension).toHaveBeenNthCalledWith( + 2, + 'scoped-extension', + 'User', + ); + // The original failure is surfaced and the command exits non-zero. + expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed'); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); + + it('surfaces a rollback failure when the recovery enable also fails', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + // Both the Workspace enable and the rollback User enable fail. + mockEnableExtension.mockRejectedValueOnce( + new Error('workspace enable failed'), + ); + mockEnableExtension.mockRejectedValueOnce(new Error('rollback failed')); + + await handleInstall({ source: 'git@some-url', scope: 'project' }); + + // A warning naming the failed rollback, plus the original error, are shown. + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('failed to roll back the scope change'), + ); + expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed'); + expect(processSpy).toHaveBeenCalledWith(1); + processSpy.mockRestore(); + }); + it('should accept workspace as an alias of project scope', async () => { mockParseInstallSource.mockResolvedValue({ type: 'git', diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 18ca3ce2e19..5fb90569d68 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -118,8 +118,13 @@ export async function handleInstall(args: InstallArgs) { extension.name, SettingScope.User, ); - } catch { - // Best-effort rollback; report the original failure below. + } catch (rollbackError) { + // Rollback failed too: the extension is now disabled at every + // scope. Surface this so the user knows recovery also failed, + // before the original error is reported below. + writeStderrLine( + `Warning: failed to roll back the scope change for "${extension.name}"; it may be disabled at all scopes: ${getErrorMessage(rollbackError)}`, + ); } throw enableError; } diff --git a/packages/core/src/agents/runtime/workflow-orchestrator.ts b/packages/core/src/agents/runtime/workflow-orchestrator.ts index 62f62e7d988..f44e706ea02 100644 --- a/packages/core/src/agents/runtime/workflow-orchestrator.ts +++ b/packages/core/src/agents/runtime/workflow-orchestrator.ts @@ -29,6 +29,7 @@ import type { } from './agent-events.js'; import { ToolNames } from '../../tools/tool-names.js'; import { createConcurrencyLimiter } from '../../utils/concurrencyLimiter.js'; +import { stripAnsiAndControl } from '../../utils/textUtils.js'; import type { SubagentConfig } from '../../subagents/types.js'; import { GitWorktreeService, @@ -274,8 +275,11 @@ function generateRunId(): string { * `runOverridePath` for `opts.agentType`. */ function sanitizeForErrorMessage(value: string): string { - // eslint-disable-next-line no-control-regex - return value.replace(/[\u0000-\u001f\u007f]+/g, ' '); + // Shared with the extension converters via stripAnsiAndControl: strips ANSI/VT + // escape sequences and removes C0/C1 control chars (incl. the C1 range the old + // local regex missed). Removing rather than spacing still keeps the error + // single-line, which is the original intent here. + return stripAnsiAndControl(value); } /** diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 19c6e9edc98..1a5cd096772 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -29,25 +29,19 @@ import { stringify as stringifyYaml, } from '../utils/yaml-parser.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { normalizeContent } from '../utils/textUtils.js'; +import { normalizeContent, stripAnsiAndControl } from '../utils/textUtils.js'; import { substituteHookVariables } from './variables.js'; -import { stripVTControlCharacters } from 'node:util'; const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); -// C0/C1 control chars left behind after escape-sequence stripping. -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g; - /** * Strips terminal escape/control sequences from untrusted values before they * are interpolated into error messages. Conversion errors here propagate to the * TUI install status area, so a hostile plugin `source`/`path` could otherwise - * smuggle ANSI/OSC sequences to the terminal during a failed install. + * smuggle ANSI/OSC sequences to the terminal during a failed install. Aliases + * the shared `stripAnsiAndControl` so the rule stays in one place. */ -function sanitizeForError(text: string): string { - return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, ''); -} +const sanitizeForError = stripAnsiAndControl; export interface ClaudePluginConfig { name: string; @@ -1118,7 +1112,7 @@ async function resolvePluginSource( } if (!fs.existsSync(subDir)) { throw new Error( - `Plugin subdirectory "${sanitizeForError(source.path)}" not found in ${sanitizeForError(source.url)} (ref: ${source.ref ?? source.sha ?? 'HEAD'})`, + `Plugin subdirectory "${sanitizeForError(source.path)}" not found in ${sanitizeForError(source.url)} (ref: ${sanitizeForError(source.ref ?? source.sha ?? 'HEAD')})`, ); } // The lexical `startsWith` check above is string-only; `cloneFromGit` diff --git a/packages/core/src/extension/corruptFile.test.ts b/packages/core/src/extension/corruptFile.test.ts new file mode 100644 index 00000000000..3ff8394dd39 --- /dev/null +++ b/packages/core/src/extension/corruptFile.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type MockInstance, +} from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { quarantineCorruptFile } from './corruptFile.js'; + +describe('quarantineCorruptFile', () => { + let testDir: string; + let stderrSpy: MockInstance<(...args: unknown[]) => unknown>; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'corrupt-file-')); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) as unknown as MockInstance< + (...args: unknown[]) => unknown + >; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('renames the file aside to ${path}.corrupted, preserving its bytes', () => { + const filePath = path.join(testDir, 'prefs.json'); + fs.writeFileSync(filePath, '{ broken json', 'utf-8'); + + quarantineCorruptFile(filePath); + + // Original is gone; the bytes are preserved in the .corrupted sibling. + expect(fs.existsSync(filePath)).toBe(false); + const quarantined = `${filePath}.corrupted`; + expect(fs.existsSync(quarantined)).toBe(true); + expect(fs.readFileSync(quarantined, 'utf-8')).toBe('{ broken json'); + // The quarantine event is surfaced on stderr (debug log is gated off). + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('moved aside'), + ); + }); + + it('does not throw when the file cannot be moved aside', () => { + // Renaming a non-existent file throws ENOENT inside the helper; it must be + // swallowed (best-effort) and reported on stderr rather than propagated. + const missing = path.join(testDir, 'does-not-exist.json'); + + expect(() => quarantineCorruptFile(missing)).not.toThrow(); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('could not be moved aside'), + ); + }); +}); diff --git a/packages/core/src/extension/corruptFile.ts b/packages/core/src/extension/corruptFile.ts index b9457745473..f181c8c1707 100644 --- a/packages/core/src/extension/corruptFile.ts +++ b/packages/core/src/extension/corruptFile.ts @@ -25,10 +25,21 @@ export function quarantineCorruptFile(filePath: string): void { const quarantinePath = `${filePath}.corrupted`; try { fs.renameSync(filePath, quarantinePath); + // `debugLogger.warn` is gated behind QWEN_DEBUG_LOG_FILE (unset for almost + // all users), so without this the user's favorites/scopes/sources would + // appear to vanish with no trail. Surface the quarantine on stderr too. + process.stderr.write( + `[warn] Corrupt extension state file ${filePath} moved aside to ${quarantinePath}\n`, + ); debugLogger.warn( `Corrupt file ${filePath} could not be parsed; moved aside to ${quarantinePath}.`, ); } catch (error) { + process.stderr.write( + `[warn] Corrupt extension state file ${filePath} could not be moved aside: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); debugLogger.warn( `Corrupt file ${filePath} could not be parsed and could not be moved aside: ${ error instanceof Error ? error.message : String(error) diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts index e9b54c5ce63..eb945f1140c 100644 --- a/packages/core/src/extension/extensionPreferences.ts +++ b/packages/core/src/extension/extensionPreferences.ts @@ -69,7 +69,18 @@ export class ExtensionPreferencesStore { return structuredClone(this.cache.prefs); } const content = fs.readFileSync(this.filePath, 'utf-8'); - const parsed = JSON.parse(content) as Partial; + let parsed: Partial; + try { + parsed = JSON.parse(content) as Partial; + } catch (parseError) { + // Only a genuine parse failure means the content is corrupt — move it + // aside so the next write can't clobber recoverable favorites/scopes. + // Transient read errors (EACCES/EMFILE/EISDIR/…) fall through to the + // outer catch, which must NOT quarantine an otherwise-valid file. + debugLogger.error('Corrupt extension preferences:', parseError); + quarantineCorruptFile(this.filePath); + return emptyPreferences(); + } const rawScopes = parsed.scopes && typeof parsed.scopes === 'object' ? parsed.scopes : {}; const scopes: Record = {}; @@ -105,11 +116,10 @@ export class ExtensionPreferencesStore { ) { return emptyPreferences(); } + // A transient read error (permission/too-many-files/…) — the file may be + // perfectly valid, so do NOT quarantine it here; only parse failures + // above do that. Return the default for this read. debugLogger.error('Error reading extension preferences:', error); - // Move the unreadable file aside so the next toggleFavorite/setScope/ - // setMcpServerDisabled write doesn't clobber recoverable favorites/scopes - // with the empty default returned below. - quarantineCorruptFile(this.filePath); return emptyPreferences(); } } diff --git a/packages/core/src/extension/gemini-converter.test.ts b/packages/core/src/extension/gemini-converter.test.ts index e3eb631f0ed..1750909ee93 100644 --- a/packages/core/src/extension/gemini-converter.test.ts +++ b/packages/core/src/extension/gemini-converter.test.ts @@ -110,6 +110,19 @@ describe('convertGeminiToQwenConfig', () => { 'Gemini extension config must have name and version fields', ); }); + + it('throws when gemini-extension.json resolves through a symlink outside the extension', () => { + const mockDir = '/mock/extension/dir'; + // realPathWithin resolves the config path first: pretend it points outside + // the extension dir (a symlink escape). The root resolves normally. + vi.mocked(fs.realpathSync).mockReturnValueOnce( + '/outside/extension/gemini-extension.json', + ); + + expect(() => convertGeminiToQwenConfig(mockDir)).toThrow( + /resolves through a symlink outside the extension/, + ); + }); }); describe('isGeminiExtensionConfig', () => { @@ -181,6 +194,17 @@ describe('isGeminiExtensionConfig', () => { expect(isGeminiExtensionConfig(mockDir)).toBe(true); }); + + it('returns false when gemini-extension.json symlink escapes during detection', () => { + const mockDir = '/mock/extension/dir'; + + vi.mocked(fs.existsSync).mockReturnValue(true); + // The config path resolves outside the extension dir (symlink escape); + // detection must refuse to read it and report "not a Gemini extension". + vi.mocked(fs.realpathSync).mockReturnValueOnce('/outside/path'); + + expect(isGeminiExtensionConfig(mockDir)).toBe(false); + }); }); // Note: convertGeminiExtensionPackage() is tested through integration tests diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 22e8cd4c488..854dd32cb01 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -6,8 +6,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { stripVTControlCharacters } from 'node:util'; import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; +import { stripAnsiAndControl } from '../utils/textUtils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; import { loadMarketplaceConfigFromSource } from './marketplace.js'; @@ -184,10 +184,6 @@ function resolveInstallSource( return plugin.name; } -// C0/C1 control chars left behind after escape-sequence stripping. -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g; - /** * Strips terminal escape/control sequences from untrusted marketplace text. * Plugin metadata is rendered in the TUI before the user consents to install, @@ -200,8 +196,8 @@ function sanitizeDisplay(text: string): string; function sanitizeDisplay(text: string | undefined): string | undefined; function sanitizeDisplay(text: string | undefined): string | undefined { if (text === undefined) return undefined; - // Drop ANSI/VT escape sequences, then any remaining C0/C1 control chars. - return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, ''); + // Delegates to the single shared implementation so the rule can't drift. + return stripAnsiAndControl(text); } function pluginsFromConfig( @@ -236,10 +232,9 @@ export class SourceRegistryStore { constructor(private readonly filePath: string) {} read(): ExtensionSource[] { + let content: string; try { - const content = fs.readFileSync(this.filePath, 'utf-8'); - const parsed = JSON.parse(content); - return Array.isArray(parsed) ? (parsed as ExtensionSource[]) : []; + content = fs.readFileSync(this.filePath, 'utf-8'); } catch (error) { if ( error instanceof Error && @@ -248,10 +243,19 @@ export class SourceRegistryStore { ) { return []; } + // A transient read error (permission/too-many-files/…) — the file may be + // valid, so do NOT quarantine it; only a parse failure below does that. debugLogger.error('Error reading marketplace registry:', error); - // Move the unreadable file aside so the next `add`/`remove` write doesn't - // clobber a recoverable (e.g. truncated) source list with the empty - // default returned below. + return []; + } + try { + const parsed = JSON.parse(content); + return Array.isArray(parsed) ? (parsed as ExtensionSource[]) : []; + } catch (parseError) { + // Genuine corruption: move the file aside so the next `add`/`remove` + // write can't clobber a recoverable (e.g. truncated) source list with + // the empty default returned below. + debugLogger.error('Corrupt marketplace registry:', parseError); quarantineCorruptFile(this.filePath); return []; } diff --git a/packages/core/src/utils/textUtils.test.ts b/packages/core/src/utils/textUtils.test.ts index bdbc80216d7..e53acd2de96 100644 --- a/packages/core/src/utils/textUtils.test.ts +++ b/packages/core/src/utils/textUtils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { safeLiteralReplace, normalizeContent } from './textUtils.js'; +import { + safeLiteralReplace, + normalizeContent, + stripAnsiAndControl, +} from './textUtils.js'; describe('safeLiteralReplace', () => { it('returns original string when oldString empty or not found', () => { @@ -117,3 +121,30 @@ describe('normalizeContent', () => { expect(normalizeContent('Just a single line')).toBe('Just a single line'); }); }); + +describe('stripAnsiAndControl', () => { + const ESC = '\x1b'; + + it('leaves ordinary text untouched', () => { + expect(stripAnsiAndControl('hello world 123')).toBe('hello world 123'); + }); + + it('strips ANSI/VT escape sequences (e.g. clear-screen, color)', () => { + expect(stripAnsiAndControl(`${ESC}[2Jevil`)).toBe('evil'); + expect(stripAnsiAndControl(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + }); + + it('strips OSC 8 hyperlink sequences but keeps the link text', () => { + const osc = `${ESC}]8;;http://attacker\u0007click${ESC}]8;;\u0007`; + expect(stripAnsiAndControl(osc)).toBe('click'); + }); + + it('removes residual C0 control chars and DEL', () => { + // NUL, BEL and DEL between letters are dropped (no escape prefix). + expect(stripAnsiAndControl('a\u0000b\u0007c\u007fd')).toBe('abcd'); + }); + + it('removes C1 control chars (the range a drifted local copy missed)', () => { + expect(stripAnsiAndControl('x\u0080y\u0081z')).toBe('xyz'); + }); +}); diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index 32c25b89f8f..bf71c0f4a13 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -4,6 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { stripVTControlCharacters } from 'node:util'; + +// C0/C1 control chars (incl. DEL) left behind after escape-sequence stripping. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Strips terminal escape/control sequences from untrusted text: removes ANSI/VT + * escape sequences (via Node's `stripVTControlCharacters`) and then any residual + * C0/C1 control characters (including DEL). + * + * Use this for ANY untrusted string that may reach a terminal — marketplace + * metadata rendered in the TUI, values interpolated into error messages, etc. + * Centralised here so the rule can't drift between call sites: a bypass fixed + * here is fixed everywhere instead of leaving a stale near-duplicate vulnerable. + */ +export function stripAnsiAndControl(text: string): string { + return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, ''); +} + /** * Safely replaces text with literal strings, avoiding ECMAScript GetSubstitution issues. * Escapes $ characters to prevent template interpretation. From a2d63d780763362b366ce1cca3b36863cc2f58f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 10:42:38 +0800 Subject: [PATCH 46/48] test(extensions): assert discoverPlugins strips ANSI/control chars from display fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the untrusted-metadata sanitization that was only covered empirically before. Feeds a hostile plugin (cursor moves, line clears, OSC title-injection, BEL) through discoverPlugins and asserts every rendered field — name/version/description/author/homepage/category/ lastUpdated/component names plus the marketplace name — comes out clean. Addresses the maintainer verification note on PR #4850. --- .../core/src/extension/sourceRegistry.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index ae925150026..d8127bc9d73 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -171,6 +171,60 @@ describe('discoverPlugins', () => { expect(plugin.lastUpdated).toBe('Jun 5, 2026'); }); + it('strips ANSI/control sequences from untrusted display fields', async () => { + // Every displayed field renders in the pre-consent Discover view, so a + // hostile marketplace must not smuggle terminal escapes (cursor moves, line + // clears, OSC title-injection) through any of them. Payload mirrors the PoC + // from the PR #4850 verification report. + const ESC = '\x1b'; + const BEL = '\x07'; + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config(`Hostile${ESC}[2K`, [ + { + name: `evil${ESC}[31m-plugin${ESC}[2K`, + version: `9.9.9${ESC}[5m`, + description: `Totally safe${ESC}[1A${ESC}]0;PWNED${BEL} — trust me${ESC}[7m`, + author: { name: `mallory${ESC}[0m` }, + homepage: `https://e${ESC}[31mvil.com`, + category: `tools${BEL}`, + skills: [`pdf${ESC}]0;t${BEL}-audit`], + lastUpdated: `Jun 5${ESC}[2K, 2026`, + source: 'anthropics/skills', + } as never, + ]), + ); + + const [plugin] = await discoverPlugins( + [{ name: 'Skills', source: 'anthropics/skills', type: 'github' }], + new Set(), + ); + + expect(plugin.marketplaceName).toBe('Hostile'); + expect(plugin.name).toBe('evil-plugin'); + expect(plugin.version).toBe('9.9.9'); + expect(plugin.description).toBe('Totally safe — trust me'); + expect(plugin.author).toBe('mallory'); + expect(plugin.homepage).toBe('https://evil.com'); + expect(plugin.category).toBe('tools'); + expect(plugin.lastUpdated).toBe('Jun 5, 2026'); + expect(plugin.components?.skills).toEqual(['pdf-audit']); + + // Belt-and-braces: no escape/control byte survives in any rendered field. + const rendered = [ + plugin.marketplaceName, + plugin.name, + plugin.version, + plugin.description, + plugin.author, + plugin.homepage, + plugin.category, + plugin.lastUpdated, + ...(plugin.components?.skills ?? []), + ].join('|'); + expect(rendered).not.toContain(ESC); + expect(rendered).not.toContain(BEL); + }); + it('derives install source from per-plugin source for http sources', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( config('Remote', [ From 631e271fba0e96c2b18bac795ea007b9971e3aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 11:39:27 +0800 Subject: [PATCH 47/48] =?UTF-8?q?fix(extensions):=20address=20review=20rou?= =?UTF-8?q?nd=205=20=E2=80=94=20scope-change=20rollback,=20version=20sanit?= =?UTF-8?q?ization,=20visible=20read=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Critical: the UI scope-change (ExtensionActionsView) and project-scope install (DiscoverTab) disabled User then enabled Workspace with no rollback — a failed Workspace enable left the extension disabled at all scopes (silently dead, and in DiscoverTab the outer catch swallowed it so the install still reported success). Mirror the CLI install.ts pattern: roll the User enable back on failure. - Persist the scope preference only AFTER enablement succeeds (install.ts + both UI paths), so a rolled-back enable can't leave prefs pointing at a scope the extension isn't actually enabled at (Installed tab mislabel). - Security: the persisted 'version' is rendered raw on the Installed tab and PluginDetailView — only 'name' is validated on load, so a marketplace plugin could inject ANSI/control sequences post-install on every render. Scrub via stripUnsafeCharacters at both sinks (covers already-installed extensions; the Discover-side sanitization doesn't reach this path). - Transient read errors in extensionPreferences/sourceRegistry only logged via the gated debugLogger, so a user's favorites/scopes/sources could vanish with no trail. Add an stderr warning matching quarantineCorruptFile. - Tests: assert quarantine runs on a parse error (.corrupted sibling) and does NOT run on a transient read error (EISDIR), for both stores. --- .../cli/src/commands/extensions/install.ts | 8 +++- .../extensions/tabs/DiscoverTab.tsx | 29 +++++++++++--- .../extensions/tabs/InstalledTab.tsx | 7 +++- .../extensions/views/ExtensionActionsView.tsx | 21 ++++++++-- .../extensions/views/PluginDetailView.tsx | 5 ++- .../extension/extensionPreferences.test.ts | 39 ++++++++++++++++++- .../src/extension/extensionPreferences.ts | 10 +++++ .../core/src/extension/sourceRegistry.test.ts | 36 +++++++++++++++++ packages/core/src/extension/sourceRegistry.ts | 10 +++++ 9 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 5fb90569d68..04e80e4202c 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -96,9 +96,10 @@ export async function handleInstall(args: InstallArgs) { ); const scope = normalizeScope(args.scope); if (args.scope) { - extensionManager.setExtensionScope(extension.name, scope); // installExtension auto-enables at the user (global) scope. For a - // project-scoped install, re-scope enablement to this workspace only. + // project-scoped install, re-scope enablement to this workspace only — + // BEFORE recording the scope preference, so a failed Workspace enable + // (which rolls back to User) can't leave the prefs claiming "project". if (scope === 'project') { await extensionManager.disableExtension( extension.name, @@ -129,6 +130,9 @@ export async function handleInstall(args: InstallArgs) { throw enableError; } } + // Enablement succeeded (or scope is user/local with no enablement change): + // now it's safe to persist the scope preference. + extensionManager.setExtensionScope(extension.name, scope); } writeStdoutLine( scope === 'project' diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 28c65b341c1..357955582d6 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -231,7 +231,6 @@ export const DiscoverTab = ({ // successful install to "failed" (which would prompt a confusing retry). installed++; try { - extensionManager.setExtensionScope(ext.name, scope); // installExtension auto-enables at User (global) scope. For a // workspace-scoped choice, re-scope enablement to this workspace // only: disable the global enable and enable for the workspace path. @@ -240,11 +239,31 @@ export const DiscoverTab = ({ ext.name, SettingScope.User, ); - await extensionManager.enableExtension( - ext.name, - SettingScope.Workspace, - ); + try { + await extensionManager.enableExtension( + ext.name, + SettingScope.Workspace, + ); + } catch (enableError) { + // The User-scope disable already landed; roll it back so a failed + // Workspace enable doesn't leave the extension disabled at every + // scope (the outer catch only logs, so the install still reports + // success — without this the extension would be silently dead). + try { + await extensionManager.enableExtension( + ext.name, + SettingScope.User, + ); + } catch { + // Best-effort rollback. + } + throw enableError; + } } + // Record the scope preference only after enablement succeeds, so the + // Installed tab can't show a "Project level" extension that is + // actually enabled at User scope after a rollback. + extensionManager.setExtensionScope(ext.name, scope); } catch (scopeError) { debugLogger.error( 'Installed extension but failed to apply scope preference:', diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 7eba1640075..e93542ce436 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -11,6 +11,7 @@ import { useKeypress } from '../../../hooks/useKeypress.js'; import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; import { keyMatchers, Command } from '../../../keyMatchers.js'; import { t } from '../../../../i18n/index.js'; +import { stripUnsafeCharacters } from '../../../utils/textUtils.js'; import { type Config, type Extension, @@ -746,7 +747,11 @@ export const InstalledTab = ({ item.kind === 'mcp' ? t('MCP') : t('Extension v{{version}}', { - version: item.extension.version, + // Persisted marketplace metadata: `version` is stored verbatim + // by the converter and only `name` is validated on load, so + // scrub it here (the Discover-side sanitization doesn't cover + // this persisted/Installed render path). + version: stripUnsafeCharacters(item.extension.version ?? ''), }); // MCP rows surface the live connection state — "enabled" alone would // read as usable even when the server failed to connect or still diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index 0901d83f1e1..db55daa792f 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -220,14 +220,29 @@ export const ExtensionActionsView = ({ const name = extension.name; setScopeBusy(true); try { - manager.setExtensionScope(name, newScope); - // Apply enablement: Global -> User; Project/Local -> workspace only. + // Apply enablement first: Global -> User; Project/Local -> workspace + // only. Record the scope preference only once enablement succeeds so a + // failed enable can't leave the prefs pointing at a scope the extension + // isn't actually enabled at. if (newScope === 'user') { await manager.enableExtension(name, SettingScope.User); } else { await manager.disableExtension(name, SettingScope.User); - await manager.enableExtension(name, SettingScope.Workspace); + try { + await manager.enableExtension(name, SettingScope.Workspace); + } catch (enableError) { + // The User-scope disable already landed; if the Workspace enable + // fails the extension would be disabled everywhere. Roll the User + // enable back so it isn't silently dead, then surface the error. + try { + await manager.enableExtension(name, SettingScope.User); + } catch { + // Best-effort rollback. + } + throw enableError; + } } + manager.setExtensionScope(name, newScope); setScope(newScope); setEnabled(true); onStatus({ diff --git a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx index 5e9479b996a..e7fd7571ed5 100644 --- a/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx +++ b/packages/cli/src/ui/components/extensions/views/PluginDetailView.tsx @@ -13,6 +13,7 @@ import { type Extension, } from '@qwen-code/qwen-code-core'; import { t } from '../../../../i18n/index.js'; +import { stripUnsafeCharacters } from '../../../utils/textUtils.js'; export type PluginDetailAction = | 'toggle' @@ -125,7 +126,9 @@ export const PluginDetailView = ({ {ext.name} - {ext.version} + + {stripUnsafeCharacters(ext.version ?? '')} + {scope} diff --git a/packages/core/src/extension/extensionPreferences.test.ts b/packages/core/src/extension/extensionPreferences.test.ts index b8b1ff39774..04a3a0c2973 100644 --- a/packages/core/src/extension/extensionPreferences.test.ts +++ b/packages/core/src/extension/extensionPreferences.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -137,4 +137,41 @@ describe('ExtensionPreferencesStore', () => { expect(store.toggleFavorite('alpha')).toBe(true); expect(store.isFavorite('alpha')).toBe(true); }); + + it('quarantines a corrupt file (parse error) to a .corrupted sibling', () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{ not valid json'); + + expect(store.getFavorites()).toEqual([]); + + // The unparseable file is moved aside so the next write can't clobber it. + expect(fs.existsSync(`${filePath}.corrupted`)).toBe(true); + expect(fs.readFileSync(`${filePath}.corrupted`, 'utf-8')).toBe( + '{ not valid json', + ); + expect(stderr).toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it('does NOT quarantine on a transient read error, but warns on stderr', () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // A path that exists but momentarily can't be read as a file (here a + // directory → EISDIR; same class as EACCES/EMFILE) must NOT be moved aside: + // only a genuine JSON parse failure quarantines. statSync succeeds, then + // readFileSync throws the transient error → outer catch returns defaults. + fs.mkdirSync(filePath, { recursive: true }); + + expect(store.getFavorites()).toEqual([]); + + // Not quarantined, and the path is left untouched for the next read. + expect(fs.existsSync(`${filePath}.corrupted`)).toBe(false); + expect(fs.existsSync(filePath)).toBe(true); + expect(stderr).toHaveBeenCalled(); + stderr.mockRestore(); + }); }); diff --git a/packages/core/src/extension/extensionPreferences.ts b/packages/core/src/extension/extensionPreferences.ts index eb945f1140c..5c08f7a7d3b 100644 --- a/packages/core/src/extension/extensionPreferences.ts +++ b/packages/core/src/extension/extensionPreferences.ts @@ -119,6 +119,16 @@ export class ExtensionPreferencesStore { // A transient read error (permission/too-many-files/…) — the file may be // perfectly valid, so do NOT quarantine it here; only parse failures // above do that. Return the default for this read. + // + // `debugLogger.error` is gated behind QWEN_DEBUG_LOG_FILE (unset for + // almost all users), so without an stderr line the user's + // favorites/scopes would appear to vanish with no trail. Mirror the + // `quarantineCorruptFile` pattern and surface it on stderr too. + process.stderr.write( + `[warn] Could not read extension preferences at ${this.filePath}: ${ + error instanceof Error ? error.message : String(error) + }. Using defaults for this session.\n`, + ); debugLogger.error('Error reading extension preferences:', error); return emptyPreferences(); } diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index d8127bc9d73..af5eca3cfe8 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -89,6 +89,42 @@ describe('SourceRegistryStore', () => { expect(store.read().map((s) => s.name)).toEqual(['B']); expect(store.remove('missing')).toBe(false); }); + + it('quarantines a corrupt registry (parse error) to a .corrupted sibling', () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '[ not valid json'); + + expect(store.read()).toEqual([]); + + // The unparseable file is moved aside so the next add/remove can't clobber + // a recoverable (e.g. truncated) source list with the empty default. + expect(fs.existsSync(`${filePath}.corrupted`)).toBe(true); + expect(fs.readFileSync(`${filePath}.corrupted`, 'utf-8')).toBe( + '[ not valid json', + ); + expect(stderr).toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it('does NOT quarantine on a transient read error, but warns on stderr', () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // A path that exists but momentarily can't be read as a file (here a + // directory → EISDIR; same class as EACCES/EMFILE) must NOT be moved aside: + // only a genuine JSON parse failure quarantines. + fs.mkdirSync(filePath, { recursive: true }); + + expect(store.read()).toEqual([]); + + expect(fs.existsSync(`${filePath}.corrupted`)).toBe(false); + expect(fs.existsSync(filePath)).toBe(true); + expect(stderr).toHaveBeenCalled(); + stderr.mockRestore(); + }); }); describe('discoverPlugins', () => { diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 854dd32cb01..28dc078a0f4 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -245,6 +245,16 @@ export class SourceRegistryStore { } // A transient read error (permission/too-many-files/…) — the file may be // valid, so do NOT quarantine it; only a parse failure below does that. + // + // `debugLogger.error` is gated behind QWEN_DEBUG_LOG_FILE (unset for + // almost all users), so without an stderr line the user's source list + // would appear to vanish with no trail. Mirror the + // `quarantineCorruptFile` pattern and surface it on stderr too. + process.stderr.write( + `[warn] Could not read marketplace registry at ${this.filePath}: ${ + error instanceof Error ? error.message : String(error) + }. Using an empty source list for this session.\n`, + ); debugLogger.error('Error reading marketplace registry:', error); return []; } From 158002c77325f34339bf840e95001be3fe3973ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Sat, 20 Jun 2026 12:48:27 +0800 Subject: [PATCH 48/48] =?UTF-8?q?fix(extensions):=20address=20review=20rou?= =?UTF-8?q?nd=206=20=E2=80=94=20marketplace-name=20sanitization,=20rollbac?= =?UTF-8?q?k-failure=20surfacing,=20url-source=20guard,=20security=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - ANSI injection via the Discover marketplace filter: the marketplace name (untrusted, from a remote marketplace.json) flowed through onBrowse to the Discover hint render unsanitized. Scrub it in handleBrowseSource — this also fixes the filter comparison (it is matched against the already-sanitized DiscoveredPlugin.marketplaceName). - '(Tab to clear)' hint was misleading: Tab cycled tabs rather than clearing the marketplace filter in place. On Discover with an active filter, Tab now clears the filter in place, matching the hint. - Scope-change rollback failures were silently swallowed by a bare catch in both the Discover batch install and ExtensionActionsView, unlike the CLI install.ts which surfaces them. Both now report the rollback failure so the user knows the extension may be disabled at all scopes (new i18n keys for en/zh/zh-TW). - resolveInstallSource: the structured { source: 'url' } branch bypassed the local-path guard applied to string sources, letting a remote http marketplace redirect the installer at a local filesystem path. Apply the same guard. Tests: - marketplace fetchUrl: wall-clock deadline (stalled server) and body-size cap (oversized stream) now covered. - sourceRegistry: remote-marketplace local-path rejection covered for both the string and { source: 'url' } source forms. - claude-converter git-subdir: clone+sha-pin happy path plus path-escape, absolute-path, missing-subdir, and symlink-escape rejections covered. Note: the bot's 'missing scope rollback' threads target a pre-631e271fb snapshot — that rollback already landed in round 5. --- packages/cli/src/i18n/locales/en.js | 4 + packages/cli/src/i18n/locales/zh-TW.js | 4 + packages/cli/src/i18n/locales/zh.js | 4 + .../extensions/ExtensionsManagerDialog.tsx | 18 ++- .../extensions/tabs/DiscoverTab.tsx | 17 ++- .../extensions/views/ExtensionActionsView.tsx | 14 +- .../src/extension/claude-converter.test.ts | 133 +++++++++++++++++- .../core/src/extension/marketplace.test.ts | 57 ++++++++ .../core/src/extension/sourceRegistry.test.ts | 45 ++++++ packages/core/src/extension/sourceRegistry.ts | 14 ++ 10 files changed, 302 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 07e1b886ff3..1664fd64edc 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -89,6 +89,10 @@ export default { 'Installed extension "{{name}}".': 'Installed extension "{{name}}".', 'Installed extensions ({{count}}):': 'Installed extensions ({{count}}):', 'Installed {{count}} extension(s).': 'Installed {{count}} extension(s).', + '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.': + '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.', + 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})': + 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})', 'Installed {{ok}}, failed {{fail}}: {{detail}}': 'Installed {{ok}}, failed {{fail}}: {{detail}}', 'Installing...': 'Installing...', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index c61c2a9383e..dadac0d99a8 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -86,6 +86,10 @@ export default { 'Installed extension "{{name}}".': '已安裝擴展 "{{name}}"。', 'Installed extensions ({{count}}):': '已安裝的擴展({{count}}):', 'Installed {{count}} extension(s).': '已安裝 {{count}} 個擴展。', + '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.': + '{{name}}:已安裝,但作用域回滾失敗 —— 該擴展可能在所有作用域均被停用;請在「已安裝」頁重新啟用。', + 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})': + '無法變更作用域,且回滾也失敗 ——「{{name}}」可能在所有作用域均被停用。請在「已安裝」頁重新啟用。({{error}})', 'Installed {{ok}}, failed {{fail}}: {{detail}}': '成功 {{ok}} 個,失敗 {{fail}} 個:{{detail}}', 'Installing...': '安裝中...', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 79ff24419f7..a832654bafe 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -84,6 +84,10 @@ export default { 'Installed extension "{{name}}".': '已安装扩展 "{{name}}"。', 'Installed extensions ({{count}}):': '已安装的扩展({{count}}):', 'Installed {{count}} extension(s).': '已安装 {{count}} 个扩展。', + '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.': + '{{name}}:已安装,但作用域回滚失败 —— 该扩展可能在所有作用域均被禁用;请在“已安装”页重新启用。', + 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})': + '无法更改作用域,且回滚也失败 ——“{{name}}”可能在所有作用域均被禁用。请在“已安装”页重新启用。({{error}})', 'Installed {{ok}}, failed {{fail}}: {{detail}}': '成功 {{ok}} 个,失败 {{fail}} 个:{{detail}}', 'Installing...': '安装中...', diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 0aa19c12944..1795ca988aa 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -11,6 +11,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { t } from '../../../i18n/index.js'; +import { stripUnsafeCharacters } from '../../utils/textUtils.js'; import { EXTENSIONS_TABS, type ExtensionsTab, @@ -110,7 +111,12 @@ export function ExtensionsManagerDialog({ const handleBrowseSource = useCallback((marketplaceName: string) => { setStatus(null); setTabLocked(false); - setDiscoverFilter(marketplaceName); + // The marketplace name is untrusted (it originates from a remote + // marketplace.json). Scrub terminal escapes before it becomes the Discover + // filter: it is rendered as a hint AND compared against the + // already-sanitized DiscoveredPlugin.marketplaceName, so sanitizing here + // both blocks ANSI injection and keeps the filter comparison matching. + setDiscoverFilter(stripUnsafeCharacters(marketplaceName)); setActiveTab(EXTENSIONS_TABS.DISCOVER); }, []); @@ -126,7 +132,15 @@ export function ExtensionsManagerDialog({ useKeypress( (key) => { if (key.name === 'tab') { - cycleTab(key.shift ? -1 : 1); + // On Discover with an active marketplace filter, Tab clears the filter + // in place (revealing all extensions) instead of leaving the tab — this + // is what the "(Tab to clear)" hint promises. Otherwise it cycles tabs. + if (activeTab === EXTENSIONS_TABS.DISCOVER && discoverFilter) { + setStatus(null); + setDiscoverFilter(null); + } else { + cycleTab(key.shift ? -1 : 1); + } } else if (key.name === 'right') { cycleTab(1); } else if (key.name === 'left') { diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 357955582d6..779fbbcf04d 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -254,8 +254,21 @@ export const DiscoverTab = ({ ext.name, SettingScope.User, ); - } catch { - // Best-effort rollback. + } catch (rollbackError) { + // Rollback failed: the extension is now disabled at every scope. + // The outer catch only debug-logs, so surface it through the + // batch error list — otherwise the user is told the install + // succeeded with no hint the extension is silently dead. + debugLogger.error( + 'Scope rollback failed after install:', + rollbackError, + ); + errors.push( + t( + '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.', + { name: plugin.name }, + ), + ); } throw enableError; } diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index db55daa792f..ce00eff15db 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -233,11 +233,19 @@ export const ExtensionActionsView = ({ } catch (enableError) { // The User-scope disable already landed; if the Workspace enable // fails the extension would be disabled everywhere. Roll the User - // enable back so it isn't silently dead, then surface the error. + // enable back so it isn't silently dead. try { await manager.enableExtension(name, SettingScope.User); - } catch { - // Best-effort rollback. + } catch (rollbackError) { + // Rollback also failed: the extension is now disabled at every + // scope. Surface that explicitly — the bare enable error wouldn't + // tell the user the extension is dead and needs manual recovery. + throw new Error( + t( + 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})', + { name, error: getErrorMessage(rollbackError) }, + ), + ); } throw enableError; } diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index f60c33c7a2b..517504fea77 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -19,9 +19,22 @@ import { type ClaudeMarketplacePluginConfig, type ClaudeMarketplaceConfig, } from './claude-converter.js'; +import { cloneFromGit } from './github.js'; import { HookType } from '../hooks/types.js'; import { performVariableReplacement } from './variables.js'; +// The git-subdir source clones a repo; stub the network clone so the security +// guards around the cloned subdirectory can be exercised against a real fs. +// Other tests use local sources and never call these, so the stubs are inert. +vi.mock('./github.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cloneFromGit: vi.fn(), + downloadFromGitHubRelease: vi.fn(), + }; +}); + describe('convertClaudeToQwenConfig', () => { it('should convert basic Claude config', () => { const claudeConfig: ClaudePluginConfig = { @@ -1225,3 +1238,121 @@ describe('performVariableReplacement for Claude extensions', () => { expect(result).not.toContain('.message.content'); }); }); + +describe('convertClaudePluginPackage — git-subdir source', () => { + let extDir: string; + + beforeEach(() => { + extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-gitsub-')); + vi.mocked(cloneFromGit).mockReset(); + }); + + afterEach(() => { + if (fs.existsSync(extDir)) { + fs.rmSync(extDir, { recursive: true, force: true }); + } + }); + + // Writes a marketplace.json declaring a single git-subdir plugin. + const writeMarketplace = (source: unknown) => { + const mp = path.join(extDir, '.claude-plugin'); + fs.mkdirSync(mp, { recursive: true }); + fs.writeFileSync( + path.join(mp, 'marketplace.json'), + JSON.stringify({ + name: 'm', + owner: { name: 'o', email: 'e' }, + plugins: [{ name: 'p', version: '1.0.0', source }], + }), + 'utf-8', + ); + }; + + it('clones, pins to the sha over the ref, and returns the subdirectory', async () => { + vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => { + const sub = path.join(dir as string, 'packages', 'plugin'); + fs.mkdirSync(path.join(sub, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(sub, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'p', version: '1.0.0' }), + 'utf-8', + ); + }); + + writeMarketplace({ + source: 'git-subdir', + url: 'https://example.com/repo.git', + path: 'packages/plugin', + ref: 'main', + sha: 'abc123', + }); + + const result = await convertClaudePluginPackage(extDir, 'p'); + expect(result.config.name).toBe('p'); + // The immutable sha is preferred over the named ref when both are present. + const meta = vi.mocked(cloneFromGit).mock.calls[0][0] as { ref?: string }; + expect(meta.ref).toBe('abc123'); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('rejects a subdirectory that escapes the repository root', async () => { + vi.mocked(cloneFromGit).mockResolvedValue(undefined as never); + writeMarketplace({ + source: 'git-subdir', + url: 'https://example.com/repo.git', + path: '../../etc', + }); + await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow( + /escapes the repository root/, + ); + }); + + it('rejects an absolute subdirectory path', async () => { + vi.mocked(cloneFromGit).mockResolvedValue(undefined as never); + writeMarketplace({ + source: 'git-subdir', + url: 'https://example.com/repo.git', + path: path.resolve(path.sep, 'etc'), + }); + await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow( + /Invalid plugin subdirectory/, + ); + }); + + it('rejects a missing subdirectory', async () => { + vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => { + // The clone succeeded but does not contain the requested subdir. + fs.mkdirSync(path.join(dir as string, 'other'), { recursive: true }); + }); + writeMarketplace({ + source: 'git-subdir', + url: 'https://example.com/repo.git', + path: 'packages/missing', + }); + await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow( + /not found/, + ); + }); + + it('rejects a subdirectory that is a symlink escaping the clone', async () => { + const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-')); + fs.writeFileSync(path.join(secretDir, 'SKILL.md'), 'secret', 'utf-8'); + vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => { + // A hostile repo commits the subdir as a symlink whose name stays inside + // the clone but whose target escapes it. + fs.symlinkSync(secretDir, path.join(dir as string, 'sub')); + }); + writeMarketplace({ + source: 'git-subdir', + url: 'https://example.com/repo.git', + path: 'sub', + }); + + await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow( + /resolves through a symlink/, + ); + + fs.rmSync(secretDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 2886a77addd..205b43b941b 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -359,5 +359,62 @@ describe('parseInstallSource', () => { ); expect(result).toEqual(cfg); }); + + // A non-GitHub https URL reaches fetchUrl via a single direct-JSON fetch, + // so these exercise the fetchUrl security guards in isolation. + it('aborts and returns null when the response body exceeds the size cap', async () => { + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + const destroy = vi.fn(); + vi.mocked(https.get).mockImplementation((_url, _options, callback) => { + const handlers: Record void> = {}; + const res = { + statusCode: 200, + resume: vi.fn(), + on: vi.fn((event: string, handler: (chunk?: Buffer) => void) => { + handlers[event] = handler; + }), + }; + if (typeof callback === 'function') callback(res as never); + // Emit one chunk past the 10 MB cap AFTER `req` is assigned in fetchUrl + // (the real `https.get` invokes the response callback asynchronously); + // 'end' never fires, so the guard must abort mid-stream. + process.nextTick(() => + handlers['data']?.(Buffer.alloc(11 * 1024 * 1024)), + ); + return { on: vi.fn(), setTimeout: vi.fn(), destroy } as never; + }); + + const result = await loadMarketplaceConfigFromSource( + 'https://example.com/marketplace.json', + ); + expect(result).toBeNull(); + expect(destroy).toHaveBeenCalled(); + }); + + it('aborts and returns null when the wall-clock deadline elapses', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + const destroy = vi.fn(); + vi.mocked(https.get).mockImplementation((_url, _options, callback) => { + // A stalled/trickling server: connects with 200 but never emits + // 'data' or 'end'. The socket-idle req.setTimeout (mocked no-op) would + // never fire, so only the absolute wall-clock deadline can resolve it. + const res = { statusCode: 200, resume: vi.fn(), on: vi.fn() }; + if (typeof callback === 'function') callback(res as never); + return { on: vi.fn(), setTimeout: vi.fn(), destroy } as never; + }); + + const promise = loadMarketplaceConfigFromSource( + 'https://example.com/marketplace.json', + ); + // MARKETPLACE_FETCH_TIMEOUT_MS is 10s; advance just past it. + await vi.advanceTimersByTimeAsync(10_000 + 50); + await expect(promise).resolves.toBeNull(); + expect(destroy).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); }); diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index af5eca3cfe8..438c966849b 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -290,6 +290,51 @@ describe('discoverPlugins', () => { ); }); + it('rejects local-path sources from a remote (http) marketplace', async () => { + // A hostile remote marketplace must not be able to point the installer at a + // local filesystem path — via either the bare-string source or the + // structured { source: 'url' } form. Both fall back to the plugin name. + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [ + { name: 'abs', version: '1.0.0', source: '/etc/passwd' }, + { name: 'rel', version: '1.0.0', source: '../../secret' }, + { name: 'home', version: '1.0.0', source: '~/.ssh/id_rsa' }, + { + name: 'urlabs', + version: '1.0.0', + source: { source: 'url', url: '/etc/shadow' }, + }, + { + name: 'urlrel', + version: '1.0.0', + source: { source: 'url', url: '../escape' }, + }, + { + name: 'urlok', + version: '1.0.0', + source: { source: 'url', url: 'https://example.com/p.tgz' }, + }, + ] as never), + ); + + const discovered = await discoverPlugins( + [{ name: 'Remote', source: 'https://x/m.json', type: 'http' }], + new Set(), + ); + + const src = (name: string) => + discovered.find((p) => p.name === name)!.installSource; + // String local paths fall back to the bare plugin name (no redirect). + expect(src('abs')).toBe('abs'); + expect(src('rel')).toBe('rel'); + expect(src('home')).toBe('home'); + // { source: 'url' } local paths are rejected too (previously bypassed). + expect(src('urlabs')).toBe('urlabs'); + expect(src('urlrel')).toBe('urlrel'); + // A genuine remote URL is preserved. + expect(src('urlok')).toBe('https://example.com/p.tgz'); + }); + it('skips sources that fail to load without throwing', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( async (source: string) => { diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 28dc078a0f4..f3ac7ba6f34 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -179,6 +179,20 @@ function resolveInstallSource( return `${src.repo}:${plugin.name}`; } if (src && src.source === 'url') { + // Same local-path guard as the string-source branch above: a remote + // marketplace must not be able to redirect the installer at a local + // filesystem path via the structured `{ source: 'url' }` form either. + if ( + typeof src.url === 'string' && + (path.isAbsolute(src.url) || + src.url.startsWith('.') || + src.url.startsWith('~')) + ) { + debugLogger.warn( + `Ignoring local path source "${src.url}" from remote marketplace "${marketplace.source}".`, + ); + return plugin.name; + } return src.url; } return plugin.name;