diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index ec905ee0..7b736687 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog,
mcpBrowse,
mcpFetchInstalled,
+ apiKeysLoading,
pluginCatalogLoading,
mcpBrowseLoading,
skillsLoading,
@@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse,
mcpFetchInstalled: s.mcpFetchInstalled,
+ apiKeysLoading: s.apiKeysLoading,
pluginCatalogLoading: s.pluginCatalogLoading,
mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading,
@@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
// Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => {
+ void fetchCliStatus();
+ void fetchApiKeys();
void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(projectPath ?? undefined);
- }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
+ }, [
+ fetchApiKeys,
+ fetchCliStatus,
+ fetchPluginCatalog,
+ fetchSkillsCatalog,
+ mcpBrowse,
+ mcpFetchInstalled,
+ projectPath,
+ ]);
- const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
+ const isRefreshing =
+ cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
const cliStatusBanner = useMemo(() => {
if (cliStatusLoading || cliStatus === null) {
return (
diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx
index 3a2a9bfb..ee73899b 100644
--- a/src/renderer/components/extensions/plugins/PluginCard.tsx
+++ b/src/renderer/components/extensions/plugins/PluginCard.tsx
@@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge';
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
+ hasInstallationInScope,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
@@ -29,6 +30,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
+ const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
const baseStriped = index % 2 === 0;
const smStriped = Math.floor(index / 2) % 2 === 0;
const xlStriped = Math.floor(index / 3) % 2 === 0;
@@ -112,9 +114,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
e.stopPropagation()}>
installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
- onUninstall={() => uninstallPlugin(plugin.pluginId)}
+ onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
size="sm"
errorMessage={installError}
/>
diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
index 23721946..1e40866a 100644
--- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
+++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
@@ -26,6 +26,7 @@ import {
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
+ hasInstallationInScope,
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
@@ -54,13 +55,21 @@ export const PluginDetailDialog = ({
open,
onClose,
}: PluginDetailDialogProps): React.JSX.Element => {
- const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
+ const {
+ fetchPluginReadme,
+ readmes,
+ readmeLoading,
+ installPlugin,
+ uninstallPlugin,
+ pluginCatalogProjectPath,
+ } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin,
+ pluginCatalogProjectPath: s.pluginCatalogProjectPath,
}))
);
const installProgress = useStore(
@@ -69,6 +78,7 @@ export const PluginDetailDialog = ({
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState('user');
+ const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
useEffect(() => {
if (plugin && open) {
@@ -76,12 +86,19 @@ export const PluginDetailDialog = ({
}
}, [plugin, open, fetchPluginReadme]);
+ useEffect(() => {
+ if (scope === 'project' && !projectScopeAvailable) {
+ setScope('user');
+ }
+ }, [projectScopeAvailable, scope]);
+
if (!plugin) return <>>;
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const readme = readmes[plugin.pluginId];
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
+ const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
return (
installPlugin({ pluginId: plugin.pluginId, scope })}
- onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
+ isInstalled={isInstalledForScope}
+ onInstall={() =>
+ installPlugin({
+ pluginId: plugin.pluginId,
+ scope,
+ ...(scope === 'project' && pluginCatalogProjectPath
+ ? { projectPath: pluginCatalogProjectPath }
+ : {}),
+ })
+ }
+ onUninstall={() =>
+ uninstallPlugin(
+ plugin.pluginId,
+ scope,
+ scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined
+ )
+ }
size="default"
errorMessage={installError}
/>
diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts
index 5f86ca7b..44b0dbc1 100644
--- a/src/shared/utils/extensionNormalizers.ts
+++ b/src/shared/utils/extensionNormalizers.ts
@@ -2,7 +2,12 @@
* Pure-function normalizers for Extension Store data.
*/
-import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions';
+import type {
+ InstallScope,
+ InstalledPluginEntry,
+ PluginCapability,
+ PluginCatalogItem,
+} from '@shared/types/extensions';
/**
* Normalize a repository URL for dedup comparison.
@@ -94,6 +99,16 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
return `${pluginName}@${marketplaceName}`;
}
+/**
+ * Check whether a plugin has an installation for the selected scope.
+ */
+export function hasInstallationInScope(
+ installations: Pick[],
+ scope: InstallScope
+): boolean {
+ return installations.some((installation) => installation.scope === scope);
+}
+
/**
* Sanitize an MCP server display name into a CLI-safe server name.
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts
index ce40a00d..b2cc7347 100644
--- a/test/shared/utils/extensionNormalizers.test.ts
+++ b/test/shared/utils/extensionNormalizers.test.ts
@@ -7,6 +7,7 @@ import {
formatInstallCount,
getCapabilityLabel,
getPrimaryCapabilityLabel,
+ hasInstallationInScope,
inferCapabilities,
normalizeCategory,
normalizeRepoUrl,
@@ -149,6 +150,24 @@ describe('buildPluginId', () => {
});
});
+describe('hasInstallationInScope', () => {
+ it('returns true when the selected scope exists', () => {
+ expect(
+ hasInstallationInScope(
+ [
+ { scope: 'user' },
+ { scope: 'project' },
+ ],
+ 'project',
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false when the selected scope is missing', () => {
+ expect(hasInstallationInScope([{ scope: 'user' }], 'project')).toBe(false);
+ });
+});
+
describe('sanitizeMcpServerName', () => {
it('lowercases and replaces spaces with dashes', () => {
expect(sanitizeMcpServerName('My Server')).toBe('my-server');