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 <makeittech@users.noreply.github.com>
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
|
import { startConfigUpdate } from '@/lib/configUpdate';
|
|
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart';
|
|
|
|
export type PluginScope = 'user' | 'project';
|
|
type PluginParsedKind = 'npm' | 'path';
|
|
|
|
export interface PluginEntry {
|
|
id: string;
|
|
spec: string;
|
|
options?: Record<string, unknown>;
|
|
scope: PluginScope;
|
|
kind: 'config';
|
|
parsedKind: PluginParsedKind;
|
|
}
|
|
|
|
export interface PluginFile {
|
|
id: string;
|
|
fileName: string;
|
|
scope: PluginScope;
|
|
kind: 'file';
|
|
}
|
|
|
|
export interface PluginDraft {
|
|
mode: 'entry' | 'file';
|
|
scope: PluginScope;
|
|
spec: string;
|
|
optionsJson: string;
|
|
fileName: string;
|
|
content: string;
|
|
}
|
|
|
|
export type PluginMutationResult = {
|
|
ok: boolean;
|
|
reloadFailed?: boolean;
|
|
message?: string;
|
|
warning?: string;
|
|
requiresManualRestart?: boolean;
|
|
restartDeferred?: boolean;
|
|
};
|
|
|
|
export type RegistryResult =
|
|
| { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean }
|
|
| { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] }
|
|
| { kind: 'npm-missing-package'; spec: string; name: string; error: string }
|
|
| { kind: 'npm-malformed'; spec: string; error: string }
|
|
| { kind: 'npm-network'; spec: string; error: string }
|
|
| { kind: 'path-ok'; spec: string; absolutePath: string }
|
|
| { kind: 'path-missing'; spec: string; absolutePath: string }
|
|
| { kind: 'path-unreadable'; spec: string; absolutePath: string };
|
|
|
|
export interface PluginsStore {
|
|
entries: PluginEntry[];
|
|
files: PluginFile[];
|
|
selectedId: string | null;
|
|
isLoading: boolean;
|
|
registryInfo: Record<string, RegistryResult>;
|
|
isLoadingRegistry: boolean;
|
|
draft: PluginDraft | null;
|
|
|
|
setSelected: (id: string | null) => void;
|
|
setDraft: (draft: PluginDraft | null) => void;
|
|
loadPlugins: (options?: { force?: boolean }) => Promise<boolean>;
|
|
loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise<boolean>;
|
|
updateToLatest: (id: string) => Promise<PluginMutationResult>;
|
|
createEntry: (input: { spec: string; options?: Record<string, unknown>; scope: PluginScope }) => Promise<PluginMutationResult>;
|
|
updateEntry: (id: string, input: { spec?: string; options?: Record<string, unknown> }) => Promise<PluginMutationResult>;
|
|
deleteEntry: (id: string) => Promise<PluginMutationResult>;
|
|
readFile: (id: string) => Promise<{ fileName: string; scope: PluginScope; content: string } | null>;
|
|
createFile: (input: { fileName: string; content: string; scope: PluginScope }) => Promise<PluginMutationResult>;
|
|
updateFile: (id: string, input: { content: string }) => Promise<PluginMutationResult>;
|
|
deleteFile: (id: string) => Promise<PluginMutationResult>;
|
|
getById: (id: string) => PluginEntry | PluginFile | undefined;
|
|
}
|
|
|
|
type PluginsListResponse = {
|
|
entries?: PluginEntry[];
|
|
files?: PluginFile[];
|
|
};
|
|
|
|
type RegistryInfoResponse = {
|
|
results?: RegistryResult[];
|
|
};
|
|
|
|
type PluginMutationPayload = {
|
|
success?: boolean;
|
|
requiresReload?: boolean;
|
|
requiresRestart?: boolean;
|
|
restartDeferred?: boolean;
|
|
requiresManualRestart?: boolean;
|
|
message?: string;
|
|
reloadDelayMs?: number;
|
|
reloadFailed?: boolean;
|
|
warning?: string;
|
|
error?: string;
|
|
};
|
|
|
|
type PluginFileContent = {
|
|
fileName: string;
|
|
scope: PluginScope;
|
|
content: string;
|
|
};
|
|
|
|
const getConfigDirectory = (): string | null => {
|
|
try {
|
|
const projectsStore = useProjectsStore.getState();
|
|
const activeProject = projectsStore.getActiveProject?.();
|
|
if (activeProject?.path?.trim()) {
|
|
return activeProject.path.trim();
|
|
}
|
|
|
|
const clientDir = opencodeClient.getDirectory();
|
|
if (clientDir?.trim()) {
|
|
return clientDir.trim();
|
|
}
|
|
} catch (err) {
|
|
console.warn('[PluginsStore] Error resolving config directory:', err);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const CLIENT_RELOAD_DELAY_MS = 800;
|
|
const PLUGINS_LOAD_CACHE_TTL_MS = 5000;
|
|
const DEFAULT_PLUGINS_CACHE_KEY = '__default__';
|
|
const pluginsLastLoadedAt = new Map<string, number>();
|
|
const pluginsLoadInFlight = new Map<string, Promise<boolean>>();
|
|
const REGISTRY_SPECS_CHUNK_LIMIT = 1500;
|
|
|
|
const getPluginCacheKey = (directory: string | null): string => {
|
|
return directory?.trim() || DEFAULT_PLUGINS_CACHE_KEY;
|
|
};
|
|
|
|
const invalidatePluginCache = (directory: string | null) => {
|
|
pluginsLastLoadedAt.delete(getPluginCacheKey(directory));
|
|
};
|
|
|
|
export const usePluginsStore = create<PluginsStore>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
entries: [],
|
|
files: [],
|
|
selectedId: null,
|
|
isLoading: false,
|
|
registryInfo: {},
|
|
isLoadingRegistry: false,
|
|
draft: null,
|
|
|
|
setSelected: (id) => set({ selectedId: id }),
|
|
|
|
setDraft: (draft) => set({ draft }),
|
|
|
|
loadPlugins: async (options) => {
|
|
const configDirectory = getConfigDirectory();
|
|
const cacheKey = getPluginCacheKey(configDirectory);
|
|
const now = Date.now();
|
|
const loadedAt = pluginsLastLoadedAt.get(cacheKey) ?? 0;
|
|
const hasCachedPlugins = get().entries.length > 0 || get().files.length > 0;
|
|
|
|
if (!options?.force && hasCachedPlugins && now - loadedAt < PLUGINS_LOAD_CACHE_TTL_MS) {
|
|
return true;
|
|
}
|
|
|
|
const inFlight = pluginsLoadInFlight.get(cacheKey);
|
|
if (!options?.force && inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const request = (async () => {
|
|
set({ isLoading: true });
|
|
try {
|
|
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins', configDirectory), {
|
|
headers: buildDirectoryHeaders(configDirectory),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load plugins');
|
|
}
|
|
const data = await readJson<PluginsListResponse>(response);
|
|
set({ entries: data.entries ?? [], files: data.files ?? [], isLoading: false });
|
|
pluginsLastLoadedAt.set(cacheKey, Date.now());
|
|
if (!options?.force) {
|
|
void get().loadRegistryInfo();
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[PluginsStore] Failed to load plugins:', error);
|
|
set({ isLoading: false });
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
pluginsLoadInFlight.set(cacheKey, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
pluginsLoadInFlight.delete(cacheKey);
|
|
}
|
|
},
|
|
|
|
loadRegistryInfo: async (opts) => {
|
|
const specs = dedupeSpecs(opts?.specs ?? get().entries.map((entry) => entry.spec));
|
|
if (specs.length === 0) {
|
|
set({ isLoadingRegistry: false });
|
|
return true;
|
|
}
|
|
|
|
set({ isLoadingRegistry: true });
|
|
try {
|
|
const configDirectory = getConfigDirectory();
|
|
const nextRegistryInfo: Record<string, RegistryResult> = { ...get().registryInfo };
|
|
for (const chunk of chunkSpecs(specs)) {
|
|
const response = await runtimeFetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), {
|
|
headers: buildDirectoryHeaders(configDirectory),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load plugin registry info');
|
|
}
|
|
const data = await readJson<RegistryInfoResponse>(response);
|
|
for (const result of data.results ?? []) {
|
|
nextRegistryInfo[result.spec] = result;
|
|
}
|
|
}
|
|
set({ registryInfo: nextRegistryInfo, isLoadingRegistry: false });
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[PluginsStore] Failed to load plugin registry info:', error);
|
|
set({ isLoadingRegistry: false });
|
|
return false;
|
|
}
|
|
},
|
|
|
|
updateToLatest: async (id) => {
|
|
const entry = get().entries.find((plugin) => plugin.id === id);
|
|
if (!entry) return { ok: false };
|
|
const info = get().registryInfo[entry.spec];
|
|
if (!info || info.kind !== 'npm-ok' || !info.hasUpdate || !info.latestVersion) {
|
|
return { ok: false };
|
|
}
|
|
return await get().updateEntry(id, { spec: `${info.name}@${info.latestVersion}` });
|
|
},
|
|
|
|
createEntry: async (input) => {
|
|
const result = await runPluginMutation('Creating plugin entry…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
|
|
method: 'POST',
|
|
headers: buildJsonHeaders(configDirectory),
|
|
body: JSON.stringify(buildEntryBody(input)),
|
|
});
|
|
return response;
|
|
}, get, { restartId: input.spec });
|
|
if (result.ok) {
|
|
void get().loadRegistryInfo({ specs: [input.spec], force: true });
|
|
}
|
|
return result;
|
|
},
|
|
|
|
updateEntry: async (id, input) => {
|
|
const existingSpec = get().entries.find((plugin) => plugin.id === id)?.spec;
|
|
const nextSpec = input.spec ?? existingSpec;
|
|
const result = await runPluginMutation('Updating plugin entry…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
|
method: 'PATCH',
|
|
headers: buildJsonHeaders(configDirectory),
|
|
body: JSON.stringify(buildEntryBody(input)),
|
|
});
|
|
return response;
|
|
}, get, { restartId: id });
|
|
if (result.ok && nextSpec) {
|
|
void get().loadRegistryInfo({ specs: [nextSpec], force: true });
|
|
}
|
|
return result;
|
|
},
|
|
|
|
deleteEntry: async (id) => {
|
|
const entryToDelete = get().entries.find((plugin) => plugin.id === id);
|
|
const result = await runPluginMutation('Deleting plugin entry…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
|
method: 'DELETE',
|
|
headers: buildDirectoryHeaders(configDirectory),
|
|
});
|
|
return response;
|
|
}, get, { restartId: id });
|
|
|
|
if (result.ok && get().selectedId === id) {
|
|
set({ selectedId: null });
|
|
}
|
|
if (result.ok && entryToDelete) {
|
|
const nextRegistryInfo = { ...get().registryInfo };
|
|
delete nextRegistryInfo[entryToDelete.spec];
|
|
set({ registryInfo: nextRegistryInfo });
|
|
}
|
|
return result;
|
|
},
|
|
|
|
readFile: async (id) => {
|
|
try {
|
|
const configDirectory = getConfigDirectory();
|
|
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
|
headers: buildDirectoryHeaders(configDirectory),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to read plugin file');
|
|
}
|
|
return await readJson<PluginFileContent>(response);
|
|
} catch (error) {
|
|
console.error('[PluginsStore] Failed to read plugin file:', error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
createFile: async (input) => {
|
|
return runPluginMutation('Creating plugin file…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
|
|
method: 'POST',
|
|
headers: buildJsonHeaders(configDirectory),
|
|
body: JSON.stringify(input),
|
|
});
|
|
return response;
|
|
}, get, { restartId: input.fileName });
|
|
},
|
|
|
|
updateFile: async (id, input) => {
|
|
return runPluginMutation('Updating plugin file…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
|
method: 'PUT',
|
|
headers: buildJsonHeaders(configDirectory),
|
|
body: JSON.stringify(input),
|
|
});
|
|
return response;
|
|
}, get, { restartId: id });
|
|
},
|
|
|
|
deleteFile: async (id) => {
|
|
const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => {
|
|
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
|
method: 'DELETE',
|
|
headers: buildDirectoryHeaders(configDirectory),
|
|
});
|
|
return response;
|
|
}, get, { restartId: id });
|
|
|
|
if (result.ok && get().selectedId === id) {
|
|
set({ selectedId: null });
|
|
}
|
|
return result;
|
|
},
|
|
|
|
getById: (id) => {
|
|
return get().entries.find((plugin) => plugin.id === id) ?? get().files.find((plugin) => plugin.id === id);
|
|
},
|
|
}),
|
|
{
|
|
name: 'plugins-store',
|
|
storage: createDeferredSafeJSONStorage(),
|
|
partialize: (state) => ({ selectedId: state.selectedId }),
|
|
},
|
|
),
|
|
{ name: 'plugins-store' },
|
|
),
|
|
);
|
|
|
|
function buildPluginsUrl(path: string, directory: string | null): string {
|
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
|
return `${path}${queryParams}`;
|
|
}
|
|
|
|
function buildRegistryUrl(specs: string[], force: boolean, directory: string | null): string {
|
|
const params = new URLSearchParams();
|
|
if (force) params.set('refresh', 'true');
|
|
if (directory) params.set('directory', directory);
|
|
const suffix = params.toString();
|
|
const specsParam = `specs=${specs.map(encodeURIComponent).join(',')}`;
|
|
return `/api/config/plugins/registry?${specsParam}${suffix ? `&${suffix}` : ''}`;
|
|
}
|
|
|
|
function dedupeSpecs(specs: string[]): string[] {
|
|
return Array.from(new Set(specs.map((spec) => spec.trim()).filter(Boolean)));
|
|
}
|
|
|
|
function chunkSpecs(specs: string[]): string[][] {
|
|
const chunks: string[][] = [];
|
|
let current: string[] = [];
|
|
let currentLength = 0;
|
|
|
|
for (const spec of specs) {
|
|
const encodedSpec = encodeURIComponent(spec);
|
|
const nextLength = current.length === 0 ? encodedSpec.length : currentLength + 1 + encodedSpec.length;
|
|
if (current.length > 0 && nextLength > REGISTRY_SPECS_CHUNK_LIMIT) {
|
|
chunks.push(current);
|
|
current = [spec];
|
|
currentLength = encodedSpec.length;
|
|
} else {
|
|
current.push(spec);
|
|
currentLength = nextLength;
|
|
}
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
chunks.push(current);
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function buildDirectoryHeaders(directory: string | null): HeadersInit | undefined {
|
|
return directory ? { 'x-opencode-directory': directory } : undefined;
|
|
}
|
|
|
|
function buildJsonHeaders(directory: string | null): HeadersInit {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
|
};
|
|
}
|
|
|
|
function buildEntryBody(input: { spec?: string; options?: Record<string, unknown>; scope?: PluginScope }): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {};
|
|
if (input.spec !== undefined) body.spec = input.spec;
|
|
if (input.options !== undefined) body.options = input.options;
|
|
if (input.scope !== undefined) body.scope = input.scope;
|
|
return body;
|
|
}
|
|
|
|
async function runPluginMutation(
|
|
progressMessage: string,
|
|
request: (configDirectory: string | null) => Promise<Response>,
|
|
get: () => PluginsStore,
|
|
options?: { restartId?: string },
|
|
): Promise<PluginMutationResult> {
|
|
try {
|
|
const configDirectory = getConfigDirectory();
|
|
const response = await request(configDirectory);
|
|
const payload = await readJson<PluginMutationPayload | null>(response).catch(() => null);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error || 'Failed to update plugin configuration');
|
|
}
|
|
|
|
invalidatePluginCache(configDirectory);
|
|
|
|
if (payload?.requiresManualRestart) {
|
|
await get().loadPlugins({ force: true });
|
|
return {
|
|
ok: true,
|
|
requiresManualRestart: true,
|
|
reloadFailed: payload?.reloadFailed === true,
|
|
message: payload?.message,
|
|
warning: payload?.warning,
|
|
};
|
|
}
|
|
|
|
if (noteDeferredRestartFromPayload(payload, 'plugins', { id: options?.restartId })) {
|
|
await get().loadPlugins({ force: true });
|
|
return {
|
|
ok: true,
|
|
restartDeferred: true,
|
|
reloadFailed: payload?.reloadFailed === true,
|
|
message: payload?.message,
|
|
warning: payload?.warning,
|
|
};
|
|
}
|
|
|
|
if (payload?.requiresReload) {
|
|
startConfigUpdate(progressMessage);
|
|
await refreshAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
|
scopes: ['all'],
|
|
});
|
|
}
|
|
|
|
await get().loadPlugins({ force: true });
|
|
return {
|
|
ok: true,
|
|
reloadFailed: payload?.reloadFailed === true,
|
|
message: payload?.message,
|
|
warning: payload?.warning,
|
|
};
|
|
} catch (error) {
|
|
console.error('[PluginsStore] Failed to update plugin configuration:', error);
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
async function readJson<T>(response: Response): Promise<T> {
|
|
return (await response.json()) as T;
|
|
}
|