diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx
index 0880639f..42d419c8 100644
--- a/packages/ui/src/apps/MobileApp.tsx
+++ b/packages/ui/src/apps/MobileApp.tsx
@@ -83,6 +83,7 @@ const MOBILE_SETTINGS_PAGES = [
'providers',
'usage',
'voice',
+ 'integrations',
'about',
] as const;
diff --git a/packages/ui/src/assets/provider-logos/claude-code.svg b/packages/ui/src/assets/provider-logos/claude-code.svg
new file mode 100644
index 00000000..9a454303
--- /dev/null
+++ b/packages/ui/src/assets/provider-logos/claude-code.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/ui/src/assets/provider-logos/opencode.svg b/packages/ui/src/assets/provider-logos/opencode.svg
new file mode 100644
index 00000000..16a0e9ef
--- /dev/null
+++ b/packages/ui/src/assets/provider-logos/opencode.svg
@@ -0,0 +1,8 @@
+
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts
index 769b58cd..b93478e6 100644
--- a/packages/ui/src/components/icon/sprite.ts
+++ b/packages/ui/src/components/icon/sprite.ts
@@ -51,6 +51,7 @@ export const iconSpriteData = {
"checkbox-blank-circle-fill": ``,
"checkbox-circle": ``,
"checkbox-multiple": ``,
+ "claude-code": ``,
"clipboard": ``,
"close": ``,
"close-circle": ``,
@@ -62,11 +63,12 @@ export const iconSpriteData = {
"code-sslash": ``,
"collapse-vertical": ``,
"command": ``,
+ "command-code": ``,
"compass-3": ``,
"computer": ``,
"contract-up-down": ``,
"corner-down-left": ``,
- "cursor": ``,
+ "cursor": ``,
"database-2": ``,
"delete-bin": ``,
"discord-fill": ``,
diff --git a/packages/ui/src/components/sections/integrations/IntegrationCard.test.tsx b/packages/ui/src/components/sections/integrations/IntegrationCard.test.tsx
new file mode 100644
index 00000000..cc9f9aa2
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/IntegrationCard.test.tsx
@@ -0,0 +1,54 @@
+import { describe, expect, test } from 'bun:test';
+import React from 'react';
+
+const integrationCards = await import('./Integration' + 'Card').catch(() => ({}));
+
+type IntegrationCardComponent = (props: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ header: React.ReactNode;
+ headerAction?: React.ReactNode;
+ children: React.ReactNode;
+}) => React.ReactElement;
+
+const IntegrationCard = (
+ integrationCards as unknown as {
+ IntegrationCard?: IntegrationCardComponent;
+ }
+).IntegrationCard;
+
+describe('IntegrationCard', () => {
+ test('keeps header controls outside the expansion button', () => {
+ expect(typeof IntegrationCard).toBe('function');
+ if (!IntegrationCard) return;
+
+ const onOpenChange = () => undefined;
+ const toggle = ;
+ const card = IntegrationCard({
+ open: false,
+ onOpenChange,
+ header: Integration,
+ headerAction: toggle,
+ children:
Details
,
+ });
+
+ const cardProps = card.props as {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactElement;
+ };
+ expect(cardProps.open).toBe(false);
+ expect(cardProps.onOpenChange).toBe(onOpenChange);
+
+ const surface = cardProps.children as React.ReactElement<{ children: React.ReactNode }>;
+ const headerRow = React.Children.toArray(surface.props.children)[0] as React.ReactElement<{
+ children: React.ReactNode;
+ }>;
+ const headerChildren = React.Children.toArray(headerRow.props.children) as React.ReactElement<{
+ children?: React.ReactNode;
+ }>[];
+
+ expect(headerChildren[0]?.type).toBe('button');
+ expect(headerChildren[1]?.props.children).toBe(toggle);
+ });
+});
diff --git a/packages/ui/src/components/sections/integrations/IntegrationCard.tsx b/packages/ui/src/components/sections/integrations/IntegrationCard.tsx
new file mode 100644
index 00000000..c1440e12
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/IntegrationCard.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
+import { cn } from '@/lib/utils';
+
+interface IntegrationCardProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ header: React.ReactNode;
+ headerAction?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+ contentClassName?: string;
+ settingsItem?: string;
+}
+
+/**
+ * Shared chrome for an integration summary and its on-demand configuration.
+ * Header actions sit beside—not inside—the expansion button so their native
+ * controls preserve their own keyboard and click behavior.
+ */
+export const IntegrationCard: React.FC = ({
+ open,
+ onOpenChange,
+ header,
+ headerAction,
+ children,
+ className,
+ contentClassName,
+ settingsItem,
+}) => {
+ return (
+
+
+
+
+ {headerAction ?
{headerAction}
: null}
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
new file mode 100644
index 00000000..fe7f00e9
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
+import { useI18n } from '@/lib/i18n';
+import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
+
+interface IntegrationsPageProps {
+ onOpenProviderSetup: (providerId: string) => Promise;
+ onOpenPluginManager: () => void;
+}
+
+export const IntegrationsPage: React.FC = ({
+ onOpenProviderSetup,
+ onOpenPluginManager,
+}) => {
+ const { t } = useI18n();
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx
new file mode 100644
index 00000000..e69e086e
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx
@@ -0,0 +1,442 @@
+import React from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { toast } from '@/components/ui';
+import { Icon } from '@/components/icon/Icon';
+import { SettingsSection } from '@/components/sections/shared/SettingsSection';
+import { useI18n } from '@/lib/i18n';
+import { openExternalUrl } from '@/lib/url';
+import { cn } from '@/lib/utils';
+import {
+ usePluginsStore,
+ type PluginMutationResult,
+} from '@/stores/usePluginsStore';
+import {
+ getCatalogPluginPrimaryAction,
+ getCatalogPluginPresentation,
+ getCatalogPluginState,
+ getLatestNpmSpec,
+ THIRD_PARTY_PLUGINS,
+ type ThirdPartyPluginDefinition,
+} from './thirdPartyPlugins';
+import { IntegrationCard } from './IntegrationCard';
+
+type PendingAction = 'install' | 'update' | 'setup' | 'remove';
+
+type RemoveTarget = ThirdPartyPluginDefinition | null;
+
+interface ThirdPartyIntegrationsSectionProps {
+ onOpenProviderSetup: (providerId: string) => Promise;
+ onOpenPluginManager: () => void;
+}
+
+const requiresRestart = (result: PluginMutationResult): boolean =>
+ result.restartDeferred === true
+ || result.requiresManualRestart === true
+ || result.reloadFailed === true;
+
+export const ThirdPartyIntegrationsSection: React.FC = ({
+ onOpenProviderSetup,
+ onOpenPluginManager,
+}) => {
+ const { t } = useI18n();
+ const {
+ entries,
+ registryInfo,
+ isLoadingRegistry,
+ loadPlugins,
+ loadRegistryInfo,
+ createEntry,
+ updateEntry,
+ deleteEntry,
+ } = usePluginsStore(
+ useShallow((state) => ({
+ entries: state.entries,
+ registryInfo: state.registryInfo,
+ isLoadingRegistry: state.isLoadingRegistry,
+ loadPlugins: state.loadPlugins,
+ loadRegistryInfo: state.loadRegistryInfo,
+ createEntry: state.createEntry,
+ updateEntry: state.updateEntry,
+ deleteEntry: state.deleteEntry,
+ })),
+ );
+
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+ const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
+ const [pendingAction, setPendingAction] = React.useState<{
+ pluginId: string;
+ action: PendingAction;
+ } | null>(null);
+ const [restartRequiredIds, setRestartRequiredIds] = React.useState>(
+ () => new Set(),
+ );
+ const [providerUnavailableIds, setProviderUnavailableIds] = React.useState>(
+ () => new Set(),
+ );
+ const [removeTarget, setRemoveTarget] = React.useState(null);
+ const [openPluginIds, setOpenPluginIds] = React.useState>(() => new Set());
+
+ const refresh = React.useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const pluginsLoaded = await loadPlugins({ force: true });
+ if (!pluginsLoaded) {
+ setRegistryLoadFailed(true);
+ return;
+ }
+
+ const latestEntries = usePluginsStore.getState().entries;
+ const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
+ for (const entry of latestEntries) {
+ if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
+ specs.add(entry.spec);
+ }
+ }
+ const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
+ setRegistryLoadFailed(!registryLoaded);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [loadPlugins, loadRegistryInfo]);
+
+ React.useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
+ setRestartRequiredIds((current) => {
+ const next = new Set(current);
+ if (required) next.add(pluginId);
+ else next.delete(pluginId);
+ return next;
+ });
+ }, []);
+
+ const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
+ setProviderUnavailableIds((current) => {
+ const next = new Set(current);
+ if (unavailable) next.add(pluginId);
+ else next.delete(pluginId);
+ return next;
+ });
+ }, []);
+
+ const runMutation = React.useCallback(async (
+ plugin: ThirdPartyPluginDefinition,
+ action: Exclude,
+ run: () => Promise,
+ ) => {
+ setPendingAction({ pluginId: plugin.id, action });
+ try {
+ const result = await run();
+ if (!result.ok) {
+ toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
+ return;
+ }
+
+ setProviderUnavailable(plugin.id, false);
+ if (action === 'install') {
+ toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }));
+ } else if (action === 'update') {
+ toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }));
+ } else {
+ toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }));
+ }
+
+ const restartNeeded = requiresRestart(result);
+ setRestartRequired(plugin.id, restartNeeded);
+ if (restartNeeded) {
+ toast.warning(t('settings.integrations.thirdParty.status.restartRequired'));
+ }
+ await refresh();
+ } finally {
+ setPendingAction(null);
+ }
+ }, [refresh, setProviderUnavailable, setRestartRequired, t]);
+
+ const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
+ const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
+ const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
+
+ if (action === 'manage') {
+ onOpenPluginManager();
+ return;
+ }
+
+ if (action === 'setup') {
+ setPendingAction({ pluginId: plugin.id, action });
+ try {
+ const opened = await onOpenProviderSetup(plugin.providerId);
+ setProviderUnavailable(plugin.id, !opened);
+ if (!opened) {
+ toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
+ }
+ } finally {
+ setPendingAction(null);
+ }
+ return;
+ }
+
+ const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
+ if (!latestSpec) {
+ setRegistryLoadFailed(true);
+ return;
+ }
+
+ if (action === 'install') {
+ await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
+ return;
+ }
+
+ if (state.userEntry) {
+ await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
+ }
+ }, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
+
+ const handleRemove = React.useCallback(async () => {
+ const plugin = removeTarget;
+ if (!plugin) return;
+ const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
+ if (!state.userEntry || state.userEntryIsAmbiguous) {
+ setRemoveTarget(null);
+ onOpenPluginManager();
+ return;
+ }
+ setRemoveTarget(null);
+ await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
+ }, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
+
+ const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
+ setOpenPluginIds((current) => {
+ const next = new Set(current);
+ if (open) next.add(pluginId);
+ else next.delete(pluginId);
+ return next;
+ });
+ }, []);
+
+ const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
+ const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
+ const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
+ const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
+ const isPending = pendingAction?.pluginId === plugin.id;
+ const isRestartRequired = restartRequiredIds.has(plugin.id);
+ const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
+ const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
+ const actionDisabled = isPending
+ || isRestartRequired
+ || ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
+ const presentation = getCatalogPluginPresentation(state, {
+ registryUnavailable,
+ restartRequired: isRestartRequired,
+ providerUnavailable: isProviderUnavailable,
+ });
+ let status: string;
+ switch (presentation.status) {
+ case 'installed-version':
+ status = presentation.latestVersion
+ ? t('settings.integrations.thirdParty.status.installedVersion', {
+ version: presentation.latestVersion,
+ })
+ : t('settings.integrations.thirdParty.status.installed');
+ break;
+ case 'update-available':
+ status = presentation.latestVersion
+ ? t('settings.integrations.thirdParty.status.updateAvailable', {
+ version: presentation.latestVersion,
+ })
+ : t('settings.integrations.thirdParty.status.unpinned');
+ break;
+ case 'not-installed':
+ status = t('settings.integrations.thirdParty.status.notInstalled');
+ break;
+ case 'installed':
+ status = t('settings.integrations.thirdParty.status.installed');
+ break;
+ case 'unpinned':
+ status = t('settings.integrations.thirdParty.status.unpinned');
+ break;
+ case 'ambiguous':
+ status = t('settings.integrations.thirdParty.status.ambiguous');
+ break;
+ case 'restart-required':
+ status = t('settings.integrations.thirdParty.status.restartRequired');
+ break;
+ case 'registry-unavailable':
+ status = t('settings.integrations.thirdParty.status.registryUnavailable');
+ break;
+ case 'provider-unavailable':
+ status = t('settings.integrations.thirdParty.status.providerUnavailable');
+ break;
+ }
+ const statusClassName = presentation.status === 'installed-version'
+ ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
+ : presentation.status === 'update-available'
+ || presentation.status === 'ambiguous'
+ || presentation.status === 'restart-required'
+ || presentation.status === 'registry-unavailable'
+ || presentation.status === 'provider-unavailable'
+ ? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
+ : 'bg-[var(--surface-muted)] text-muted-foreground';
+ const hasUpdate = presentation.status === 'update-available';
+
+ const primaryLabel = {
+ install: t('settings.integrations.thirdParty.actions.install'),
+ update: t('settings.integrations.thirdParty.actions.update'),
+ setup: t('settings.integrations.thirdParty.actions.setup'),
+ manage: t('settings.integrations.thirdParty.actions.managePlugins'),
+ }[primaryAction];
+
+ return (
+ setPluginOpen(plugin.id, open)}
+ settingsItem={`integrations.third-party.${plugin.id}`}
+ header={(
+
+
+
+
+
+
{t(plugin.nameKey)}
+
+ {t(plugin.descriptionKey)}
+
+
+
+ {status}
+
+
+ )}
+ >
+
+
+
{status}
+ {state.projectEntries.length > 0 ? (
+
+ {t('settings.integrations.thirdParty.status.projectInstalled')}
+
+ ) : null}
+
+
+
+ {registryUnavailable ? (
+
+ ) : null}
+
+ {state.userEntry && !state.userEntryIsAmbiguous ? (
+
+ ) : null}
+
+
+
+ );
+ };
+
+ return (
+ <>
+ void refresh()}
+ disabled={isRefreshing || isLoadingRegistry}
+ >
+ {isRefreshing || isLoadingRegistry ? : null}
+ {t('settings.integrations.thirdParty.actions.refresh')}
+
+ )}
+ contentClassName="space-y-3"
+ >
+ {THIRD_PARTY_PLUGINS.map(renderPlugin)}
+
+
+
+ >
+ );
+};
diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts
new file mode 100644
index 00000000..ba82ed3d
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts
@@ -0,0 +1,194 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
+import * as thirdPartyCatalog from './thirdPartyPlugins';
+import {
+ getCatalogPluginState,
+ getCatalogPluginPrimaryAction,
+ getLatestNpmSpec,
+ specMatchesPackage,
+} from './thirdPartyPlugins';
+
+type CatalogPresentationStatus =
+ | 'not-installed'
+ | 'installed'
+ | 'installed-version'
+ | 'update-available'
+ | 'unpinned'
+ | 'ambiguous'
+ | 'restart-required'
+ | 'registry-unavailable'
+ | 'provider-unavailable';
+
+type GetCatalogPluginPresentation = (
+ state: ReturnType,
+ options?: {
+ registryUnavailable?: boolean;
+ restartRequired?: boolean;
+ providerUnavailable?: boolean;
+ },
+) => {
+ status: CatalogPresentationStatus;
+ latestVersion: string | null;
+};
+
+const getCatalogPluginPresentation = (
+ thirdPartyCatalog as unknown as {
+ getCatalogPluginPresentation?: GetCatalogPluginPresentation;
+ }
+).getCatalogPluginPresentation;
+
+const claudePackage = '@otto-assistant/opencode-claude';
+
+const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
+ id: `config:${scope}:${spec}`,
+ spec,
+ scope,
+ kind: 'config',
+ parsedKind: 'npm',
+});
+
+const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
+ kind: 'npm-ok',
+ spec,
+ name: claudePackage,
+ currentVersion,
+ latestVersion,
+ versions: ['0.6.0', latestVersion],
+ hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
+});
+
+describe('third-party plugin catalog helpers', () => {
+ test('derives compact-card status with explicit transient-state priority', () => {
+ expect(typeof getCatalogPluginPresentation).toBe('function');
+ if (!getCatalogPluginPresentation) return;
+
+ const notInstalled = getCatalogPluginState([], claudePackage, {});
+ expect(getCatalogPluginPresentation(notInstalled)).toEqual({
+ status: 'not-installed',
+ latestVersion: null,
+ });
+
+ const current = getCatalogPluginState(
+ [entry(`${claudePackage}@0.7.0`)],
+ claudePackage,
+ { [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
+ );
+ expect(getCatalogPluginPresentation(current)).toEqual({
+ status: 'installed-version',
+ latestVersion: '0.7.0',
+ });
+
+ const outdated = getCatalogPluginState(
+ [entry(`${claudePackage}@0.6.0`)],
+ claudePackage,
+ { [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
+ );
+ expect(getCatalogPluginPresentation(outdated)).toEqual({
+ status: 'update-available',
+ latestVersion: '0.7.0',
+ });
+ expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
+ status: 'registry-unavailable',
+ latestVersion: '0.7.0',
+ });
+ expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
+ status: 'provider-unavailable',
+ latestVersion: '0.7.0',
+ });
+ expect(getCatalogPluginPresentation(outdated, {
+ providerUnavailable: true,
+ restartRequired: true,
+ })).toEqual({
+ status: 'restart-required',
+ latestVersion: '0.7.0',
+ });
+
+ const ambiguous = getCatalogPluginState(
+ [entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
+ claudePackage,
+ {},
+ );
+ expect(getCatalogPluginPresentation(ambiguous)).toEqual({
+ status: 'ambiguous',
+ latestVersion: null,
+ });
+ });
+
+ test('matches only a package or its versioned spec', () => {
+ expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
+ expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
+ expect(specMatchesPackage('@otto-assistant/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
+ });
+
+ test('uses the configured user entry and its registry result', () => {
+ const installed = entry(`${claudePackage}@0.6.0`);
+ const state = getCatalogPluginState(
+ [installed],
+ claudePackage,
+ { [installed.spec]: registry(installed.spec, '0.6.0') },
+ );
+
+ expect(state.userEntry).toEqual(installed);
+ expect(state.userEntryIsAmbiguous).toBe(false);
+ expect(state.projectEntries).toEqual([]);
+ expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
+ });
+
+ test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
+ const state = getCatalogPluginState(
+ [entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
+ claudePackage,
+ {},
+ );
+
+ expect(state.userEntry).toBeNull();
+ expect(state.userEntryIsAmbiguous).toBe(true);
+ expect(state.projectEntries).toHaveLength(1);
+ });
+
+ test('returns an exact latest spec only from a valid npm registry result', () => {
+ expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
+ expect(getLatestNpmSpec(claudePackage, {
+ kind: 'npm-network',
+ spec: claudePackage,
+ error: 'offline',
+ })).toBeNull();
+ });
+
+ test('chooses an update for a bare or outdated user-wide entry', () => {
+ const bare = getCatalogPluginState(
+ [entry(claudePackage)],
+ claudePackage,
+ { [claudePackage]: registry(claudePackage, null) },
+ );
+ const outdated = getCatalogPluginState(
+ [entry(`${claudePackage}@0.6.0`)],
+ claudePackage,
+ { [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
+ );
+
+ expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
+ expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
+ });
+
+ test('keeps setup as the primary action once the exact latest spec is installed', () => {
+ const installed = entry(`${claudePackage}@0.7.0`);
+ const state = getCatalogPluginState(
+ [installed],
+ claudePackage,
+ { [installed.spec]: registry(installed.spec, '0.7.0') },
+ );
+
+ expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
+ });
+
+ test('sends ambiguous entries to manual plugin management', () => {
+ const state = getCatalogPluginState(
+ [entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
+ claudePackage,
+ {},
+ );
+
+ expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
+ });
+});
diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts
new file mode 100644
index 00000000..de4b95b0
--- /dev/null
+++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts
@@ -0,0 +1,166 @@
+import type { IconName } from '@/components/icon/icons';
+import type { I18nKey } from '@/lib/i18n';
+import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
+
+export interface ThirdPartyPluginDefinition {
+ id: string;
+ packageName: string;
+ providerId: string;
+ icon: IconName;
+ /** Brand mark color class (theme tokens only). */
+ brandClassName: string;
+ nameKey: I18nKey;
+ descriptionKey: I18nKey;
+ homepage: string;
+}
+
+export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
+ {
+ id: 'opencode-claude',
+ packageName: '@otto-assistant/opencode-claude',
+ providerId: 'claude-code',
+ icon: 'claude-code',
+ brandClassName: 'text-foreground',
+ nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
+ homepage: 'https://github.com/otto-assistant/opencode-claude',
+ },
+ {
+ id: 'opencode-commandcode',
+ packageName: '@otto-assistant/opencode-commandcode',
+ providerId: 'command-code',
+ icon: 'command-code',
+ brandClassName: 'text-foreground',
+ nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
+ homepage: 'https://github.com/otto-assistant/opencode-commandcode',
+ },
+ {
+ id: 'opencode-cursor-oauth',
+ packageName: '@otto-assistant/opencode-cursor-oauth',
+ providerId: 'cursor',
+ icon: 'cursor',
+ brandClassName: 'text-foreground',
+ nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
+ homepage: 'https://github.com/otto-assistant/opencode-cursor',
+ },
+] as const;
+
+export interface CatalogPluginState {
+ userEntry: PluginEntry | null;
+ userEntryIsAmbiguous: boolean;
+ projectEntries: PluginEntry[];
+ registry: RegistryResult | null;
+}
+
+export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
+
+type CatalogPluginPresentationStatus =
+ | 'not-installed'
+ | 'installed'
+ | 'installed-version'
+ | 'update-available'
+ | 'unpinned'
+ | 'ambiguous'
+ | 'restart-required'
+ | 'registry-unavailable'
+ | 'provider-unavailable';
+
+interface CatalogPluginPresentationOptions {
+ registryUnavailable?: boolean;
+ restartRequired?: boolean;
+ providerUnavailable?: boolean;
+}
+
+interface CatalogPluginPresentation {
+ status: CatalogPluginPresentationStatus;
+ latestVersion: string | null;
+}
+
+export const specMatchesPackage = (spec: string, packageName: string): boolean =>
+ spec === packageName || spec.startsWith(`${packageName}@`);
+
+export function getCatalogPluginState(
+ entries: PluginEntry[],
+ packageName: string,
+ registryInfo: Record,
+): CatalogPluginState {
+ const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
+ const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
+ const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
+ const userEntry = userEntries.length === 1 ? userEntries[0] : null;
+ const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
+
+ return {
+ userEntry,
+ userEntryIsAmbiguous: userEntries.length > 1,
+ projectEntries,
+ registry,
+ };
+}
+
+export function getLatestNpmSpec(
+ packageName: string,
+ registry: RegistryResult | null | undefined,
+): string | null {
+ if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
+ return null;
+ }
+ return `${packageName}@${registry.latestVersion}`;
+}
+
+export function getCatalogPluginPrimaryAction(
+ state: CatalogPluginState,
+ packageName: string,
+): CatalogPluginPrimaryAction {
+ if (state.userEntryIsAmbiguous) {
+ return 'manage';
+ }
+
+ if (!state.userEntry) {
+ return 'install';
+ }
+
+ const latestSpec = getLatestNpmSpec(packageName, state.registry);
+ return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
+}
+
+/**
+ * Converts catalog and temporary mutation state into the one compact-card
+ * status. Transient states intentionally outrank installed/version metadata.
+ */
+export function getCatalogPluginPresentation(
+ state: CatalogPluginState,
+ options: CatalogPluginPresentationOptions = {},
+): CatalogPluginPresentation {
+ const latestVersion = state.registry?.kind === 'npm-ok'
+ ? state.registry.latestVersion
+ : null;
+
+ if (state.userEntryIsAmbiguous) {
+ return { status: 'ambiguous', latestVersion };
+ }
+ if (options.restartRequired) {
+ return { status: 'restart-required', latestVersion };
+ }
+ if (options.providerUnavailable) {
+ return { status: 'provider-unavailable', latestVersion };
+ }
+ if (options.registryUnavailable) {
+ return { status: 'registry-unavailable', latestVersion };
+ }
+ if (!state.userEntry) {
+ return { status: 'not-installed', latestVersion };
+ }
+ if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
+ return { status: 'installed-version', latestVersion };
+ }
+ if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
+ return { status: 'unpinned', latestVersion };
+ }
+ if (state.registry?.kind === 'npm-ok' && latestVersion) {
+ return { status: 'update-available', latestVersion };
+ }
+ return { status: 'installed', latestVersion };
+}
diff --git a/packages/ui/src/components/ui/ProviderLogo.tsx b/packages/ui/src/components/ui/ProviderLogo.tsx
index ef2e3bac..70af4f45 100644
--- a/packages/ui/src/components/ui/ProviderLogo.tsx
+++ b/packages/ui/src/components/ui/ProviderLogo.tsx
@@ -1,6 +1,8 @@
import React from 'react';
+import { Icon } from '@/components/icon/Icon';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { cn } from '@/lib/utils';
+import { getProviderLogoFallbackIcon } from './providerLogoFallback';
interface ProviderLogoProps {
providerId: string;
@@ -16,6 +18,7 @@ export const ProviderLogo: React.FC = ({
onError: externalOnError
}) => {
const { src, onError: handleInternalError, hasLogo } = useProviderLogo(providerId);
+ const fallbackIcon = getProviderLogoFallbackIcon(providerId);
const handleError = React.useCallback(() => {
handleInternalError();
@@ -23,7 +26,7 @@ export const ProviderLogo: React.FC = ({
}, [handleInternalError, externalOnError]);
if (!hasLogo || !src) {
- return null;
+ return fallbackIcon ? : null;
}
return (
diff --git a/packages/ui/src/components/ui/providerLogoFallback.test.ts b/packages/ui/src/components/ui/providerLogoFallback.test.ts
new file mode 100644
index 00000000..c82b9ad3
--- /dev/null
+++ b/packages/ui/src/components/ui/providerLogoFallback.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from 'bun:test';
+import { getProviderLogoFallbackIcon } from './providerLogoFallback';
+
+describe('provider logo fallbacks', () => {
+ test('uses a local terminal icon when Command Code has no resolved logo', () => {
+ expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box');
+ });
+
+ test('does not replace providers with their own logo assets', () => {
+ expect(getProviderLogoFallbackIcon('claude-code')).toBeNull();
+ expect(getProviderLogoFallbackIcon('cursor')).toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/ui/providerLogoFallback.ts b/packages/ui/src/components/ui/providerLogoFallback.ts
new file mode 100644
index 00000000..9aa871fd
--- /dev/null
+++ b/packages/ui/src/components/ui/providerLogoFallback.ts
@@ -0,0 +1,5 @@
+import type { IconName } from '@/components/icon/icons';
+
+export function getProviderLogoFallbackIcon(providerId: string | null | undefined): IconName | null {
+ return providerId?.trim().toLowerCase() === 'command-code' ? 'terminal-box' : null;
+}
diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx
index 7e0e59c6..50f501c0 100644
--- a/packages/ui/src/components/views/SettingsView.tsx
+++ b/packages/ui/src/components/views/SettingsView.tsx
@@ -34,6 +34,7 @@ import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromp
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
import { GitPage } from '@/components/sections/git-identities/GitPage';
+import { IntegrationsPage } from '@/components/sections/integrations/IntegrationsPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
@@ -94,6 +95,7 @@ const pageOrder: SettingsPageSlug[] = [
'sessions',
'shortcuts',
'voice',
+ 'integrations',
'usage',
'about',
// 'projects' group — Workspace
@@ -298,6 +300,24 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
}, [isMobile, setSettingsPage]);
+ const openThirdPartyProviderSetup = React.useCallback(async (providerId: string): Promise => {
+ const configStore = useConfigStore.getState();
+ await configStore.loadProviders({ source: 'settings:third-party-provider-setup' });
+ const providerAvailable = useConfigStore.getState().providers.some(
+ (provider) => provider.id === providerId,
+ );
+ if (!providerAvailable) {
+ return false;
+ }
+
+ configStore.setSelectedProvider(providerId);
+ openPage('providers');
+ if (isMobile) {
+ setMobileStage('page-content');
+ }
+ return true;
+ }, [isMobile, openPage]);
+
const activePageMeta = React.useMemo(() => {
return getSettingsPageMeta(settingsSlug);
}, [settingsSlug]);
@@ -343,6 +363,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
return t('settings.page.skillsCatalog.title');
case 'git':
return t('settings.page.git.title');
+ case 'integrations':
+ return t('settings.page.integrations.title');
case 'appearance':
return t('settings.page.appearance.title');
case 'chat':
@@ -646,6 +668,13 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
return ;
case 'git':
return ;
+ case 'integrations':
+ return (
+ openPage('plugins')}
+ />
+ );
case 'general':
case 'appearance':
case 'chat':
@@ -661,7 +690,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
default:
return null;
}
- }, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
+ }, [openChamberSectionBySlug, openPage, openThirdPartyProviderSetup, renderUnavailable, runtimeCtx, t]);
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => {
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts
index eaf0f74f..ff1292c5 100644
--- a/packages/ui/src/lib/i18n/messages/de.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/de.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.',
@@ -2124,4 +2125,5 @@ export const settingsDict = {
'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden',
'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden',
'chat.message.userText.collapseAria': 'Benutzernachricht einklappen',
+ ...thirdPartyIntegrationI18n.de,
};
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index a1476de4..73c444c7 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.',
@@ -2123,4 +2124,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n.en,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index ec9a72f2..4a51c6b8 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...thirdPartyIntegrationI18n.es,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts
index d35224bc..208ceefe 100644
--- a/packages/ui/src/lib/i18n/messages/fr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n.fr,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts
index a1e32fe4..451dafcd 100644
--- a/packages/ui/src/lib/i18n/messages/ja.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー',
+ ...thirdPartyIntegrationI18n.ja,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index c331bc69..f708ff3b 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n.ko,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index 36bccdce..10f7c910 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.',
@@ -2125,4 +2126,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n.pl,
};
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index 111dc325..cb7bed04 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...thirdPartyIntegrationI18n['pt-BR'],
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts
new file mode 100644
index 00000000..457362f9
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'bun:test';
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
+
+const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW'] as const;
+
+const requiredKeys = [
+ 'settings.page.integrations.title',
+ 'settings.page.integrations.description',
+ 'settings.integrations.thirdParty.title',
+ 'settings.integrations.thirdParty.actions.install',
+ 'settings.integrations.thirdParty.actions.update',
+ 'settings.integrations.thirdParty.actions.setup',
+ 'settings.integrations.thirdParty.actions.remove',
+ 'settings.integrations.thirdParty.status.notInstalled',
+ 'settings.integrations.thirdParty.opencodeClaude.description',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description',
+] as const;
+
+describe('third-party integration translations', () => {
+ test('provides every required key in every supported locale', () => {
+ for (const locale of locales) {
+ for (const key of requiredKeys) {
+ expect(thirdPartyIntegrationI18n[locale][key]).toBeTruthy();
+ }
+ }
+ });
+});
diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts
new file mode 100644
index 00000000..1df8a4c1
--- /dev/null
+++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts
@@ -0,0 +1,399 @@
+/** Third-party integration settings strings — merged into each locale's settings dictionary. */
+export const thirdPartyIntegrationI18n = {
+ en: {
+ 'settings.page.integrations.title': 'Integrations',
+ 'settings.page.integrations.description': 'Connect third-party providers to OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Third-party integrations',
+ 'settings.integrations.thirdParty.info': 'Install and manage supported OpenCode provider plugins. Setup opens the provider’s existing authentication settings.',
+ 'settings.integrations.thirdParty.actions.install': 'Install',
+ 'settings.integrations.thirdParty.actions.update': 'Update',
+ 'settings.integrations.thirdParty.actions.setup': 'Set up',
+ 'settings.integrations.thirdParty.actions.remove': 'Remove',
+ 'settings.integrations.thirdParty.actions.refresh': 'Refresh',
+ 'settings.integrations.thirdParty.actions.docs': 'Docs',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Manage plugins',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Not installed',
+ 'settings.integrations.thirdParty.status.installed': 'Installed',
+ 'settings.integrations.thirdParty.status.installedVersion': 'Installed {version}',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Update available: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Following the latest release',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Also configured for this project',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Multiple user-wide plugin entries need manual management',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Restart OpenCode before setting up this provider.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'Could not check npm right now.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'The provider is not available yet. Restart OpenCode and try again.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Remove integration',
+ 'settings.integrations.thirdParty.dialog.remove.description': 'Remove {name} from your user-wide OpenCode configuration? The provider will no longer load after OpenCode refreshes.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} installed',
+ 'settings.integrations.thirdParty.toast.updated': '{name} updated',
+ 'settings.integrations.thirdParty.toast.removed': '{name} removed',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Could not update the integration',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'The provider could not be opened yet',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Use a Claude Pro or Max subscription through OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Use Command Code’s Laguna S 2.1 model through OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Use your Cursor subscription through OpenCode.',
+ },
+ de: {
+ 'settings.page.integrations.title': 'Integrationen',
+ 'settings.page.integrations.description': 'Verbinde Drittanbieter-Provider mit OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Drittanbieter-Integrationen',
+ 'settings.integrations.thirdParty.info': 'Installiere und verwalte unterstützte OpenCode-Provider-Plugins. Die Einrichtung öffnet die bestehenden Authentifizierungseinstellungen des Providers.',
+ 'settings.integrations.thirdParty.actions.install': 'Installieren',
+ 'settings.integrations.thirdParty.actions.update': 'Aktualisieren',
+ 'settings.integrations.thirdParty.actions.setup': 'Einrichten',
+ 'settings.integrations.thirdParty.actions.remove': 'Entfernen',
+ 'settings.integrations.thirdParty.actions.refresh': 'Aktualisieren',
+ 'settings.integrations.thirdParty.actions.docs': 'Dokumentation',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Plugins verwalten',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Nicht installiert',
+ 'settings.integrations.thirdParty.status.installed': 'Installiert',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} installiert',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Aktualisierung verfügbar: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Folgt der neuesten Version',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Auch für dieses Projekt konfiguriert',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Mehrere benutzerweite Plugin-Einträge müssen manuell verwaltet werden',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Starte OpenCode neu, bevor du diesen Provider einrichtest.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'npm konnte gerade nicht geprüft werden.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'Der Provider ist noch nicht verfügbar. Starte OpenCode neu und versuche es erneut.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Integration entfernen',
+ 'settings.integrations.thirdParty.dialog.remove.description': '{name} aus deiner benutzerweiten OpenCode-Konfiguration entfernen? Der Provider wird nach der Aktualisierung von OpenCode nicht mehr geladen.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} installiert',
+ 'settings.integrations.thirdParty.toast.updated': '{name} aktualisiert',
+ 'settings.integrations.thirdParty.toast.removed': '{name} entfernt',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Die Integration konnte nicht aktualisiert werden',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Der Provider konnte noch nicht geöffnet werden',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Nutze ein Claude-Pro- oder -Max-Abonnement über OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Nutze das Laguna-S-2.1-Modell von Command Code über OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Nutze dein Cursor-Abonnement über OpenCode.',
+ },
+ fr: {
+ 'settings.page.integrations.title': 'Intégrations',
+ 'settings.page.integrations.description': 'Connectez des fournisseurs tiers à OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Intégrations tierces',
+ 'settings.integrations.thirdParty.info': 'Installez et gérez les plugins de fournisseurs OpenCode pris en charge. La configuration ouvre les réglages d’authentification existants du fournisseur.',
+ 'settings.integrations.thirdParty.actions.install': 'Installer',
+ 'settings.integrations.thirdParty.actions.update': 'Mettre à jour',
+ 'settings.integrations.thirdParty.actions.setup': 'Configurer',
+ 'settings.integrations.thirdParty.actions.remove': 'Supprimer',
+ 'settings.integrations.thirdParty.actions.refresh': 'Actualiser',
+ 'settings.integrations.thirdParty.actions.docs': 'Documentation',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Gérer les plugins',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Non installé',
+ 'settings.integrations.thirdParty.status.installed': 'Installé',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} installé',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Mise à jour disponible : {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Suit la dernière version',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Également configuré pour ce projet',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Plusieurs entrées de plugin globales doivent être gérées manuellement',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Redémarrez OpenCode avant de configurer ce fournisseur.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'Impossible de vérifier npm pour le moment.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'Le fournisseur n’est pas encore disponible. Redémarrez OpenCode et réessayez.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Supprimer l’intégration',
+ 'settings.integrations.thirdParty.dialog.remove.description': 'Supprimer {name} de votre configuration OpenCode globale ? Le fournisseur ne sera plus chargé après l’actualisation d’OpenCode.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} installé',
+ 'settings.integrations.thirdParty.toast.updated': '{name} mis à jour',
+ 'settings.integrations.thirdParty.toast.removed': '{name} supprimé',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Impossible de mettre à jour l’intégration',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Le fournisseur n’a pas encore pu être ouvert',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Utilisez un abonnement Claude Pro ou Max via OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Utilisez le modèle Laguna S 2.1 de Command Code via OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Utilisez votre abonnement Cursor via OpenCode.',
+ },
+ es: {
+ 'settings.page.integrations.title': 'Integraciones',
+ 'settings.page.integrations.description': 'Conecta proveedores de terceros a OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Integraciones de terceros',
+ 'settings.integrations.thirdParty.info': 'Instala y administra los plugins de proveedores OpenCode compatibles. La configuración abre los ajustes de autenticación existentes del proveedor.',
+ 'settings.integrations.thirdParty.actions.install': 'Instalar',
+ 'settings.integrations.thirdParty.actions.update': 'Actualizar',
+ 'settings.integrations.thirdParty.actions.setup': 'Configurar',
+ 'settings.integrations.thirdParty.actions.remove': 'Quitar',
+ 'settings.integrations.thirdParty.actions.refresh': 'Actualizar',
+ 'settings.integrations.thirdParty.actions.docs': 'Documentación',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Administrar plugins',
+ 'settings.integrations.thirdParty.status.notInstalled': 'No instalado',
+ 'settings.integrations.thirdParty.status.installed': 'Instalado',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} instalado',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Actualización disponible: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Sigue la versión más reciente',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'También configurado para este proyecto',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Hay varias entradas de plugin globales que requieren gestión manual',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Reinicia OpenCode antes de configurar este proveedor.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'No se pudo comprobar npm ahora mismo.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'El proveedor todavía no está disponible. Reinicia OpenCode e inténtalo de nuevo.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Quitar integración',
+ 'settings.integrations.thirdParty.dialog.remove.description': '¿Quitar {name} de tu configuración global de OpenCode? El proveedor dejará de cargarse después de actualizar OpenCode.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} instalado',
+ 'settings.integrations.thirdParty.toast.updated': '{name} actualizado',
+ 'settings.integrations.thirdParty.toast.removed': '{name} eliminado',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'No se pudo actualizar la integración',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Todavía no se pudo abrir el proveedor',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Usa una suscripción Claude Pro o Max mediante OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Usa el modelo Laguna S 2.1 de Command Code mediante OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Usa tu suscripción de Cursor mediante OpenCode.',
+ },
+ ja: {
+ 'settings.page.integrations.title': '連携',
+ 'settings.page.integrations.description': 'サードパーティープロバイダーを OpenCode に接続します。',
+ 'settings.integrations.thirdParty.title': 'サードパーティー連携',
+ 'settings.integrations.thirdParty.info': '対応する OpenCode プロバイダープラグインをインストールして管理します。設定ではプロバイダーの既存の認証設定を開きます。',
+ 'settings.integrations.thirdParty.actions.install': 'インストール',
+ 'settings.integrations.thirdParty.actions.update': '更新',
+ 'settings.integrations.thirdParty.actions.setup': '設定',
+ 'settings.integrations.thirdParty.actions.remove': '削除',
+ 'settings.integrations.thirdParty.actions.refresh': '再読み込み',
+ 'settings.integrations.thirdParty.actions.docs': 'ドキュメント',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'プラグインを管理',
+ 'settings.integrations.thirdParty.status.notInstalled': '未インストール',
+ 'settings.integrations.thirdParty.status.installed': 'インストール済み',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} をインストール済み',
+ 'settings.integrations.thirdParty.status.updateAvailable': '更新があります: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': '最新リリースを追跡中',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'このプロジェクトにも設定済み',
+ 'settings.integrations.thirdParty.status.ambiguous': '複数のユーザー全体プラグインエントリーは手動で管理する必要があります',
+ 'settings.integrations.thirdParty.status.restartRequired': 'このプロバイダーを設定する前に OpenCode を再起動してください。',
+ 'settings.integrations.thirdParty.status.registryUnavailable': '現在 npm を確認できません。',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'プロバイダーはまだ利用できません。OpenCode を再起動して再試行してください。',
+ 'settings.integrations.thirdParty.dialog.remove.title': '連携を削除',
+ 'settings.integrations.thirdParty.dialog.remove.description': '{name} をユーザー全体の OpenCode 設定から削除しますか?OpenCode の更新後、このプロバイダーは読み込まれなくなります。',
+ 'settings.integrations.thirdParty.toast.installed': '{name} をインストールしました',
+ 'settings.integrations.thirdParty.toast.updated': '{name} を更新しました',
+ 'settings.integrations.thirdParty.toast.removed': '{name} を削除しました',
+ 'settings.integrations.thirdParty.toast.actionFailed': '連携を更新できませんでした',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'プロバイダーをまだ開けませんでした',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro または Max のサブスクリプションを OpenCode 経由で利用します。',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Command Code の Laguna S 2.1 モデルを OpenCode 経由で利用します。',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor のサブスクリプションを OpenCode 経由で利用します。',
+ },
+ ko: {
+ 'settings.page.integrations.title': '통합',
+ 'settings.page.integrations.description': '서드파티 프로바이더를 OpenCode에 연결합니다.',
+ 'settings.integrations.thirdParty.title': '서드파티 통합',
+ 'settings.integrations.thirdParty.info': '지원되는 OpenCode 프로바이더 플러그인을 설치하고 관리합니다. 설정을 누르면 프로바이더의 기존 인증 설정이 열립니다.',
+ 'settings.integrations.thirdParty.actions.install': '설치',
+ 'settings.integrations.thirdParty.actions.update': '업데이트',
+ 'settings.integrations.thirdParty.actions.setup': '설정',
+ 'settings.integrations.thirdParty.actions.remove': '제거',
+ 'settings.integrations.thirdParty.actions.refresh': '새로 고침',
+ 'settings.integrations.thirdParty.actions.docs': '문서',
+ 'settings.integrations.thirdParty.actions.managePlugins': '플러그인 관리',
+ 'settings.integrations.thirdParty.status.notInstalled': '설치되지 않음',
+ 'settings.integrations.thirdParty.status.installed': '설치됨',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} 설치됨',
+ 'settings.integrations.thirdParty.status.updateAvailable': '업데이트 가능: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': '최신 릴리스 추적 중',
+ 'settings.integrations.thirdParty.status.projectInstalled': '이 프로젝트에도 구성됨',
+ 'settings.integrations.thirdParty.status.ambiguous': '여러 사용자 전체 플러그인 항목은 수동으로 관리해야 합니다',
+ 'settings.integrations.thirdParty.status.restartRequired': '이 프로바이더를 설정하기 전에 OpenCode를 다시 시작하세요.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': '지금은 npm을 확인할 수 없습니다.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': '프로바이더를 아직 사용할 수 없습니다. OpenCode를 다시 시작한 후 재시도하세요.',
+ 'settings.integrations.thirdParty.dialog.remove.title': '통합 제거',
+ 'settings.integrations.thirdParty.dialog.remove.description': '사용자 전체 OpenCode 구성에서 {name}을(를) 제거할까요? OpenCode가 새로 고쳐진 후 프로바이더가 더 이상 로드되지 않습니다.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} 설치됨',
+ 'settings.integrations.thirdParty.toast.updated': '{name} 업데이트됨',
+ 'settings.integrations.thirdParty.toast.removed': '{name} 제거됨',
+ 'settings.integrations.thirdParty.toast.actionFailed': '통합을 업데이트하지 못했습니다',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': '프로바이더를 아직 열 수 없습니다',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'OpenCode를 통해 Claude Pro 또는 Max 구독을 사용합니다.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'OpenCode를 통해 Command Code의 Laguna S 2.1 모델을 사용합니다.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'OpenCode를 통해 Cursor 구독을 사용합니다.',
+ },
+ pl: {
+ 'settings.page.integrations.title': 'Integracje',
+ 'settings.page.integrations.description': 'Połącz zewnętrznych dostawców z OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Integracje zewnętrzne',
+ 'settings.integrations.thirdParty.info': 'Instaluj i zarządzaj obsługiwanymi wtyczkami dostawców OpenCode. Konfiguracja otwiera istniejące ustawienia uwierzytelniania dostawcy.',
+ 'settings.integrations.thirdParty.actions.install': 'Zainstaluj',
+ 'settings.integrations.thirdParty.actions.update': 'Aktualizuj',
+ 'settings.integrations.thirdParty.actions.setup': 'Skonfiguruj',
+ 'settings.integrations.thirdParty.actions.remove': 'Usuń',
+ 'settings.integrations.thirdParty.actions.refresh': 'Odśwież',
+ 'settings.integrations.thirdParty.actions.docs': 'Dokumentacja',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Zarządzaj wtyczkami',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Nie zainstalowano',
+ 'settings.integrations.thirdParty.status.installed': 'Zainstalowano',
+ 'settings.integrations.thirdParty.status.installedVersion': 'Zainstalowano {version}',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Dostępna aktualizacja: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Śledzi najnowsze wydanie',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Skonfigurowano także dla tego projektu',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Wiele globalnych wpisów wtyczki wymaga ręcznego zarządzania',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Uruchom ponownie OpenCode przed konfiguracją tego dostawcy.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'Nie można teraz sprawdzić npm.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'Dostawca nie jest jeszcze dostępny. Uruchom ponownie OpenCode i spróbuj ponownie.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Usuń integrację',
+ 'settings.integrations.thirdParty.dialog.remove.description': 'Usunąć {name} z globalnej konfiguracji OpenCode? Dostawca przestanie być ładowany po odświeżeniu OpenCode.',
+ 'settings.integrations.thirdParty.toast.installed': 'Zainstalowano {name}',
+ 'settings.integrations.thirdParty.toast.updated': 'Zaktualizowano {name}',
+ 'settings.integrations.thirdParty.toast.removed': 'Usunięto {name}',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Nie udało się zaktualizować integracji',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Nie można jeszcze otworzyć dostawcy',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Korzystaj z subskrypcji Claude Pro lub Max przez OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Korzystaj z modelu Laguna S 2.1 od Command Code przez OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Korzystaj z subskrypcji Cursor przez OpenCode.',
+ },
+ 'pt-BR': {
+ 'settings.page.integrations.title': 'Integrações',
+ 'settings.page.integrations.description': 'Conecte provedores de terceiros ao OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Integrações de terceiros',
+ 'settings.integrations.thirdParty.info': 'Instale e gerencie plugins compatíveis de provedores do OpenCode. A configuração abre as definições de autenticação existentes do provedor.',
+ 'settings.integrations.thirdParty.actions.install': 'Instalar',
+ 'settings.integrations.thirdParty.actions.update': 'Atualizar',
+ 'settings.integrations.thirdParty.actions.setup': 'Configurar',
+ 'settings.integrations.thirdParty.actions.remove': 'Remover',
+ 'settings.integrations.thirdParty.actions.refresh': 'Atualizar',
+ 'settings.integrations.thirdParty.actions.docs': 'Documentação',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Gerenciar plugins',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Não instalado',
+ 'settings.integrations.thirdParty.status.installed': 'Instalado',
+ 'settings.integrations.thirdParty.status.installedVersion': '{version} instalado',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Atualização disponível: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Acompanha a versão mais recente',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Também configurado para este projeto',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Várias entradas de plugin globais precisam de gerenciamento manual',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Reinicie o OpenCode antes de configurar este provedor.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'Não foi possível verificar o npm agora.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'O provedor ainda não está disponível. Reinicie o OpenCode e tente novamente.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Remover integração',
+ 'settings.integrations.thirdParty.dialog.remove.description': 'Remover {name} da sua configuração global do OpenCode? O provedor deixará de ser carregado após a atualização do OpenCode.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} instalado',
+ 'settings.integrations.thirdParty.toast.updated': '{name} atualizado',
+ 'settings.integrations.thirdParty.toast.removed': '{name} removido',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Não foi possível atualizar a integração',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Ainda não foi possível abrir o provedor',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Use uma assinatura Claude Pro ou Max pelo OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Use o modelo Laguna S 2.1 do Command Code pelo OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Use sua assinatura Cursor pelo OpenCode.',
+ },
+ uk: {
+ 'settings.page.integrations.title': 'Інтеграції',
+ 'settings.page.integrations.description': 'Підключайте сторонніх провайдерів до OpenCode.',
+ 'settings.integrations.thirdParty.title': 'Сторонні інтеграції',
+ 'settings.integrations.thirdParty.info': 'Встановлюйте й керуйте підтримуваними плагінами провайдерів OpenCode. Налаштування відкриває наявні параметри автентифікації провайдера.',
+ 'settings.integrations.thirdParty.actions.install': 'Встановити',
+ 'settings.integrations.thirdParty.actions.update': 'Оновити',
+ 'settings.integrations.thirdParty.actions.setup': 'Налаштувати',
+ 'settings.integrations.thirdParty.actions.remove': 'Видалити',
+ 'settings.integrations.thirdParty.actions.refresh': 'Оновити',
+ 'settings.integrations.thirdParty.actions.docs': 'Документація',
+ 'settings.integrations.thirdParty.actions.managePlugins': 'Керувати плагінами',
+ 'settings.integrations.thirdParty.status.notInstalled': 'Не встановлено',
+ 'settings.integrations.thirdParty.status.installed': 'Встановлено',
+ 'settings.integrations.thirdParty.status.installedVersion': 'Встановлено {version}',
+ 'settings.integrations.thirdParty.status.updateAvailable': 'Доступне оновлення: {version}',
+ 'settings.integrations.thirdParty.status.unpinned': 'Відстежує найновіший випуск',
+ 'settings.integrations.thirdParty.status.projectInstalled': 'Також налаштовано для цього проєкту',
+ 'settings.integrations.thirdParty.status.ambiguous': 'Кілька глобальних записів плагіна потребують ручного керування',
+ 'settings.integrations.thirdParty.status.restartRequired': 'Перезапустіть OpenCode перед налаштуванням цього провайдера.',
+ 'settings.integrations.thirdParty.status.registryUnavailable': 'Зараз не вдалося перевірити npm.',
+ 'settings.integrations.thirdParty.status.providerUnavailable': 'Провайдер ще недоступний. Перезапустіть OpenCode й спробуйте знову.',
+ 'settings.integrations.thirdParty.dialog.remove.title': 'Видалити інтеграцію',
+ 'settings.integrations.thirdParty.dialog.remove.description': 'Видалити {name} з вашої глобальної конфігурації OpenCode? Після оновлення OpenCode провайдер більше не завантажуватиметься.',
+ 'settings.integrations.thirdParty.toast.installed': '{name} встановлено',
+ 'settings.integrations.thirdParty.toast.updated': '{name} оновлено',
+ 'settings.integrations.thirdParty.toast.removed': '{name} видалено',
+ 'settings.integrations.thirdParty.toast.actionFailed': 'Не вдалося оновити інтеграцію',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': 'Провайдера ще не вдалося відкрити',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': 'Використовуйте підписку Claude Pro або Max через OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Використовуйте модель Laguna S 2.1 від Command Code через OpenCode.',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Використовуйте свою підписку Cursor через OpenCode.',
+ },
+ 'zh-CN': {
+ 'settings.page.integrations.title': '集成',
+ 'settings.page.integrations.description': '将第三方提供商连接到 OpenCode。',
+ 'settings.integrations.thirdParty.title': '第三方集成',
+ 'settings.integrations.thirdParty.info': '安装和管理受支持的 OpenCode 提供商插件。设置会打开该提供商已有的身份验证设置。',
+ 'settings.integrations.thirdParty.actions.install': '安装',
+ 'settings.integrations.thirdParty.actions.update': '更新',
+ 'settings.integrations.thirdParty.actions.setup': '设置',
+ 'settings.integrations.thirdParty.actions.remove': '移除',
+ 'settings.integrations.thirdParty.actions.refresh': '刷新',
+ 'settings.integrations.thirdParty.actions.docs': '文档',
+ 'settings.integrations.thirdParty.actions.managePlugins': '管理插件',
+ 'settings.integrations.thirdParty.status.notInstalled': '未安装',
+ 'settings.integrations.thirdParty.status.installed': '已安装',
+ 'settings.integrations.thirdParty.status.installedVersion': '已安装 {version}',
+ 'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}',
+ 'settings.integrations.thirdParty.status.unpinned': '跟踪最新版本',
+ 'settings.integrations.thirdParty.status.projectInstalled': '也已为此项目配置',
+ 'settings.integrations.thirdParty.status.ambiguous': '多个用户级插件条目需要手动管理',
+ 'settings.integrations.thirdParty.status.restartRequired': '请先重启 OpenCode,再设置此提供商。',
+ 'settings.integrations.thirdParty.status.registryUnavailable': '当前无法检查 npm。',
+ 'settings.integrations.thirdParty.status.providerUnavailable': '该提供商尚不可用。请重启 OpenCode 后重试。',
+ 'settings.integrations.thirdParty.dialog.remove.title': '移除集成',
+ 'settings.integrations.thirdParty.dialog.remove.description': '要从用户级 OpenCode 配置中移除 {name} 吗?OpenCode 刷新后将不再加载该提供商。',
+ 'settings.integrations.thirdParty.toast.installed': '已安装 {name}',
+ 'settings.integrations.thirdParty.toast.updated': '已更新 {name}',
+ 'settings.integrations.thirdParty.toast.removed': '已移除 {name}',
+ 'settings.integrations.thirdParty.toast.actionFailed': '无法更新集成',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': '暂时无法打开该提供商',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': '通过 OpenCode 使用 Claude Pro 或 Max 订阅。',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': '通过 OpenCode 使用 Command Code 的 Laguna S 2.1 模型。',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': '通过 OpenCode 使用你的 Cursor 订阅。',
+ },
+ 'zh-TW': {
+ 'settings.page.integrations.title': '整合',
+ 'settings.page.integrations.description': '將第三方供應商連接到 OpenCode。',
+ 'settings.integrations.thirdParty.title': '第三方整合',
+ 'settings.integrations.thirdParty.info': '安裝並管理支援的 OpenCode 供應商外掛。設定會開啟供應商既有的驗證設定。',
+ 'settings.integrations.thirdParty.actions.install': '安裝',
+ 'settings.integrations.thirdParty.actions.update': '更新',
+ 'settings.integrations.thirdParty.actions.setup': '設定',
+ 'settings.integrations.thirdParty.actions.remove': '移除',
+ 'settings.integrations.thirdParty.actions.refresh': '重新整理',
+ 'settings.integrations.thirdParty.actions.docs': '文件',
+ 'settings.integrations.thirdParty.actions.managePlugins': '管理外掛',
+ 'settings.integrations.thirdParty.status.notInstalled': '未安裝',
+ 'settings.integrations.thirdParty.status.installed': '已安裝',
+ 'settings.integrations.thirdParty.status.installedVersion': '已安裝 {version}',
+ 'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}',
+ 'settings.integrations.thirdParty.status.unpinned': '追蹤最新版本',
+ 'settings.integrations.thirdParty.status.projectInstalled': '也已為此專案設定',
+ 'settings.integrations.thirdParty.status.ambiguous': '多個使用者層級外掛項目需要手動管理',
+ 'settings.integrations.thirdParty.status.restartRequired': '請先重新啟動 OpenCode,再設定此供應商。',
+ 'settings.integrations.thirdParty.status.registryUnavailable': '目前無法檢查 npm。',
+ 'settings.integrations.thirdParty.status.providerUnavailable': '供應商尚不可用。請重新啟動 OpenCode 後再試一次。',
+ 'settings.integrations.thirdParty.dialog.remove.title': '移除整合',
+ 'settings.integrations.thirdParty.dialog.remove.description': '要從使用者層級 OpenCode 設定中移除 {name} 嗎?OpenCode 重新整理後將不再載入此供應商。',
+ 'settings.integrations.thirdParty.toast.installed': '已安裝 {name}',
+ 'settings.integrations.thirdParty.toast.updated': '已更新 {name}',
+ 'settings.integrations.thirdParty.toast.removed': '已移除 {name}',
+ 'settings.integrations.thirdParty.toast.actionFailed': '無法更新整合',
+ 'settings.integrations.thirdParty.toast.providerUnavailable': '暫時無法開啟供應商',
+ 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
+ 'settings.integrations.thirdParty.opencodeClaude.description': '透過 OpenCode 使用 Claude Pro 或 Max 訂閱。',
+ 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
+ 'settings.integrations.thirdParty.opencodeCommandcode.description': '透過 OpenCode 使用 Command Code 的 Laguna S 2.1 模型。',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
+ 'settings.integrations.thirdParty.opencodeCursorOauth.description': '透過 OpenCode 使用你的 Cursor 訂閱。',
+ },
+} as const;
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index 3b77e6ea..7b3980dc 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
+ ...thirdPartyIntegrationI18n.uk,
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index e9a7e615..171fd0a3 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n['zh-CN'],
} as const;
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
index 9ff9eca3..e54526b9 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
@@ -1,3 +1,4 @@
+import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。',
@@ -2133,4 +2134,5 @@
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
+ ...thirdPartyIntegrationI18n['zh-TW'],
} as const;
diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts
index f8d96d72..8b03add0 100644
--- a/packages/ui/src/lib/settings/metadata.ts
+++ b/packages/ui/src/lib/settings/metadata.ts
@@ -25,7 +25,8 @@ export type SettingsPageSlug =
| 'notifications'
| 'voice'
| 'tunnel'
- | 'about';
+ | 'about'
+ | 'integrations';
type SettingsPageGroup =
| 'general'
@@ -201,6 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
+ { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect'] },
] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = {
@@ -286,6 +288,9 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'git':
return 'git-branch';
+ case 'integrations':
+ return 'plug';
+
case 'usage':
return 'bar-chart-2';
case 'voice':
diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts
new file mode 100644
index 00000000..33afe4b6
--- /dev/null
+++ b/packages/ui/src/lib/settings/search.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from 'bun:test';
+import type { I18nKey } from '@/lib/i18n/store';
+import { buildSettingsSearchResults } from './search';
+
+const t = (key: I18nKey): string => key;
+
+const runtimeCtx = {
+ isVSCode: false,
+ isWeb: true,
+ isDesktop: false,
+ isMobile: false,
+ isDesktopLocalOrigin: false,
+ isMac: false,
+ isWindows: false,
+ isLinux: false,
+ isWindowsArm64: false,
+};
+
+describe('settings search', () => {
+ test('finds the Claude Code third-party integration', () => {
+ const results = buildSettingsSearchResults({
+ query: 'claude',
+ runtimeCtx,
+ t,
+ getPageTitle: (page) => page,
+ });
+
+ expect(results.some((result) => result.id === 'integrations.third-party.opencode-claude')).toBe(true);
+ });
+});
diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts
index a1079a6d..10e24965 100644
--- a/packages/ui/src/lib/settings/search.ts
+++ b/packages/ui/src/lib/settings/search.ts
@@ -933,6 +933,34 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['background', 'push'],
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
+
+ {
+ id: 'integrations.third-party',
+ page: 'integrations',
+ titleKey: 'settings.integrations.thirdParty.title',
+ keywords: ['plugin', 'provider', 'oauth', 'install', 'update', 'remove'],
+ },
+ {
+ id: 'integrations.third-party.opencode-claude',
+ page: 'integrations',
+ titleKey: 'settings.integrations.thirdParty.opencodeClaude.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
+ keywords: ['claude', 'anthropic', 'claude code', 'pro', 'max', 'agent sdk'],
+ },
+ {
+ id: 'integrations.third-party.opencode-commandcode',
+ page: 'integrations',
+ titleKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
+ keywords: ['command code', 'commandcode', 'laguna', 'poolside', 'gateway'],
+ },
+ {
+ id: 'integrations.third-party.opencode-cursor-oauth',
+ page: 'integrations',
+ titleKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
+ descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
+ keywords: ['cursor', 'oauth', 'subscription', 'openai compatible'],
+ },
] as const;
interface BuildSettingsSearchResultsOptions {
diff --git a/packages/ui/src/stores/usePluginsStore.test.ts b/packages/ui/src/stores/usePluginsStore.test.ts
index 7c8c7cd2..cc4d10ec 100644
--- a/packages/ui/src/stores/usePluginsStore.test.ts
+++ b/packages/ui/src/stores/usePluginsStore.test.ts
@@ -292,12 +292,24 @@ describe('usePluginsStore', () => {
test('loadRegistryInfo skips empty specs and clears loading flag', async () => {
usePluginsStore.setState({ isLoadingRegistry: true });
- await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
+ const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
+ expect(result).toBe(true);
expect(fetchCalls).toHaveLength(0);
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
+ test('loadRegistryInfo reports a failed request without clearing prior registry data', async () => {
+ usePluginsStore.setState({ registryInfo: { [entry.spec]: registryOk } });
+ queueFetchResponses([jsonResponse({ error: 'registry unavailable' }, { status: 500 })]);
+
+ const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [entry.spec] });
+
+ expect(result).toBe(false);
+ expect(usePluginsStore.getState().registryInfo).toEqual({ [entry.spec]: registryOk });
+ expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
+ });
+
test('loadPlugins success triggers registry load without blocking result', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts
index 7f4b9d10..2b9a2ec8 100644
--- a/packages/ui/src/stores/usePluginsStore.ts
+++ b/packages/ui/src/stores/usePluginsStore.ts
@@ -67,7 +67,7 @@ export interface PluginsStore {
setSelected: (id: string | null) => void;
setDraft: (draft: PluginDraft | null) => void;
loadPlugins: (options?: { force?: boolean }) => Promise;
- loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise;
+ loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise;
updateToLatest: (id: string) => Promise;
createEntry: (input: { spec: string; options?: Record; scope: PluginScope }) => Promise;
updateEntry: (id: string, input: { spec?: string; options?: Record }) => Promise;
@@ -207,7 +207,7 @@ export const usePluginsStore = create()(
const specs = dedupeSpecs(opts?.specs ?? get().entries.map((entry) => entry.spec));
if (specs.length === 0) {
set({ isLoadingRegistry: false });
- return;
+ return true;
}
set({ isLoadingRegistry: true });
@@ -227,9 +227,11 @@ export const usePluginsStore = create()(
}
}
set({ registryInfo: nextRegistryInfo, isLoadingRegistry: false });
+ return true;
} catch (error) {
console.error('[PluginsStore] Failed to load plugin registry info:', error);
set({ isLoadingRegistry: false });
+ return false;
}
},
diff --git a/scripts/generate-icon-sprite.mjs b/scripts/generate-icon-sprite.mjs
index 1d1f1c3e..3098bb05 100644
--- a/scripts/generate-icon-sprite.mjs
+++ b/scripts/generate-icon-sprite.mjs
@@ -22,6 +22,21 @@ const customIconData = new Map([
"openchamber",
``,
],
+ // Claude spark — official Anthropic mark (Simple Icons path), monochrome.
+ [
+ "claude-code",
+ ``,
+ ],
+ // Cursor two-cursor mark — official (Simple Icons path), monochrome.
+ [
+ "cursor",
+ ``,
+ ],
+ // Command Code — corner squares + center square (official logo geometry).
+ [
+ "command-code",
+ ``,
+ ],
])
const source = readFileSync(remixPath, "utf-8")