feat(settings): retire the third-party plugin integrations

Settings → Integrations offered install cards for the Claude Code and
Cursor provider plugins. They are gone: the section, its plugin catalog,
its own i18n module and tests, the settings search entries, the page
keywords, and the two sprite icons only it used. The page now holds the
built-in GitHub and Linear cards, so it is hidden in VS Code where neither
applies; its title and description live in the settings dictionaries.

Docs follow: the Integrations page in every locale now documents GitHub
(pointing at its own page) and Linear in full, the GitHub page names
Settings → Integrations as the place to connect and covers linking an
issue or PR to a message, and the Providers pages no longer promise Claude
or Cursor subscriptions.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 23:34:15 +03:00
parent 34bf631157
commit d6f0f2f23c
58 changed files with 435 additions and 1764 deletions
@@ -6,23 +6,13 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GitHubIntegration } from './GitHubIntegration';
import { LinearSettings } from './LinearSettings';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
export const IntegrationsPage: React.FC = () => {
const { t } = useI18n();
// GitHub sign-in is an OpenChamber server feature; the VS Code extension
// uses the editor's own GitHub session instead.
const hasGitHub = !isVSCodeRuntime();
const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
const hasBuiltIn = hasGitHub || hasLinear;
return (
<SettingsPageLayout
@@ -30,23 +20,16 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
description={t('settings.page.integrations.description')}
showSaveStatus
>
{hasBuiltIn ? (
<SettingsSection
title={t('settings.integrations.firstParty.title')}
info={t('settings.integrations.firstParty.info')}
divider={false}
settingsItem="integrations.first-party"
contentClassName="space-y-3"
>
{hasGitHub ? <GitHubIntegration /> : null}
{hasLinear ? <LinearSettings /> : null}
</SettingsSection>
) : null}
<ThirdPartyIntegrationsSection
divider={hasBuiltIn}
onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager}
/>
<SettingsSection
title={t('settings.integrations.firstParty.title')}
info={t('settings.integrations.firstParty.info')}
divider={false}
settingsItem="integrations.first-party"
contentClassName="space-y-3"
>
{hasGitHub ? <GitHubIntegration /> : null}
{hasLinear ? <LinearSettings /> : null}
</SettingsSection>
</SettingsPageLayout>
);
};
@@ -1,448 +0,0 @@
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
import {
usePluginsStore,
type PluginMutationResult,
} from '@/stores/usePluginsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import {
getCatalogPluginPrimaryAction,
getCatalogPluginPresentation,
getCatalogPluginState,
getLatestNpmSpec,
THIRD_PARTY_PLUGINS,
type ThirdPartyPluginDefinition,
} from './thirdPartyPlugins';
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
type RemoveTarget = ThirdPartyPluginDefinition | null;
interface ThirdPartyIntegrationsSectionProps {
divider?: boolean;
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
const requiresRestart = (result: PluginMutationResult): boolean =>
result.restartDeferred === true
|| result.requiresManualRestart === true
|| result.reloadFailed === true;
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
divider = true,
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
const {
entries,
registryInfo,
loadPlugins,
loadRegistryInfo,
createEntry,
updateEntry,
deleteEntry,
} = usePluginsStore(
useShallow((state) => ({
entries: state.entries,
registryInfo: state.registryInfo,
loadPlugins: state.loadPlugins,
loadRegistryInfo: state.loadRegistryInfo,
createEntry: state.createEntry,
updateEntry: state.updateEntry,
deleteEntry: state.deleteEntry,
})),
);
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<{
pluginId: string;
action: PendingAction;
} | null>(null);
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
const refresh = React.useCallback(async () => {
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);
}, [loadPlugins, loadRegistryInfo]);
React.useEffect(() => {
void refresh();
}, [refresh]);
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
);
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
// When deferred plugin restarts are applied (pending plugins scope clears), drop
// local restart/unavailable flags and reload so statuses update immediately.
React.useEffect(() => {
const previousCount = previousPluginRestartCountRef.current;
previousPluginRestartCountRef.current = pendingPluginRestartCount;
if (isApplyingRestart) {
return;
}
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
return;
}
setRestartRequiredIds(new Set());
setProviderUnavailableIds(new Set());
void refresh();
}, [isApplyingRestart, pendingPluginRestartCount, 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<PendingAction, 'setup'>,
run: () => Promise<PluginMutationResult>,
) => {
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);
const restartNeeded = requiresRestart(result);
setRestartRequired(plugin.id, restartNeeded);
const toastOptions = restartNeeded
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
: undefined;
if (action === 'install') {
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
} else if (action === 'update') {
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
} else {
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
}
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 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];
const open = openPluginIds.has(plugin.id);
return (
<Collapsible
key={plugin.id}
open={open}
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
>
<div
data-settings-item={`integrations.third-party.${plugin.id}`}
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(plugin.descriptionKey)}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{status}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{state.projectEntries.length > 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.thirdParty.status.projectInstalled')}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={primaryAction === 'manage' ? 'outline' : 'default'}
onClick={() => void handlePrimaryAction(plugin)}
disabled={actionDisabled}
>
{isPending ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : primaryAction === 'setup' ? (
<Icon name="plug-2" className="size-3.5" />
) : null}
{primaryLabel}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void openExternalUrl(plugin.homepage)}
>
<Icon name="external-link" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.docs')}
</Button>
{state.userEntry && !state.userEntryIsAmbiguous ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setRemoveTarget(plugin)}
disabled={isPending}
>
<Icon name="delete-bin" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
) : null}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
return (
<>
<SettingsSection
title={t('settings.integrations.thirdParty.title')}
info={t('settings.integrations.thirdParty.info')}
divider={divider}
settingsItem="integrations.third-party"
contentClassName="space-y-3"
>
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection>
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
<DialogDescription>
{t('settings.integrations.thirdParty.dialog.remove.description', {
name: removeTarget ? t(removeTarget.nameKey) : '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,213 +0,0 @@
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<typeof getCatalogPluginState>,
options?: {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
},
) => {
status: CatalogPresentationStatus;
latestVersion: string | null;
};
const getCatalogPluginPresentation = (
thirdPartyCatalog as unknown as {
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
}
).getCatalogPluginPresentation;
const claudePackage = '@openchamber/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('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
});
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
id: plugin.id,
packageName: plugin.packageName,
homepage: plugin.homepage,
}))).toEqual([
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
]);
});
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');
});
});
@@ -1,156 +0,0 @@
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 tint (e.g. Claude orange); neutral marks use text-foreground. */
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
homepage: string;
}
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
providerId: 'claude-code',
icon: 'claude-code',
brandClassName: 'text-[#D97757]',
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
providerId: 'cursor',
icon: 'cursor',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
homepage: 'https://github.com/openchamber/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<string, RegistryResult>,
): 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 };
}