feat: plugin settings (#1375)

* feat(settings): add opencode plugins page

Manage opencode `plugin` array entries (npm, scoped npm, versioned,
local paths) and auto-loaded plugin files in `~/.config/opencode/plugins/`
and `<project>/.opencode/plugins/`. Mirrors MCP CRUD pattern.

- Server: `plugins.js` data layer + `plugin-routes.js` REST routes
- UI: PluginsSidebar / PluginsPage / AddPluginDialog
- Store: usePluginsStore (cache TTL, in-flight dedup, narrow selectors)
- i18n: 41 keys across 7 locales

Whitelist /api/config/plugins in JSON body-parser so POST/PATCH bodies
parse; opencode plugin specs runtime-resolve OPENCODE_CONFIG dir so
parallel test files do not cross-pollute module-frozen consts.

* feat(settings/plugins): hook npm registry for update + invalid-version detection

Plugins page now consults registry.npmjs.org with a 1h server cache. Sidebar
rows show an update badge with the latest version, group headers show how
many updates are available, the kebab adds an "Update to latest" action
that reuses the existing PATCH+restart flow, and the editor surfaces a
banner for update-available / missing-version / missing-package / malformed
/ missing-path / unreadable-path / offline-registry states. A refresh
button in the sidebar header forces a cache bypass.

- Server: `npm-registry.js` (cache + in-flight dedup + 5s timeout, 404
  cached, network failures NOT cached) + `plugin-spec.js` (parser + exact
  semver detection) + `GET /api/config/plugins/registry?specs=...&refresh=`
- Routes accept up to 100 specs/request, dedup by npm package name before
  fetching, classify each result by kind, never propagate network failure
  as 500.
- Client: `registryInfo` slice + `loadRegistryInfo` (fire-and-forget after
  loadPlugins, refreshes on mutations) + `updateToLatest(id)`.
- UI: `RegistryBadge` per-row + `RegistryBanner` per-entry editor, both
  use theme tokens (text-only color, no new bg/border tokens) and the
  shared Icon sprite. Per-spec subscriptions only.
- i18n: 24 new keys (incl. split singular/plural for "N update(s)
  available" because the runtime does not parse ICU plural format).

* fix(settings/plugins): keep registry badge visible for long specs

Sidebar entry row used `inline-flex` with `truncate` only on the spec
text. With long npm specs the badge could be pushed past the row edge
and clipped by the parent overflow. Switch to `flex` with spec
`flex-1 min-w-0 truncate` and add `shrink-0` to the badge wrapper so
the update indicator stays anchored to the right of the row.

* fix(settings/plugins): use code-box icon to distinguish from MCP

Plugins nav entry used 'plug' which is visually too close to MCP's
'plug-2' icon. Swap to 'code-box' for clearer differentiation in the
Settings nav list.

* Update packages/ui/src/components/sections/plugins/PluginsPage.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>

* Update packages/ui/src/stores/usePluginsStore.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>

* fix(settings/plugins): validate registry directory + surface save errors

- registry endpoint: return 400 on invalid directory query (was silently falling back to homedir, breaking relative path specs)
- save failure toast: prefer result.message over generic 'Reload failed'

* fix(settings/plugins): address review follow-ups

---------

Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Quat3rnion
2026-05-25 19:20:04 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a25e64099c
commit 2b47d899c6
29 changed files with 4667 additions and 0 deletions
+467
View File
@@ -0,0 +1,467 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import {
startConfigUpdate,
finishConfigUpdate,
} from '@/lib/configUpdate';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
export type PluginScope = 'user' | 'project';
export 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;
};
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<void>;
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;
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;
export 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 fetch(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;
}
set({ isLoadingRegistry: true });
try {
const configDirectory = getConfigDirectory();
const nextRegistryInfo: Record<string, RegistryResult> = { ...get().registryInfo };
for (const chunk of chunkSpecs(specs)) {
const response = await fetch(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 });
} catch (error) {
console.error('[PluginsStore] Failed to load plugin registry info:', error);
set({ isLoadingRegistry: 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 fetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get);
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 fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'PATCH',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(buildEntryBody(input)),
});
return response;
}, get);
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 fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get);
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 fetch(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 fetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
method: 'POST',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get);
},
updateFile: async (id, input) => {
return runPluginMutation('Updating plugin file…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'PUT',
headers: buildJsonHeaders(configDirectory),
body: JSON.stringify(input),
});
return response;
}, get);
},
deleteFile: async (id) => {
const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => {
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
method: 'DELETE',
headers: buildDirectoryHeaders(configDirectory),
});
return response;
}, get);
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: createJSONStorage(() => getSafeStorage()),
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,
): Promise<PluginMutationResult> {
startConfigUpdate(progressMessage);
let requiresReload = false;
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?.requiresReload) {
requiresReload = true;
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 };
} finally {
if (!requiresReload) finishConfigUpdate();
}
}
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}