From af380081c29a28a8eecce0d0c6a3dfcea6232245 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 12 Aug 2026 08:57:09 +0000 Subject: [PATCH] feat(settings): add third-party integrations dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Settings → Integrations page for installing and managing the three supported OpenCode provider plugins (Claude Code, Command Code, Cursor), with search, i18n, and plugin-registry status wiring. Co-authored-by: Serhii Dziupin --- packages/ui/src/apps/MobileApp.tsx | 1 + .../src/assets/provider-logos/claude-code.svg | 4 + .../ui/src/assets/provider-logos/opencode.svg | 8 + packages/ui/src/components/icon/sprite.ts | 4 +- .../integrations/IntegrationCard.test.tsx | 54 +++ .../sections/integrations/IntegrationCard.tsx | 64 +++ .../integrations/IntegrationsPage.tsx | 29 ++ .../ThirdPartyIntegrationsSection.tsx | 442 ++++++++++++++++++ .../integrations/thirdPartyPlugins.test.ts | 194 ++++++++ .../integrations/thirdPartyPlugins.ts | 166 +++++++ .../ui/src/components/ui/ProviderLogo.tsx | 5 +- .../ui/providerLogoFallback.test.ts | 13 + .../src/components/ui/providerLogoFallback.ts | 5 + .../ui/src/components/views/SettingsView.tsx | 31 +- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../third-party-integrations.i18n.test.ts | 28 ++ .../messages/third-party-integrations.i18n.ts | 399 ++++++++++++++++ .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/settings/metadata.ts | 7 +- packages/ui/src/lib/settings/search.test.ts | 30 ++ packages/ui/src/lib/settings/search.ts | 28 ++ .../ui/src/stores/usePluginsStore.test.ts | 14 +- packages/ui/src/stores/usePluginsStore.ts | 6 +- scripts/generate-icon-sprite.mjs | 15 + 33 files changed, 1562 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/assets/provider-logos/claude-code.svg create mode 100644 packages/ui/src/assets/provider-logos/opencode.svg create mode 100644 packages/ui/src/components/sections/integrations/IntegrationCard.test.tsx create mode 100644 packages/ui/src/components/sections/integrations/IntegrationCard.tsx create mode 100644 packages/ui/src/components/sections/integrations/IntegrationsPage.tsx create mode 100644 packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx create mode 100644 packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts create mode 100644 packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts create mode 100644 packages/ui/src/components/ui/providerLogoFallback.test.ts create mode 100644 packages/ui/src/components/ui/providerLogoFallback.ts create mode 100644 packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts create mode 100644 packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts create mode 100644 packages/ui/src/lib/settings/search.test.ts 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)} + + + !open && setRemoveTarget(null)}> + + + {t('settings.integrations.thirdParty.dialog.remove.title')} + + {t('settings.integrations.thirdParty.dialog.remove.description', { + name: removeTarget ? t(removeTarget.nameKey) : '', + })} + + + + + + + + + + ); +}; 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")