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
+3
View File
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Settings: added a Plugins page to view, add, edit, and remove opencode plugins. Supports `plugin` array entries (npm packages, scoped npm, versioned specs, local paths) with optional options JSON, plus auto-loaded plugin files in `~/.config/opencode/plugins/` and `<project>/.opencode/plugins/`.
- Settings/Plugins: the Plugins page now talks to the npm registry. Sidebar rows show an update badge with the latest available version, group headers show how many updates are available, the kebab menu adds an "Update to latest" action, and the editor surfaces a banner for available updates, missing/invalid versions, missing-package, missing local file, and offline-registry states. Results are cached for one hour with a "Check for updates" button in the sidebar header for forced refresh.
## [1.11.5] - 2026-05-25 ## [1.11.5] - 2026-05-25
- Chat/Input: pending image attachments now show previews, sent image attachments can be cited from assistant messages, and markdown source mode highlights formatting while you type. - Chat/Input: pending image attachments now show previews, sent image attachments can be cited from assistant messages, and markdown source mode highlights formatting while you type.
@@ -0,0 +1,304 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Radio } from '@/components/ui/radio';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { usePluginsStore, type PluginScope } from '@/stores/usePluginsStore';
type TabKey = 'npm' | 'path' | 'file';
interface AddPluginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultScope?: PluginScope;
}
const FILENAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
function parseOptions(raw: string): { ok: true; value?: Record<string, unknown> } | { ok: false } {
const trimmed = raw.trim();
if (trimmed === '') return { ok: true, value: undefined };
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false };
}
}
export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
open,
onOpenChange,
defaultScope = 'user',
}) => {
const { t } = useI18n();
const createEntry = usePluginsStore((s) => s.createEntry);
const createFile = usePluginsStore((s) => s.createFile);
const [tab, setTab] = React.useState<TabKey>('npm');
const [spec, setSpec] = React.useState('');
const [optionsJson, setOptionsJson] = React.useState('');
const [fileName, setFileName] = React.useState('');
const [content, setContent] = React.useState('');
const [scope, setScope] = React.useState<PluginScope>(defaultScope);
const [submitting, setSubmitting] = React.useState(false);
const resetForm = React.useCallback(() => {
setSpec('');
setOptionsJson('');
setFileName('');
setContent('');
setScope(defaultScope);
}, [defaultScope]);
React.useEffect(() => {
if (open) {
setTab('npm');
resetForm();
}
}, [open, resetForm]);
const handleTabChange = (next: TabKey) => {
if (next === tab) return;
setTab(next);
resetForm();
};
const optionsResult = React.useMemo(() => parseOptions(optionsJson), [optionsJson]);
const optionsInvalid = !optionsResult.ok;
const fileNameInvalid = tab === 'file' && fileName.trim() !== '' && !FILENAME_PATTERN.test(fileName.trim());
const specEmpty = (tab === 'npm' || tab === 'path') && spec.trim() === '';
const contentEmpty = tab === 'file' && content.trim() === '';
const fileNameEmpty = tab === 'file' && fileName.trim() === '';
const submitDisabled =
submitting ||
optionsInvalid ||
(tab === 'npm' && specEmpty) ||
(tab === 'path' && specEmpty) ||
(tab === 'file' && (fileNameEmpty || fileNameInvalid || contentEmpty));
const handleSubmit = async () => {
if (submitDisabled) return;
setSubmitting(true);
try {
let result;
if (tab === 'file') {
result = await createFile({ fileName: fileName.trim(), content, scope });
} else {
result = await createEntry({
spec: spec.trim(),
options: optionsResult.ok ? optionsResult.value : undefined,
scope,
});
}
if (result.ok) {
toast.success(result.message || t('settings.plugins.toast.created'));
if (result.reloadFailed) {
toast.warning(t('settings.plugins.toast.reloadFailed'));
}
onOpenChange(false);
} else {
toast.error(result.message || t('settings.plugins.sidebar.toast.deleteFailed'));
}
} finally {
setSubmitting(false);
}
};
const tabs = React.useMemo<SortableTabsStripItem[]>(() => [
{ id: 'npm', label: t('settings.plugins.dialog.add.tab.npm') },
{ id: 'path', label: t('settings.plugins.dialog.add.tab.path') },
{ id: 'file', label: t('settings.plugins.dialog.add.tab.file') },
], [t]);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next && submitting) return;
onOpenChange(next);
}}
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('settings.plugins.dialog.add.title')}</DialogTitle>
<DialogDescription>
{t('settings.plugins.sidebar.empty.description')}
</DialogDescription>
</DialogHeader>
<SortableTabsStrip
items={tabs}
activeId={tab}
onSelect={(id) => handleTabChange(id as TabKey)}
layoutMode="fit"
variant="active-pill"
activePillLowercase={false}
className="h-10"
/>
<div className="flex flex-col gap-4">
{(tab === 'npm' || tab === 'path') && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor="plugin-spec" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.spec')}
</label>
<Input
id="plugin-spec"
value={spec}
onChange={(e) => setSpec(e.target.value)}
placeholder={t('settings.plugins.page.field.spec.placeholder')}
aria-invalid={specEmpty ? false : undefined}
disabled={submitting}
/>
{specEmpty && (
<p className="typography-meta text-muted-foreground">
{t('settings.plugins.validation.specRequired')}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="plugin-options" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.options')}
</label>
<Textarea
id="plugin-options"
value={optionsJson}
onChange={(e) => setOptionsJson(e.target.value)}
rows={5}
className="font-mono"
hasError={optionsInvalid}
disabled={submitting}
/>
{optionsInvalid && (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.plugins.page.field.options.invalidJson')}
</p>
)}
</div>
</>
)}
{tab === 'file' && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor="plugin-filename" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.fileName')}
</label>
<Input
id="plugin-filename"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="my-plugin.ts"
aria-invalid={fileNameInvalid || undefined}
disabled={submitting}
/>
{fileNameInvalid && (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.plugins.validation.fileName')}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="plugin-content" className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.content')}
</label>
<Textarea
id="plugin-content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={16}
className="font-mono"
disabled={submitting}
/>
</div>
</>
)}
<div className="flex flex-col gap-1.5">
<span className="typography-ui-label text-foreground">
{t('settings.plugins.page.field.scope')}
</span>
<div className="flex items-center gap-4">
{(['user', 'project'] as const).map((value) => {
const selected = scope === value;
const label =
value === 'user'
? t('settings.plugins.scope.user')
: t('settings.plugins.scope.project');
return (
<button
key={value}
type="button"
onClick={() => setScope(value)}
disabled={submitting}
className="flex items-center gap-2 py-1 text-left disabled:opacity-50"
>
<Radio
checked={selected}
onChange={() => setScope(value)}
ariaLabel={label}
/>
<span
className={cn(
'typography-ui-label font-normal',
selected ? 'text-foreground' : 'text-foreground/60',
)}
>
{label}
</span>
</button>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button
size="sm"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t('settings.plugins.dialog.add.action.cancel')}
</Button>
<Button
size="sm"
onClick={() => {
void handleSubmit();
}}
disabled={submitDisabled}
>
{submitting ? (
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
) : null}
{t('settings.plugins.dialog.add.action.submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default AddPluginDialog;
@@ -0,0 +1,386 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { RegistryBanner } from './RegistryBanner';
import {
usePluginsStore,
type PluginDraft,
type PluginEntry,
type PluginFile,
type PluginScope,
} from '@/stores/usePluginsStore';
interface OptionsParseResult {
ok: boolean;
value?: Record<string, unknown>;
}
function parseOptionsJson(raw: string): OptionsParseResult {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return { ok: true, value: undefined };
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false };
}
}
function stringifyOptions(options: Record<string, unknown> | undefined): string {
if (!options || Object.keys(options).length === 0) {
return '';
}
return JSON.stringify(options, null, 2);
}
function buildEntryDraft(entry: PluginEntry): PluginDraft {
return {
mode: 'entry',
scope: entry.scope,
spec: entry.spec,
optionsJson: stringifyOptions(entry.options),
fileName: '',
content: '',
};
}
function buildFileDraft(file: PluginFile, content: string): PluginDraft {
return {
mode: 'file',
scope: file.scope,
spec: '',
optionsJson: '',
fileName: file.fileName,
content,
};
}
const ScopeBadge: React.FC<{ scope: PluginScope; label: string }> = ({ scope, label }) => {
return (
<span
className={cn(
'typography-micro font-medium rounded-full px-2 py-0.5',
'bg-[var(--surface-elevated)] text-muted-foreground',
'border border-[var(--interactive-border)]',
)}
data-scope={scope}
>
{label}
</span>
);
};
export const PluginsPage: React.FC = () => {
const { t } = useI18n();
const selectedId = usePluginsStore((s) => s.selectedId);
const entries = usePluginsStore((s) => s.entries);
const files = usePluginsStore((s) => s.files);
const draft = usePluginsStore((s) => s.draft);
const setDraft = usePluginsStore((s) => s.setDraft);
const updateEntry = usePluginsStore((s) => s.updateEntry);
const updateFile = usePluginsStore((s) => s.updateFile);
const readFile = usePluginsStore((s) => s.readFile);
const selectedEntry = React.useMemo(
() => (selectedId ? entries.find((e) => e.id === selectedId) ?? null : null),
[entries, selectedId],
);
const selectedFile = React.useMemo(
() => (selectedId ? files.find((f) => f.id === selectedId) ?? null : null),
[files, selectedId],
);
const [isSaving, setIsSaving] = React.useState(false);
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const originalFileContentById = React.useRef(new Map<string, string>());
React.useEffect(() => {
let cancelled = false;
if (selectedEntry) {
setDraft(buildEntryDraft(selectedEntry));
return () => {
cancelled = true;
};
}
if (selectedFile) {
setIsLoadingFile(true);
void (async () => {
const result = await readFile(selectedFile.id);
if (cancelled) return;
setIsLoadingFile(false);
const content = result?.content ?? '';
originalFileContentById.current.set(selectedFile.id, content);
setDraft(buildFileDraft(selectedFile, content));
})();
return () => {
cancelled = true;
};
}
setDraft(null);
return () => {
cancelled = true;
};
}, [selectedEntry, selectedFile, readFile, setDraft]);
if (!selectedId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<Icon name="plug" className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.plugins.page.empty.select')}</p>
<p className="typography-meta mt-1 opacity-75">
{t('settings.plugins.page.empty.add')}
</p>
</div>
</div>
);
}
if (selectedEntry && draft && draft.mode === 'entry') {
const optionsResult = parseOptionsJson(draft.optionsJson);
const optionsValid = optionsResult.ok;
const isDirty =
draft.spec !== selectedEntry.spec ||
draft.optionsJson !== stringifyOptions(selectedEntry.options);
const handleEntryDiscard = () => {
setDraft(buildEntryDraft(selectedEntry));
};
const handleEntrySave = async () => {
if (!optionsValid) return;
const spec = draft.spec.trim();
if (!spec) {
toast.error(t('settings.plugins.validation.specRequired'));
return;
}
setIsSaving(true);
try {
const result = await updateEntry(selectedEntry.id, {
spec,
options: optionsResult.value,
});
if (result.ok) {
if (result.reloadFailed) {
toast.warning(
result.message || t('settings.plugins.toast.reloadFailed'),
{ description: result.warning },
);
} else {
toast.success(result.message || t('settings.plugins.toast.updated'));
}
} else {
toast.error(result.message || t('settings.plugins.toast.reloadFailed'));
}
} finally {
setIsSaving(false);
}
};
return (
<SettingsPageLayout>
<div>
<div className="flex items-center gap-2 min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{t('settings.plugins.page.header.entry')}
</h2>
<ScopeBadge
scope={selectedEntry.scope}
label={
selectedEntry.scope === 'project'
? t('settings.plugins.sidebar.group.projectEntries')
: t('settings.plugins.sidebar.group.userEntries')
}
/>
</div>
</div>
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
<div className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.spec')}
</label>
<Input
value={draft.spec}
onChange={(e) =>
setDraft({ ...draft, spec: e.target.value })
}
placeholder={t('settings.plugins.page.field.spec.placeholder')}
className="font-mono typography-meta"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.options')}
</label>
<Textarea
value={draft.optionsJson}
onChange={(e) =>
setDraft({ ...draft, optionsJson: e.target.value })
}
rows={10}
className={cn(
'font-mono typography-meta min-h-[200px]',
!optionsValid && 'border-[var(--status-error-border)]',
)}
spellCheck={false}
placeholder='{ }'
/>
{!optionsValid && (
<p className="typography-micro text-[var(--status-error)]">
{t('settings.plugins.page.field.options.invalidJson')}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => void handleEntrySave()}
disabled={!isDirty || !optionsValid || isSaving}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEntryDiscard}
disabled={!isDirty || isSaving}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
</SettingsPageLayout>
);
}
if (selectedFile && draft && draft.mode === 'file') {
const originalContent = originalFileContentById.current.get(selectedFile.id) ?? '';
const isDirty = draft.content !== originalContent || draft.fileName !== selectedFile.fileName;
const handleFileDiscard = () => {
void (async () => {
setIsLoadingFile(true);
const result = await readFile(selectedFile.id);
const content = result?.content ?? '';
setIsLoadingFile(false);
originalFileContentById.current.set(selectedFile.id, content);
setDraft(buildFileDraft(selectedFile, content));
})();
};
const handleFileSave = async () => {
setIsSaving(true);
try {
const result = await updateFile(selectedFile.id, { content: draft.content });
if (result.ok) {
originalFileContentById.current.set(selectedFile.id, draft.content);
if (result.reloadFailed) {
toast.warning(
result.message || t('settings.plugins.toast.reloadFailed'),
{ description: result.warning },
);
} else {
toast.success(result.message || t('settings.plugins.toast.updated'));
}
} else {
toast.error(result.message || t('settings.plugins.toast.reloadFailed'));
}
} finally {
setIsSaving(false);
}
};
return (
<SettingsPageLayout>
<div>
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{t('settings.plugins.page.header.file')}
</h2>
<ScopeBadge
scope={selectedFile.scope}
label={
selectedFile.scope === 'project'
? t('settings.plugins.sidebar.group.projectFiles')
: t('settings.plugins.sidebar.group.userFiles')
}
/>
<span
className={cn(
'typography-micro font-mono rounded-full px-2 py-0.5',
'bg-[var(--surface-elevated)] text-foreground',
'border border-[var(--interactive-border)]',
)}
>
{selectedFile.fileName}
</span>
</div>
</div>
<div className="space-y-1.5">
<label className="typography-meta text-muted-foreground">
{t('settings.plugins.page.field.content')}
</label>
<Textarea
value={draft.content}
onChange={(e) =>
setDraft({ ...draft, content: e.target.value })
}
rows={16}
className="font-mono typography-meta min-h-[320px]"
spellCheck={false}
disabled={isLoadingFile}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => void handleFileSave()}
disabled={!isDirty || isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFileDiscard}
disabled={isSaving || isLoadingFile}
>
{t('settings.plugins.page.action.discard')}
</Button>
</div>
</SettingsPageLayout>
);
}
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<Icon name="loader-4" className="mx-auto mb-3 h-6 w-6 animate-spin opacity-50" />
</div>
</div>
);
};
@@ -0,0 +1,397 @@
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 { AddPluginDialog } from './AddPluginDialog';
import { RegistryBadge } from './RegistryBadge';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { useI18n } from '@/lib/i18n';
import {
usePluginsStore,
type PluginEntry,
type PluginFile,
} from '@/stores/usePluginsStore';
interface PluginsSidebarProps {
onItemSelect?: () => void;
onAddClick?: () => void;
}
type DeleteTarget =
| { kind: 'entry'; id: string; label: string }
| { kind: 'file'; id: string; label: string }
| null;
const entryIcon = (entry: PluginEntry): IconName =>
entry.parsedKind === 'npm' ? 'code-box' : 'folder';
export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
onItemSelect,
onAddClick,
}) => {
const { t } = useI18n();
const { entries, files, selectedId, setSelected, deleteEntry, deleteFile, loadPlugins } =
usePluginsStore(
useShallow((s) => ({
entries: s.entries,
files: s.files,
selectedId: s.selectedId,
setSelected: s.setSelected,
deleteEntry: s.deleteEntry,
deleteFile: s.deleteFile,
loadPlugins: s.loadPlugins,
})),
);
const registryInfo = usePluginsStore((s) => s.registryInfo);
const isLoadingRegistry = usePluginsStore((s) => s.isLoadingRegistry);
const loadRegistryInfo = usePluginsStore((s) => s.loadRegistryInfo);
const updateToLatest = usePluginsStore((s) => s.updateToLatest);
const [deleteTarget, setDeleteTarget] = React.useState<DeleteTarget>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [isAddOpen, setIsAddOpen] = React.useState(false);
React.useEffect(() => {
void loadPlugins();
}, [loadPlugins]);
const updateCounts = React.useMemo(() => {
const counts = { userEntries: 0, projectEntries: 0 };
for (const entry of entries) {
const info = registryInfo[entry.spec];
if (info?.kind === 'npm-ok' && info.hasUpdate) {
if (entry.scope === 'user') counts.userEntries++;
else if (entry.scope === 'project') counts.projectEntries++;
}
}
return counts;
}, [entries, registryInfo]);
const userEntries = React.useMemo(
() => entries.filter((e) => e.scope === 'user'),
[entries],
);
const projectEntries = React.useMemo(
() => entries.filter((e) => e.scope === 'project'),
[entries],
);
const userFiles = React.useMemo(
() => files.filter((f) => f.scope === 'user'),
[files],
);
const projectFiles = React.useMemo(
() => files.filter((f) => f.scope === 'project'),
[files],
);
const total = entries.length + files.length;
const isEmpty = total === 0;
const handleAdd = React.useCallback(() => {
if (onAddClick) {
onAddClick();
} else {
setIsAddOpen(true);
}
}, [onAddClick]);
const handleSelect = React.useCallback(
(id: string) => {
setSelected(id);
onItemSelect?.();
},
[onItemSelect, setSelected],
);
const handleUpdateToLatest = React.useCallback(
async (id: string) => {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const info = registryInfo[entry.spec];
if (!info || info.kind !== 'npm-ok' || !info.hasUpdate || !info.latestVersion) {
return;
}
const latest = info.latestVersion;
const result = await updateToLatest(id);
if (result.ok) {
toast.success(
t('settings.plugins.toast.updatedToLatest', { version: latest }),
);
} else {
toast.error(t('settings.plugins.toast.refreshFailed'));
}
},
[entries, registryInfo, t, updateToLatest],
);
const handleRefresh = React.useCallback(async () => {
toast.info(t('settings.plugins.toast.refreshing'));
try {
await loadRegistryInfo({ force: true });
} catch {
toast.error(t('settings.plugins.toast.refreshFailed'));
}
}, [loadRegistryInfo, t]);
const handleDelete = React.useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result =
deleteTarget.kind === 'entry'
? await deleteEntry(deleteTarget.id)
: await deleteFile(deleteTarget.id);
if (result.ok) {
toast.success(
result.message ||
t('settings.plugins.sidebar.toast.deleted', { name: deleteTarget.label }),
);
} else {
toast.error(t('settings.plugins.sidebar.toast.deleteFailed'));
}
setDeleteTarget(null);
setIsDeleting(false);
}, [deleteEntry, deleteFile, deleteTarget, t]);
const renderEntry = (entry: PluginEntry) => {
const info = registryInfo[entry.spec];
const canUpdate =
info?.kind === 'npm-ok' && info.hasUpdate && !!info.latestVersion;
const actions: Array<{
label: string;
icon?: IconName;
destructive?: boolean;
onClick: () => void;
}> = [];
if (canUpdate) {
actions.push({
label: t('settings.plugins.sidebar.actions.updateToLatest'),
icon: 'arrow-up-s',
onClick: () => void handleUpdateToLatest(entry.id),
});
}
actions.push({
label: t('settings.common.actions.delete'),
icon: 'delete-bin',
destructive: true,
onClick: () =>
setDeleteTarget({ kind: 'entry', id: entry.id, label: entry.spec }),
});
return (
<SettingsSidebarItem
key={entry.id}
title={
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">{entry.spec}</span>
<RegistryBadge spec={entry.spec} />
</span>
}
metadata={
entry.parsedKind === 'npm'
? t('settings.plugins.sidebar.kind.npm')
: t('settings.plugins.sidebar.kind.path')
}
selected={selectedId === entry.id}
onSelect={() => handleSelect(entry.id)}
icon={
<Icon
name={entryIcon(entry)}
className="h-4 w-4 flex-shrink-0 text-muted-foreground/70"
/>
}
actions={actions}
/>
);
};
const renderFile = (file: PluginFile) => (
<SettingsSidebarItem
key={file.id}
title={file.fileName}
metadata={t('settings.plugins.sidebar.kind.file')}
selected={selectedId === file.id}
onSelect={() => handleSelect(file.id)}
icon={
<Icon
name="file-text"
className="h-4 w-4 flex-shrink-0 text-muted-foreground/70"
/>
}
actions={[
{
label: t('settings.common.actions.delete'),
icon: 'delete-bin',
destructive: true,
onClick: () =>
setDeleteTarget({ kind: 'file', id: file.id, label: file.fileName }),
},
]}
/>
);
const renderGroup = (
label: string,
children: React.ReactNode,
updateCount = 0,
) => (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
{updateCount > 0 && (
<span className="ml-2 normal-case font-normal text-[var(--status-success)]">
{t(
updateCount === 1
? 'settings.plugins.sidebar.group.updatesAvailable_one'
: 'settings.plugins.sidebar.group.updatesAvailable_other',
{ count: updateCount },
)}
</span>
)}
</div>
{children}
</>
);
return (
<>
<SettingsSidebarLayout
variant="background"
header={
<div className="border-b px-3 pt-4 pb-3">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">
{t('settings.plugins.sidebar.title')}
</h2>
</div>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">
{t('settings.plugins.sidebar.total', { count: total })}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => void handleRefresh()}
disabled={isLoadingRegistry}
aria-label={t('settings.plugins.sidebar.actions.refresh')}
title={t('settings.plugins.sidebar.actions.refresh')}
>
<Icon
name="refresh"
className={
isLoadingRegistry ? 'size-4 animate-spin' : 'size-4'
}
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleAdd}
aria-label={t('settings.plugins.sidebar.actions.addTitle')}
title={t('settings.plugins.sidebar.actions.addTitle')}
>
<Icon name="add" className="size-4" />
</Button>
</div>
</div>
</div>
}
>
{isEmpty ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<Icon name="plug" className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">
{t('settings.plugins.sidebar.empty.title')}
</p>
<p className="typography-meta mt-1 opacity-75">
{t('settings.plugins.sidebar.empty.description')}
</p>
</div>
) : (
<>
{userEntries.length > 0 &&
renderGroup(
t('settings.plugins.sidebar.group.userEntries'),
userEntries.map(renderEntry),
updateCounts.userEntries,
)}
{userFiles.length > 0 &&
renderGroup(
t('settings.plugins.sidebar.group.userFiles'),
userFiles.map(renderFile),
)}
{projectEntries.length > 0 &&
renderGroup(
t('settings.plugins.sidebar.group.projectEntries'),
projectEntries.map(renderEntry),
updateCounts.projectEntries,
)}
{projectFiles.length > 0 &&
renderGroup(
t('settings.plugins.sidebar.group.projectFiles'),
projectFiles.map(renderFile),
)}
</>
)}
</SettingsSidebarLayout>
<AddPluginDialog open={isAddOpen} onOpenChange={setIsAddOpen} />
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !isDeleting) setDeleteTarget(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t('settings.plugins.sidebar.deleteDialog.title')}
</DialogTitle>
<DialogDescription>
{t('settings.plugins.sidebar.deleteDialog.description', {
name: deleteTarget?.label ?? '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>
{t('settings.common.actions.cancel')}
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting
? t('settings.plugins.sidebar.actions.deleting')
: t('settings.common.actions.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,108 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { usePluginsStore } from '@/stores/usePluginsStore';
interface RegistryBadgeProps {
spec: string;
}
export const RegistryBadge: React.FC<RegistryBadgeProps> = ({ spec }) => {
const info = usePluginsStore((s) => s.registryInfo[spec]);
const { t } = useI18n();
if (!info) return null;
const wrap = (
trigger: React.ReactNode,
tooltipText: string,
): React.ReactElement => (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center gap-0.5 text-xs">
{trigger}
</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
switch (info.kind) {
case 'npm-ok': {
if (!info.hasUpdate || !info.latestVersion) return null;
return wrap(
<span className="inline-flex items-center gap-0.5 text-[var(--status-success)]">
<Icon name="arrow-up-s" className="h-3 w-3" />
{info.latestVersion}
</span>,
t('settings.plugins.registry.badge.update.tooltip', {
current: info.currentVersion ?? '',
latest: info.latestVersion,
}),
);
}
case 'npm-missing-version':
return wrap(
<Icon
name="error-warning"
className="h-3 w-3 text-[var(--status-warning)]"
/>,
t('settings.plugins.registry.badge.missingVersion.tooltip', {
version: info.currentVersion,
name: info.name,
}),
);
case 'npm-missing-package':
return wrap(
<Icon
name="error-warning"
className="h-3 w-3 text-[var(--status-error)]"
/>,
t('settings.plugins.registry.badge.missingPackage.tooltip', {
name: info.name,
}),
);
case 'npm-malformed':
return wrap(
<Icon
name="error-warning"
className="h-3 w-3 text-[var(--status-error)]"
/>,
t('settings.plugins.registry.badge.malformed.tooltip'),
);
case 'npm-network':
return wrap(
<Icon name="cloud-off" className="h-3 w-3 text-muted-foreground" />,
t('settings.plugins.registry.badge.network.tooltip'),
);
case 'path-missing':
return wrap(
<Icon
name="error-warning"
className="h-3 w-3 text-[var(--status-error)]"
/>,
t('settings.plugins.registry.badge.pathMissing.tooltip', {
path: info.absolutePath,
}),
);
case 'path-unreadable':
return wrap(
<Icon
name="error-warning"
className="h-3 w-3 text-[var(--status-warning)]"
/>,
t('settings.plugins.registry.badge.pathUnreadable.tooltip', {
path: info.absolutePath,
}),
);
case 'path-ok':
return null;
default:
return null;
}
};
@@ -0,0 +1,127 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { usePluginsStore } from '@/stores/usePluginsStore';
interface RegistryBannerProps {
entryId: string;
spec: string;
}
export const RegistryBanner: React.FC<RegistryBannerProps> = ({ entryId, spec }) => {
const { t } = useI18n();
const info = usePluginsStore((s) => s.registryInfo[spec]);
const updateToLatest = usePluginsStore((s) => s.updateToLatest);
const [isUpdating, setIsUpdating] = React.useState(false);
if (!info) return null;
if (info.kind === 'npm-ok') {
if (!info.hasUpdate || !info.latestVersion) return null;
const latest = info.latestVersion;
const current = info.currentVersion ?? '';
const handleUpdate = async () => {
setIsUpdating(true);
try {
const result = await updateToLatest(entryId);
if (result.ok) {
toast.success(
t('settings.plugins.toast.updatedToLatest', { version: latest }),
);
} else {
toast.error(t('settings.plugins.toast.refreshFailed'));
}
} finally {
setIsUpdating(false);
}
};
return (
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
<Icon
name="arrow-up"
className="h-5 w-5 text-[var(--status-success)] shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className="typography-label text-[var(--status-success)]">
{t('settings.plugins.registry.banner.updateAvailable.title')}
</p>
<p className="typography-micro text-muted-foreground mt-0.5">
{t('settings.plugins.registry.banner.updateAvailable.description', {
current,
latest,
})}
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => void handleUpdate()}
disabled={isUpdating}
>
{t('settings.plugins.registry.banner.updateAvailable.action', { latest })}
</Button>
</div>
);
}
if (info.kind === 'path-ok') return null;
if (info.kind === 'npm-network') {
return (
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
<Icon
name="cloud-off"
className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className="typography-micro text-muted-foreground">
{t('settings.plugins.registry.badge.network.tooltip')}
</p>
</div>
</div>
);
}
const isWarning = info.kind === 'path-unreadable';
const colorVar = isWarning ? 'var(--status-warning)' : 'var(--status-error)';
const iconName = isWarning ? 'alert' : 'error-warning';
const description = (() => {
switch (info.kind) {
case 'npm-missing-version':
return t('settings.plugins.registry.banner.invalid.missingVersion');
case 'npm-missing-package':
return t('settings.plugins.registry.banner.invalid.missingPackage');
case 'npm-malformed':
return t('settings.plugins.registry.banner.invalid.malformed');
case 'path-missing':
return t('settings.plugins.registry.banner.invalid.pathMissing');
case 'path-unreadable':
return t('settings.plugins.registry.banner.invalid.pathUnreadable');
}
})();
return (
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
<Icon
name={iconName}
className="h-5 w-5 shrink-0 mt-0.5"
style={{ color: colorVar }}
/>
<div className="flex-1 min-w-0">
<p className="typography-label" style={{ color: colorVar }}>
{t('settings.plugins.registry.banner.invalid.title')}
</p>
<p className="typography-micro text-muted-foreground mt-0.5">
{description}
</p>
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export { PluginsSidebar } from './PluginsSidebar';
export { PluginsPage } from './PluginsPage';
export { AddPluginDialog } from './AddPluginDialog';
@@ -17,6 +17,8 @@ import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar'
import { CommandsPage } from '@/components/sections/commands/CommandsPage'; import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { McpSidebar } from '@/components/sections/mcp/McpSidebar'; import { McpSidebar } from '@/components/sections/mcp/McpSidebar';
import { McpPage } from '@/components/sections/mcp/McpPage'; import { McpPage } from '@/components/sections/mcp/McpPage';
import { PluginsSidebar, PluginsPage } from '@/components/sections/plugins';
import { usePluginsStore } from '@/stores/usePluginsStore';
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar'; import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage'; import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar'; import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
@@ -88,6 +90,7 @@ const pageOrder: SettingsPageSlug[] = [
'behavior', 'behavior',
'commands', 'commands',
'mcp', 'mcp',
'plugins',
'providers', 'providers',
'usage', 'usage',
'skills.installed', 'skills.installed',
@@ -174,6 +177,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
return 'slash-commands-2'; return 'slash-commands-2';
case 'mcp': case 'mcp':
return 'plug-2'; return 'plug-2';
case 'plugins':
return 'code-box';
case 'skills.installed': case 'skills.installed':
return 'book-open'; return 'book-open';
@@ -397,6 +402,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
void useMcpConfigStore.getState().loadMcpConfigs(); void useMcpConfigStore.getState().loadMcpConfigs();
return; return;
} }
if (settingsSlug === 'plugins') {
void usePluginsStore.getState().loadPlugins();
return;
}
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') { if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
void useSkillsStore.getState().loadSkills(); void useSkillsStore.getState().loadSkills();
void useSkillsCatalogStore.getState().loadCatalog(); void useSkillsCatalogStore.getState().loadCatalog();
@@ -454,6 +463,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return t('settings.page.commands.title'); return t('settings.page.commands.title');
case 'mcp': case 'mcp':
return t('settings.page.mcp.title'); return t('settings.page.mcp.title');
case 'plugins':
return t('settings.page.plugins.title');
case 'skills.installed': case 'skills.installed':
return t('settings.page.skills.title'); return t('settings.page.skills.title');
case 'skills.catalog': case 'skills.catalog':
@@ -507,6 +518,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <CommandsSidebar onItemSelect={opts.onItemSelect} />; return <CommandsSidebar onItemSelect={opts.onItemSelect} />;
case 'mcp': case 'mcp':
return <McpSidebar onItemSelect={opts.onItemSelect} />; return <McpSidebar onItemSelect={opts.onItemSelect} />;
case 'plugins':
return <PluginsSidebar onItemSelect={opts.onItemSelect} />;
case 'skills.installed': case 'skills.installed':
return <SkillsSidebar onItemSelect={opts.onItemSelect} />; return <SkillsSidebar onItemSelect={opts.onItemSelect} />;
case 'providers': case 'providers':
@@ -543,6 +556,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <CommandsPage />; return <CommandsPage />;
case 'mcp': case 'mcp':
return <McpPage />; return <McpPage />;
case 'plugins':
return <PluginsPage />;
case 'skills.installed': case 'skills.installed':
return <SkillsPage view="installed" />; return <SkillsPage view="installed" />;
case 'skills.catalog': case 'skills.catalog':
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.view.home.cards.skillsCatalog.description': 'Install skills from catalogs', 'settings.view.home.cards.skillsCatalog.description': 'Install skills from catalogs',
'settings.view.home.cards.mcp.title': 'MCP', 'settings.view.home.cards.mcp.title': 'MCP',
'settings.view.home.cards.mcp.description': 'Configure MCP servers + connections', 'settings.view.home.cards.mcp.description': 'Configure MCP servers + connections',
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
'settings.view.home.cards.usage.title': 'Usage', 'settings.view.home.cards.usage.title': 'Usage',
'settings.view.home.cards.usage.description': 'Quota + spend visibility', 'settings.view.home.cards.usage.description': 'Quota + spend visibility',
'settings.view.unavailable.title': 'Not available', 'settings.view.unavailable.title': 'Not available',
@@ -33,6 +35,7 @@ export const settingsDict = {
'settings.page.agents.title': 'Agents', 'settings.page.agents.title': 'Agents',
'settings.page.commands.title': 'Commands', 'settings.page.commands.title': 'Commands',
'settings.page.mcp.title': 'MCP', 'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins',
'settings.page.skills.title': 'Skills', 'settings.page.skills.title': 'Skills',
'settings.page.skillsCatalog.title': 'Skills Catalog', 'settings.page.skillsCatalog.title': 'Skills Catalog',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
@@ -799,6 +802,73 @@ export const settingsDict = {
'settings.mcp.sidebar.toast.deleteFailed': 'Failed to delete MCP server', 'settings.mcp.sidebar.toast.deleteFailed': 'Failed to delete MCP server',
'settings.mcp.sidebar.toast.serverDeleted': 'MCP server "{name}" deleted', 'settings.mcp.sidebar.toast.serverDeleted': 'MCP server "{name}" deleted',
'settings.mcp.sidebar.toast.refreshListIfStale': 'Refresh the MCP list if the UI looks stale.', 'settings.mcp.sidebar.toast.refreshListIfStale': 'Refresh the MCP list if the UI looks stale.',
'settings.plugins.sidebar.title': 'Plugins',
'settings.plugins.sidebar.total': 'Total {count}',
'settings.plugins.sidebar.actions.addTitle': 'Add plugin',
'settings.plugins.sidebar.actions.deleting': 'Deleting…',
'settings.plugins.sidebar.empty.title': 'No plugins configured',
'settings.plugins.sidebar.empty.description': 'Use the + button above to add one',
'settings.plugins.sidebar.group.userEntries': 'User config',
'settings.plugins.sidebar.group.userFiles': 'User plugin file',
'settings.plugins.sidebar.group.projectEntries': 'Project config',
'settings.plugins.sidebar.group.projectFiles': 'Project plugin file',
'settings.plugins.sidebar.kind.npm': 'npm package',
'settings.plugins.sidebar.kind.path': 'Local path',
'settings.plugins.sidebar.kind.file': 'Plugin file',
'settings.plugins.sidebar.deleteDialog.title': 'Delete plugin',
'settings.plugins.sidebar.deleteDialog.description': 'Are you sure you want to delete "{name}"?',
'settings.plugins.sidebar.toast.deleted': 'Plugin "{name}" deleted',
'settings.plugins.sidebar.toast.deleteFailed': 'Failed to delete plugin',
'settings.plugins.page.empty.select': 'Select a plugin to view or edit',
'settings.plugins.page.empty.add': 'Or click + to add a new plugin',
'settings.plugins.page.header.entry': 'Installed plugin',
'settings.plugins.page.header.file': 'Plugin file',
'settings.plugins.page.field.spec': 'Spec',
'settings.plugins.page.field.spec.placeholder': 'npm-package@version or /absolute/path',
'settings.plugins.page.field.options': 'Options (JSON)',
'settings.plugins.page.field.options.invalidJson': 'Invalid JSON',
'settings.plugins.page.field.fileName': 'Filename',
'settings.plugins.page.field.content': 'Content',
'settings.plugins.page.field.scope': 'Scope',
'settings.plugins.scope.user': 'User',
'settings.plugins.scope.project': 'Project',
'settings.plugins.page.action.save': 'Save',
'settings.plugins.page.action.discard': 'Discard',
'settings.plugins.dialog.add.title': 'Add plugin',
'settings.plugins.dialog.add.tab.npm': 'From npm',
'settings.plugins.dialog.add.tab.path': 'From local path',
'settings.plugins.dialog.add.tab.file': 'New file',
'settings.plugins.dialog.add.action.submit': 'Add',
'settings.plugins.dialog.add.action.cancel': 'Cancel',
'settings.plugins.toast.created': 'Plugin added',
'settings.plugins.toast.updated': 'Plugin updated',
'settings.plugins.toast.reloadFailed': 'opencode reload failed — restart required',
'settings.plugins.validation.fileName': 'Filename must be lowercase, end in .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'Spec is required',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Update available: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'Spec is malformed',
'settings.plugins.registry.badge.missingPackage.tooltip': 'Package {name} not found on npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'Version {version} of {name} not published',
'settings.plugins.registry.badge.pathMissing.tooltip': 'File not found: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'File not readable: {path}',
'settings.plugins.registry.badge.network.tooltip': 'Could not reach npm registry',
'settings.plugins.registry.banner.updateAvailable.title': 'Update available',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Update to {latest}',
'settings.plugins.registry.banner.invalid.title': 'Invalid plugin',
'settings.plugins.registry.banner.invalid.malformed': 'Spec syntax is malformed',
'settings.plugins.registry.banner.invalid.missingPackage': 'Package not found on npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Version not published',
'settings.plugins.registry.banner.invalid.pathMissing': 'File does not exist',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'File is not readable',
'settings.plugins.sidebar.actions.updateToLatest': 'Update to latest',
'settings.plugins.sidebar.actions.refresh': 'Check for updates',
'settings.plugins.sidebar.group.updatesAvailable_one': '{count} update available',
'settings.plugins.sidebar.group.updatesAvailable_other': '{count} updates available',
'settings.plugins.toast.updatedToLatest': 'Plugin updated to {version}',
'settings.plugins.toast.refreshing': 'Checking npm for updates…',
'settings.plugins.toast.refreshFailed': 'Could not check npm registry',
'settings.openchamber.keyboardShortcuts.title': 'Keyboard Shortcuts', 'settings.openchamber.keyboardShortcuts.title': 'Keyboard Shortcuts',
'settings.openchamber.keyboardShortcuts.actions.resetAll': 'Reset All', 'settings.openchamber.keyboardShortcuts.actions.resetAll': 'Reset All',
'settings.openchamber.keyboardShortcuts.actions.overwrite': 'Overwrite', 'settings.openchamber.keyboardShortcuts.actions.overwrite': 'Overwrite',
@@ -13,6 +13,8 @@ export const settingsDict = {
"settings.view.home.cards.skillsCatalog.description": "Instalar habilidades desde catálogos", "settings.view.home.cards.skillsCatalog.description": "Instalar habilidades desde catálogos",
"settings.view.home.cards.mcp.title": "MCP", "settings.view.home.cards.mcp.title": "MCP",
"settings.view.home.cards.mcp.description": "Configurar servidores + conexiones MCP", "settings.view.home.cards.mcp.description": "Configurar servidores + conexiones MCP",
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
"settings.view.home.cards.usage.title": "Uso", "settings.view.home.cards.usage.title": "Uso",
"settings.view.home.cards.usage.description": "Cuota + visibilidad del gasto", "settings.view.home.cards.usage.description": "Cuota + visibilidad del gasto",
"settings.view.unavailable.title": "No disponible", "settings.view.unavailable.title": "No disponible",
@@ -33,6 +35,7 @@ export const settingsDict = {
"settings.page.agents.title": "Agentes", "settings.page.agents.title": "Agentes",
"settings.page.commands.title": "Comandos", "settings.page.commands.title": "Comandos",
"settings.page.mcp.title": "MCP", "settings.page.mcp.title": "MCP",
'settings.page.plugins.title': 'Plugins',
"settings.page.skills.title": "Habilidades", "settings.page.skills.title": "Habilidades",
"settings.page.skillsCatalog.title": "Catálogo de habilidades", "settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
@@ -766,6 +769,73 @@ export const settingsDict = {
"settings.mcp.sidebar.toast.deleteFailed": "No se pudo eliminar el servidor MCP", "settings.mcp.sidebar.toast.deleteFailed": "No se pudo eliminar el servidor MCP",
"settings.mcp.sidebar.toast.serverDeleted": "Servidor MCP \"{name}\" eliminado", "settings.mcp.sidebar.toast.serverDeleted": "Servidor MCP \"{name}\" eliminado",
"settings.mcp.sidebar.toast.refreshListIfStale": "Actualiza la lista de MCP si la interfaz parece desactualizada.", "settings.mcp.sidebar.toast.refreshListIfStale": "Actualiza la lista de MCP si la interfaz parece desactualizada.",
'settings.plugins.sidebar.title': 'Plugins',
'settings.plugins.sidebar.total': 'Total: {count}',
'settings.plugins.sidebar.actions.addTitle': 'Añadir plugin',
'settings.plugins.sidebar.actions.deleting': 'Eliminando…',
'settings.plugins.sidebar.empty.title': 'No hay plugins configurados',
'settings.plugins.sidebar.empty.description': 'Usa el botón + de arriba para añadir uno',
'settings.plugins.sidebar.group.userEntries': 'Configuración de usuario',
'settings.plugins.sidebar.group.userFiles': 'Archivo de plugin de usuario',
'settings.plugins.sidebar.group.projectEntries': 'Configuración del proyecto',
'settings.plugins.sidebar.group.projectFiles': 'Archivo de plugin del proyecto',
'settings.plugins.sidebar.kind.npm': 'paquete npm',
'settings.plugins.sidebar.kind.path': 'Ruta local',
'settings.plugins.sidebar.kind.file': 'Archivo de plugin',
'settings.plugins.sidebar.deleteDialog.title': 'Eliminar plugin',
'settings.plugins.sidebar.deleteDialog.description': '¿Seguro que quieres eliminar "{name}"?',
'settings.plugins.sidebar.toast.deleted': 'Plugin "{name}" eliminado',
'settings.plugins.sidebar.toast.deleteFailed': 'No se pudo eliminar el plugin',
'settings.plugins.page.empty.select': 'Selecciona un plugin para verlo o editarlo',
'settings.plugins.page.empty.add': 'O haz clic en + para añadir un plugin nuevo',
'settings.plugins.page.header.entry': 'Plugin instalado',
'settings.plugins.page.header.file': 'Archivo de plugin',
'settings.plugins.page.field.spec': 'Especificación',
'settings.plugins.page.field.spec.placeholder': 'paquete-npm@versión o /ruta/absoluta',
'settings.plugins.page.field.options': 'Opciones (JSON)',
'settings.plugins.page.field.options.invalidJson': 'JSON no válido',
'settings.plugins.page.field.fileName': 'Nombre de archivo',
'settings.plugins.page.field.content': 'Contenido',
'settings.plugins.page.field.scope': 'Ámbito',
'settings.plugins.scope.user': 'Usuario',
'settings.plugins.scope.project': 'Proyecto',
'settings.plugins.page.action.save': 'Guardar',
'settings.plugins.page.action.discard': 'Descartar',
'settings.plugins.dialog.add.title': 'Añadir plugin',
'settings.plugins.dialog.add.tab.npm': 'Desde npm',
'settings.plugins.dialog.add.tab.path': 'Desde ruta local',
'settings.plugins.dialog.add.tab.file': 'Archivo nuevo',
'settings.plugins.dialog.add.action.submit': 'Añadir',
'settings.plugins.dialog.add.action.cancel': 'Cancelar',
'settings.plugins.toast.created': 'Plugin añadido',
'settings.plugins.toast.updated': 'Plugin actualizado',
'settings.plugins.toast.reloadFailed': 'Falló la recarga de opencode; se requiere reiniciar',
'settings.plugins.validation.fileName': 'El nombre de archivo debe estar en minúsculas y terminar en .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'La especificación es obligatoria',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Actualización disponible: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'La especificación no es válida',
'settings.plugins.registry.badge.missingPackage.tooltip': 'No se encontró el paquete {name} en npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'La versión {version} de {name} no está publicada',
'settings.plugins.registry.badge.pathMissing.tooltip': 'Archivo no encontrado: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'No se puede leer el archivo: {path}',
'settings.plugins.registry.badge.network.tooltip': 'No se pudo conectar con el registro de npm',
'settings.plugins.registry.banner.updateAvailable.title': 'Actualización disponible',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Actualizar a {latest}',
'settings.plugins.registry.banner.invalid.title': 'Plugin no válido',
'settings.plugins.registry.banner.invalid.malformed': 'La sintaxis de la especificación no es válida',
'settings.plugins.registry.banner.invalid.missingPackage': 'Paquete no encontrado en npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Versión no publicada',
'settings.plugins.registry.banner.invalid.pathMissing': 'El archivo no existe',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'No se puede leer el archivo',
'settings.plugins.sidebar.actions.updateToLatest': 'Actualizar a la última versión',
'settings.plugins.sidebar.actions.refresh': 'Buscar actualizaciones',
'settings.plugins.sidebar.group.updatesAvailable_one': '{count} actualización disponible',
'settings.plugins.sidebar.group.updatesAvailable_other': '{count} actualizaciones disponibles',
'settings.plugins.toast.updatedToLatest': 'Plugin actualizado a {version}',
'settings.plugins.toast.refreshing': 'Buscando actualizaciones en npm…',
'settings.plugins.toast.refreshFailed': 'No se pudo consultar el registro de npm',
"settings.openchamber.keyboardShortcuts.title": "Atajos de teclado", "settings.openchamber.keyboardShortcuts.title": "Atajos de teclado",
"settings.openchamber.keyboardShortcuts.actions.resetAll": "Restablecer todo", "settings.openchamber.keyboardShortcuts.actions.resetAll": "Restablecer todo",
"settings.openchamber.keyboardShortcuts.actions.overwrite": "Sobrescribir", "settings.openchamber.keyboardShortcuts.actions.overwrite": "Sobrescribir",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.view.home.cards.skillsCatalog.description': '카탈로그에서 스킬을 설치합니다', 'settings.view.home.cards.skillsCatalog.description': '카탈로그에서 스킬을 설치합니다',
'settings.view.home.cards.mcp.title': 'MCP', 'settings.view.home.cards.mcp.title': 'MCP',
'settings.view.home.cards.mcp.description': 'MCP 서버 연결을 설정하세요', 'settings.view.home.cards.mcp.description': 'MCP 서버 연결을 설정하세요',
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
'settings.view.home.cards.usage.title': '사용량', 'settings.view.home.cards.usage.title': '사용량',
'settings.view.home.cards.usage.description': '할당량 및 지출 현황', 'settings.view.home.cards.usage.description': '할당량 및 지출 현황',
'settings.view.unavailable.title': '사용할 수 없음', 'settings.view.unavailable.title': '사용할 수 없음',
@@ -33,6 +35,7 @@ export const settingsDict = {
'settings.page.agents.title': '에이전트', 'settings.page.agents.title': '에이전트',
'settings.page.commands.title': '명령어', 'settings.page.commands.title': '명령어',
'settings.page.mcp.title': 'MCP', 'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins',
'settings.page.skills.title': '스킬', 'settings.page.skills.title': '스킬',
'settings.page.skillsCatalog.title': '스킬 카탈로그', 'settings.page.skillsCatalog.title': '스킬 카탈로그',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
@@ -766,6 +769,73 @@ export const settingsDict = {
'settings.mcp.sidebar.toast.deleteFailed': 'MCP 서버를 삭제하지 못했습니다', 'settings.mcp.sidebar.toast.deleteFailed': 'MCP 서버를 삭제하지 못했습니다',
'settings.mcp.sidebar.toast.serverDeleted': 'MCP 서버 "{name}"이(가) 삭제되었습니다', 'settings.mcp.sidebar.toast.serverDeleted': 'MCP 서버 "{name}"이(가) 삭제되었습니다',
'settings.mcp.sidebar.toast.refreshListIfStale': 'UI가 오래된 것처럼 보이면 MCP 목록을 새로고침하세요.', 'settings.mcp.sidebar.toast.refreshListIfStale': 'UI가 오래된 것처럼 보이면 MCP 목록을 새로고침하세요.',
'settings.plugins.sidebar.title': '플러그인',
'settings.plugins.sidebar.total': '총 {count}개',
'settings.plugins.sidebar.actions.addTitle': '플러그인 추가',
'settings.plugins.sidebar.actions.deleting': '삭제 중…',
'settings.plugins.sidebar.empty.title': '설정된 플러그인이 없습니다',
'settings.plugins.sidebar.empty.description': '위의 + 버튼을 눌러 추가하세요',
'settings.plugins.sidebar.group.userEntries': '사용자 설정',
'settings.plugins.sidebar.group.userFiles': '사용자 플러그인 파일',
'settings.plugins.sidebar.group.projectEntries': '프로젝트 설정',
'settings.plugins.sidebar.group.projectFiles': '프로젝트 플러그인 파일',
'settings.plugins.sidebar.kind.npm': 'npm 패키지',
'settings.plugins.sidebar.kind.path': '로컬 경로',
'settings.plugins.sidebar.kind.file': '플러그인 파일',
'settings.plugins.sidebar.deleteDialog.title': '플러그인 삭제',
'settings.plugins.sidebar.deleteDialog.description': '"{name}"을(를) 삭제하시겠습니까?',
'settings.plugins.sidebar.toast.deleted': '플러그인 "{name}"이(가) 삭제되었습니다',
'settings.plugins.sidebar.toast.deleteFailed': '플러그인을 삭제하지 못했습니다',
'settings.plugins.page.empty.select': '보거나 편집할 플러그인을 선택하세요',
'settings.plugins.page.empty.add': '또는 +를 눌러 새 플러그인을 추가하세요',
'settings.plugins.page.header.entry': '설치된 플러그인',
'settings.plugins.page.header.file': '플러그인 파일',
'settings.plugins.page.field.spec': '명세',
'settings.plugins.page.field.spec.placeholder': 'npm-패키지@버전 또는 /절대/경로',
'settings.plugins.page.field.options': '옵션(JSON)',
'settings.plugins.page.field.options.invalidJson': '잘못된 JSON',
'settings.plugins.page.field.fileName': '파일 이름',
'settings.plugins.page.field.content': '내용',
'settings.plugins.page.field.scope': '범위',
'settings.plugins.scope.user': '사용자',
'settings.plugins.scope.project': '프로젝트',
'settings.plugins.page.action.save': '저장',
'settings.plugins.page.action.discard': '취소',
'settings.plugins.dialog.add.title': '플러그인 추가',
'settings.plugins.dialog.add.tab.npm': 'npm에서',
'settings.plugins.dialog.add.tab.path': '로컬 경로에서',
'settings.plugins.dialog.add.tab.file': '새 파일',
'settings.plugins.dialog.add.action.submit': '추가',
'settings.plugins.dialog.add.action.cancel': '취소',
'settings.plugins.toast.created': '플러그인이 추가되었습니다',
'settings.plugins.toast.updated': '플러그인이 업데이트되었습니다',
'settings.plugins.toast.reloadFailed': 'opencode 다시 로드 실패; 재시작이 필요합니다',
'settings.plugins.validation.fileName': '파일 이름은 소문자여야 하며 .js / .ts / .mjs / .cjs로 끝나야 합니다',
'settings.plugins.validation.specRequired': '명세는 필수입니다',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': '업데이트 가능: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': '명세 형식이 잘못되었습니다',
'settings.plugins.registry.badge.missingPackage.tooltip': 'npm에서 패키지 {name}을(를) 찾을 수 없습니다',
'settings.plugins.registry.badge.missingVersion.tooltip': '{name}의 {version} 버전이 게시되지 않았습니다',
'settings.plugins.registry.badge.pathMissing.tooltip': '파일을 찾을 수 없음: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': '파일을 읽을 수 없음: {path}',
'settings.plugins.registry.badge.network.tooltip': 'npm 레지스트리에 연결할 수 없습니다',
'settings.plugins.registry.banner.updateAvailable.title': '업데이트 가능',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': '{latest}(으)로 업데이트',
'settings.plugins.registry.banner.invalid.title': '잘못된 플러그인',
'settings.plugins.registry.banner.invalid.malformed': '명세 구문이 잘못되었습니다',
'settings.plugins.registry.banner.invalid.missingPackage': 'npm에서 패키지를 찾을 수 없습니다',
'settings.plugins.registry.banner.invalid.missingVersion': '게시되지 않은 버전입니다',
'settings.plugins.registry.banner.invalid.pathMissing': '파일이 존재하지 않습니다',
'settings.plugins.registry.banner.invalid.pathUnreadable': '파일을 읽을 수 없습니다',
'settings.plugins.sidebar.actions.updateToLatest': '최신 버전으로 업데이트',
'settings.plugins.sidebar.actions.refresh': '업데이트 확인',
'settings.plugins.sidebar.group.updatesAvailable_one': '업데이트 {count}개 가능',
'settings.plugins.sidebar.group.updatesAvailable_other': '업데이트 {count}개 가능',
'settings.plugins.toast.updatedToLatest': '플러그인이 {version}(으)로 업데이트되었습니다',
'settings.plugins.toast.refreshing': 'npm에서 업데이트 확인 중…',
'settings.plugins.toast.refreshFailed': 'npm 레지스트리를 확인하지 못했습니다',
'settings.openchamber.keyboardShortcuts.title': '키보드 단축키', 'settings.openchamber.keyboardShortcuts.title': '키보드 단축키',
'settings.openchamber.keyboardShortcuts.actions.resetAll': '모두 초기화', 'settings.openchamber.keyboardShortcuts.actions.resetAll': '모두 초기화',
'settings.openchamber.keyboardShortcuts.actions.overwrite': '덮어쓰기', 'settings.openchamber.keyboardShortcuts.actions.overwrite': '덮어쓰기',
@@ -479,6 +479,73 @@ export const settingsDict = {
'settings.mcp.sidebar.title': 'Serwery MCP', 'settings.mcp.sidebar.title': 'Serwery MCP',
'settings.mcp.sidebar.toast.deleteFailed': 'Nie udało się usunąć serwera MCP', 'settings.mcp.sidebar.toast.deleteFailed': 'Nie udało się usunąć serwera MCP',
'settings.mcp.sidebar.toast.refreshListIfStale': 'Odśwież listę MCP, jeśli interfejs wydaje się nieaktualny.', 'settings.mcp.sidebar.toast.refreshListIfStale': 'Odśwież listę MCP, jeśli interfejs wydaje się nieaktualny.',
'settings.plugins.sidebar.title': 'Pluginy',
'settings.plugins.sidebar.total': 'Łącznie: {count}',
'settings.plugins.sidebar.actions.addTitle': 'Dodaj plugin',
'settings.plugins.sidebar.actions.deleting': 'Usuwanie…',
'settings.plugins.sidebar.empty.title': 'Brak skonfigurowanych pluginów',
'settings.plugins.sidebar.empty.description': 'Użyj przycisku + powyżej, aby dodać plugin',
'settings.plugins.sidebar.group.userEntries': 'Konfiguracja użytkownika',
'settings.plugins.sidebar.group.userFiles': 'Plik pluginu użytkownika',
'settings.plugins.sidebar.group.projectEntries': 'Konfiguracja projektu',
'settings.plugins.sidebar.group.projectFiles': 'Plik pluginu projektu',
'settings.plugins.sidebar.kind.npm': 'pakiet npm',
'settings.plugins.sidebar.kind.path': 'Ścieżka lokalna',
'settings.plugins.sidebar.kind.file': 'Plik pluginu',
'settings.plugins.sidebar.deleteDialog.title': 'Usuń plugin',
'settings.plugins.sidebar.deleteDialog.description': 'Czy na pewno chcesz usunąć „{name}”?',
'settings.plugins.sidebar.toast.deleted': 'Plugin „{name}” usunięty',
'settings.plugins.sidebar.toast.deleteFailed': 'Nie udało się usunąć pluginu',
'settings.plugins.page.empty.select': 'Wybierz plugin, aby go wyświetlić lub edytować',
'settings.plugins.page.empty.add': 'Albo kliknij +, aby dodać nowy plugin',
'settings.plugins.page.header.entry': 'Zainstalowany plugin',
'settings.plugins.page.header.file': 'Plik pluginu',
'settings.plugins.page.field.spec': 'Specyfikacja',
'settings.plugins.page.field.spec.placeholder': 'pakiet-npm@wersja lub /ścieżka/bezwzględna',
'settings.plugins.page.field.options': 'Opcje (JSON)',
'settings.plugins.page.field.options.invalidJson': 'Nieprawidłowy JSON',
'settings.plugins.page.field.fileName': 'Nazwa pliku',
'settings.plugins.page.field.content': 'Zawartość',
'settings.plugins.page.field.scope': 'Zakres',
'settings.plugins.scope.user': 'Użytkownik',
'settings.plugins.scope.project': 'Projekt',
'settings.plugins.page.action.save': 'Zapisz',
'settings.plugins.page.action.discard': 'Odrzuć',
'settings.plugins.dialog.add.title': 'Dodaj plugin',
'settings.plugins.dialog.add.tab.npm': 'Z npm',
'settings.plugins.dialog.add.tab.path': 'Ze ścieżki lokalnej',
'settings.plugins.dialog.add.tab.file': 'Nowy plik',
'settings.plugins.dialog.add.action.submit': 'Dodaj',
'settings.plugins.dialog.add.action.cancel': 'Anuluj',
'settings.plugins.toast.created': 'Plugin dodany',
'settings.plugins.toast.updated': 'Plugin zaktualizowany',
'settings.plugins.toast.reloadFailed': 'Nie udało się przeładować opencode; wymagany restart',
'settings.plugins.validation.fileName': 'Nazwa pliku musi być małymi literami i kończyć się na .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'Specyfikacja jest wymagana',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Dostępna aktualizacja: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'Specyfikacja jest nieprawidłowa',
'settings.plugins.registry.badge.missingPackage.tooltip': 'Nie znaleziono pakietu {name} w npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'Wersja {version} pakietu {name} nie została opublikowana',
'settings.plugins.registry.badge.pathMissing.tooltip': 'Nie znaleziono pliku: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'Nie można odczytać pliku: {path}',
'settings.plugins.registry.badge.network.tooltip': 'Nie można połączyć się z rejestrem npm',
'settings.plugins.registry.banner.updateAvailable.title': 'Dostępna aktualizacja',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Aktualizuj do {latest}',
'settings.plugins.registry.banner.invalid.title': 'Nieprawidłowy plugin',
'settings.plugins.registry.banner.invalid.malformed': 'Składnia specyfikacji jest nieprawidłowa',
'settings.plugins.registry.banner.invalid.missingPackage': 'Nie znaleziono pakietu w npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Wersja nie została opublikowana',
'settings.plugins.registry.banner.invalid.pathMissing': 'Plik nie istnieje',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'Nie można odczytać pliku',
'settings.plugins.sidebar.actions.updateToLatest': 'Aktualizuj do najnowszej wersji',
'settings.plugins.sidebar.actions.refresh': 'Sprawdź aktualizacje',
'settings.plugins.sidebar.group.updatesAvailable_one': 'Dostępna {count} aktualizacja',
'settings.plugins.sidebar.group.updatesAvailable_other': 'Dostępnych aktualizacji: {count}',
'settings.plugins.toast.updatedToLatest': 'Plugin zaktualizowany do {version}',
'settings.plugins.toast.refreshing': 'Sprawdzanie aktualizacji w npm…',
'settings.plugins.toast.refreshFailed': 'Nie udało się sprawdzić rejestru npm',
'settings.mcp.sidebar.toast.serverDeleted': 'Serwer MCP "{name}" został usunięty', 'settings.mcp.sidebar.toast.serverDeleted': 'Serwer MCP "{name}" został usunięty',
'settings.mcp.sidebar.total': 'Suma: {count}', 'settings.mcp.sidebar.total': 'Suma: {count}',
'settings.notifications.page.delivery.browserPermissionHint': 'Twoja przeglądarka może poprosić o uprawnienia przy pierwszym uruchomieniu.', 'settings.notifications.page.delivery.browserPermissionHint': 'Twoja przeglądarka może poprosić o uprawnienia przy pierwszym uruchomieniu.',
@@ -957,6 +1024,7 @@ export const settingsDict = {
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
'settings.page.magicPrompts.title': 'Magiczne Prompty', 'settings.page.magicPrompts.title': 'Magiczne Prompty',
'settings.page.mcp.title': 'MCP', 'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins',
'settings.page.notifications.title': 'Powiadomienia', 'settings.page.notifications.title': 'Powiadomienia',
'settings.page.projects.title': 'Projekty', 'settings.page.projects.title': 'Projekty',
'settings.page.providers.title': 'Dostawcy', 'settings.page.providers.title': 'Dostawcy',
@@ -1543,6 +1611,8 @@ export const settingsDict = {
'settings.view.home.cards.agents.title': 'Agenci', 'settings.view.home.cards.agents.title': 'Agenci',
'settings.view.home.cards.mcp.description': 'Skonfiguruj serwery MCP + połączenia', 'settings.view.home.cards.mcp.description': 'Skonfiguruj serwery MCP + połączenia',
'settings.view.home.cards.mcp.title': 'MCP', 'settings.view.home.cards.mcp.title': 'MCP',
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
'settings.view.home.cards.providers.description': 'Połącz modele + dane uwierzytelniające', 'settings.view.home.cards.providers.description': 'Połącz modele + dane uwierzytelniające',
'settings.view.home.cards.providers.title': 'Dostawcy', 'settings.view.home.cards.providers.title': 'Dostawcy',
'settings.view.home.cards.skillsCatalog.description': 'Zainstaluj skille z katalogów', 'settings.view.home.cards.skillsCatalog.description': 'Zainstaluj skille z katalogów',
@@ -13,6 +13,8 @@ export const settingsDict = {
"settings.view.home.cards.skillsCatalog.description": "Instalar habilidades de catálogos", "settings.view.home.cards.skillsCatalog.description": "Instalar habilidades de catálogos",
"settings.view.home.cards.mcp.title": "MCP", "settings.view.home.cards.mcp.title": "MCP",
"settings.view.home.cards.mcp.description": "Configure servidores e conexões MCP", "settings.view.home.cards.mcp.description": "Configure servidores e conexões MCP",
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
"settings.view.home.cards.usage.title": "Uso", "settings.view.home.cards.usage.title": "Uso",
"settings.view.home.cards.usage.description": "Cota + visibilidade dos gastos", "settings.view.home.cards.usage.description": "Cota + visibilidade dos gastos",
"settings.view.unavailable.title": "Indisponível", "settings.view.unavailable.title": "Indisponível",
@@ -33,6 +35,7 @@ export const settingsDict = {
"settings.page.agents.title": "Agentes", "settings.page.agents.title": "Agentes",
"settings.page.commands.title": "Comandos", "settings.page.commands.title": "Comandos",
"settings.page.mcp.title": "MCP", "settings.page.mcp.title": "MCP",
'settings.page.plugins.title': 'Plugins',
"settings.page.skills.title": "Habilidades", "settings.page.skills.title": "Habilidades",
"settings.page.skillsCatalog.title": "Catálogo de habilidades", "settings.page.skillsCatalog.title": "Catálogo de habilidades",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
@@ -766,6 +769,73 @@ export const settingsDict = {
"settings.mcp.sidebar.toast.deleteFailed": "Não foi possível excluir o servidor MCP", "settings.mcp.sidebar.toast.deleteFailed": "Não foi possível excluir o servidor MCP",
"settings.mcp.sidebar.toast.serverDeleted": "Servidor MCP \"{name}\" excluído", "settings.mcp.sidebar.toast.serverDeleted": "Servidor MCP \"{name}\" excluído",
"settings.mcp.sidebar.toast.refreshListIfStale": "Atualize a lista de MCP se a interface parecer desatualizada.", "settings.mcp.sidebar.toast.refreshListIfStale": "Atualize a lista de MCP se a interface parecer desatualizada.",
'settings.plugins.sidebar.title': 'Plugins',
'settings.plugins.sidebar.total': 'Total: {count}',
'settings.plugins.sidebar.actions.addTitle': 'Adicionar plugin',
'settings.plugins.sidebar.actions.deleting': 'Excluindo…',
'settings.plugins.sidebar.empty.title': 'Nenhum plugin configurado',
'settings.plugins.sidebar.empty.description': 'Use o botão + acima para adicionar um',
'settings.plugins.sidebar.group.userEntries': 'Configuração do usuário',
'settings.plugins.sidebar.group.userFiles': 'Arquivo de plugin do usuário',
'settings.plugins.sidebar.group.projectEntries': 'Configuração do projeto',
'settings.plugins.sidebar.group.projectFiles': 'Arquivo de plugin do projeto',
'settings.plugins.sidebar.kind.npm': 'pacote npm',
'settings.plugins.sidebar.kind.path': 'Caminho local',
'settings.plugins.sidebar.kind.file': 'Arquivo de plugin',
'settings.plugins.sidebar.deleteDialog.title': 'Excluir plugin',
'settings.plugins.sidebar.deleteDialog.description': 'Tem certeza de que deseja excluir "{name}"?',
'settings.plugins.sidebar.toast.deleted': 'Plugin "{name}" excluído',
'settings.plugins.sidebar.toast.deleteFailed': 'Falha ao excluir plugin',
'settings.plugins.page.empty.select': 'Selecione um plugin para ver ou editar',
'settings.plugins.page.empty.add': 'Ou clique em + para adicionar um novo plugin',
'settings.plugins.page.header.entry': 'Plugin instalado',
'settings.plugins.page.header.file': 'Arquivo de plugin',
'settings.plugins.page.field.spec': 'Especificação',
'settings.plugins.page.field.spec.placeholder': 'pacote-npm@versão ou /caminho/absoluto',
'settings.plugins.page.field.options': 'Opções (JSON)',
'settings.plugins.page.field.options.invalidJson': 'JSON inválido',
'settings.plugins.page.field.fileName': 'Nome do arquivo',
'settings.plugins.page.field.content': 'Conteúdo',
'settings.plugins.page.field.scope': 'Escopo',
'settings.plugins.scope.user': 'Usuário',
'settings.plugins.scope.project': 'Projeto',
'settings.plugins.page.action.save': 'Salvar',
'settings.plugins.page.action.discard': 'Descartar',
'settings.plugins.dialog.add.title': 'Adicionar plugin',
'settings.plugins.dialog.add.tab.npm': 'Do npm',
'settings.plugins.dialog.add.tab.path': 'De caminho local',
'settings.plugins.dialog.add.tab.file': 'Novo arquivo',
'settings.plugins.dialog.add.action.submit': 'Adicionar',
'settings.plugins.dialog.add.action.cancel': 'Cancelar',
'settings.plugins.toast.created': 'Plugin adicionado',
'settings.plugins.toast.updated': 'Plugin atualizado',
'settings.plugins.toast.reloadFailed': 'Falha ao recarregar opencode; reinicialização necessária',
'settings.plugins.validation.fileName': 'O nome do arquivo deve estar em minúsculas e terminar em .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'A especificação é obrigatória',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Atualização disponível: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'A especificação está malformada',
'settings.plugins.registry.badge.missingPackage.tooltip': 'Pacote {name} não encontrado no npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'A versão {version} de {name} não foi publicada',
'settings.plugins.registry.badge.pathMissing.tooltip': 'Arquivo não encontrado: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'Arquivo não legível: {path}',
'settings.plugins.registry.badge.network.tooltip': 'Não foi possível acessar o registro npm',
'settings.plugins.registry.banner.updateAvailable.title': 'Atualização disponível',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Atualizar para {latest}',
'settings.plugins.registry.banner.invalid.title': 'Plugin inválido',
'settings.plugins.registry.banner.invalid.malformed': 'A sintaxe da especificação está malformada',
'settings.plugins.registry.banner.invalid.missingPackage': 'Pacote não encontrado no npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Versão não publicada',
'settings.plugins.registry.banner.invalid.pathMissing': 'O arquivo não existe',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'O arquivo não é legível',
'settings.plugins.sidebar.actions.updateToLatest': 'Atualizar para a versão mais recente',
'settings.plugins.sidebar.actions.refresh': 'Verificar atualizações',
'settings.plugins.sidebar.group.updatesAvailable_one': '{count} atualização disponível',
'settings.plugins.sidebar.group.updatesAvailable_other': '{count} atualizações disponíveis',
'settings.plugins.toast.updatedToLatest': 'Plugin atualizado para {version}',
'settings.plugins.toast.refreshing': 'Verificando atualizações no npm…',
'settings.plugins.toast.refreshFailed': 'Não foi possível consultar o registro npm',
"settings.openchamber.keyboardShortcuts.title": "Atalhos de teclado", "settings.openchamber.keyboardShortcuts.title": "Atalhos de teclado",
"settings.openchamber.keyboardShortcuts.actions.resetAll": "Redefinir tudo", "settings.openchamber.keyboardShortcuts.actions.resetAll": "Redefinir tudo",
"settings.openchamber.keyboardShortcuts.actions.overwrite": "Sobrescrever", "settings.openchamber.keyboardShortcuts.actions.overwrite": "Sobrescrever",
@@ -13,6 +13,8 @@ export const settingsDict = {
"settings.view.home.cards.skillsCatalog.description": "Встановлення навичок із каталогів", "settings.view.home.cards.skillsCatalog.description": "Встановлення навичок із каталогів",
"settings.view.home.cards.mcp.title": "MCP", "settings.view.home.cards.mcp.title": "MCP",
"settings.view.home.cards.mcp.description": "Налаштування MCP серверів і підключень", "settings.view.home.cards.mcp.description": "Налаштування MCP серверів і підключень",
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
"settings.view.home.cards.usage.title": "Використання", "settings.view.home.cards.usage.title": "Використання",
"settings.view.home.cards.usage.description": "Квота + видимість витрат", "settings.view.home.cards.usage.description": "Квота + видимість витрат",
"settings.view.unavailable.title": "Недоступно", "settings.view.unavailable.title": "Недоступно",
@@ -33,6 +35,7 @@ export const settingsDict = {
"settings.page.agents.title": "Агенти", "settings.page.agents.title": "Агенти",
"settings.page.commands.title": "Команди", "settings.page.commands.title": "Команди",
"settings.page.mcp.title": "MCP", "settings.page.mcp.title": "MCP",
'settings.page.plugins.title': 'Plugins',
"settings.page.skills.title": "Навички", "settings.page.skills.title": "Навички",
"settings.page.skillsCatalog.title": "Каталог навичок", "settings.page.skillsCatalog.title": "Каталог навичок",
"settings.page.git.title": "Git", "settings.page.git.title": "Git",
@@ -766,6 +769,73 @@ export const settingsDict = {
"settings.mcp.sidebar.toast.deleteFailed": "Не вдалося видалити сервер MCP", "settings.mcp.sidebar.toast.deleteFailed": "Не вдалося видалити сервер MCP",
"settings.mcp.sidebar.toast.serverDeleted": "MCP сервер \"{name}\" видалено", "settings.mcp.sidebar.toast.serverDeleted": "MCP сервер \"{name}\" видалено",
"settings.mcp.sidebar.toast.refreshListIfStale": "Оновити список MCP, якщо інтерфейс користувача виглядає застарілим.", "settings.mcp.sidebar.toast.refreshListIfStale": "Оновити список MCP, якщо інтерфейс користувача виглядає застарілим.",
'settings.plugins.sidebar.title': 'Плагіни',
'settings.plugins.sidebar.total': 'Усього: {count}',
'settings.plugins.sidebar.actions.addTitle': 'Додати плагін',
'settings.plugins.sidebar.actions.deleting': 'Видалення…',
'settings.plugins.sidebar.empty.title': 'Плагіни не налаштовано',
'settings.plugins.sidebar.empty.description': 'Натисніть кнопку + вище, щоб додати плагін',
'settings.plugins.sidebar.group.userEntries': 'Конфігурація користувача',
'settings.plugins.sidebar.group.userFiles': 'Файл плагіна користувача',
'settings.plugins.sidebar.group.projectEntries': 'Конфігурація проєкту',
'settings.plugins.sidebar.group.projectFiles': 'Файл плагіна проєкту',
'settings.plugins.sidebar.kind.npm': 'пакет npm',
'settings.plugins.sidebar.kind.path': 'Локальний шлях',
'settings.plugins.sidebar.kind.file': 'Файл плагіна',
'settings.plugins.sidebar.deleteDialog.title': 'Видалити плагін',
'settings.plugins.sidebar.deleteDialog.description': 'Ви впевнені, що хочете видалити "{name}"?',
'settings.plugins.sidebar.toast.deleted': 'Плагін "{name}" видалено',
'settings.plugins.sidebar.toast.deleteFailed': 'Не вдалося видалити плагін',
'settings.plugins.page.empty.select': 'Виберіть плагін, щоб переглянути або змінити його',
'settings.plugins.page.empty.add': 'Або натисніть +, щоб додати новий плагін',
'settings.plugins.page.header.entry': 'Встановлений плагін',
'settings.plugins.page.header.file': 'Файл плагіна',
'settings.plugins.page.field.spec': 'Специфікація',
'settings.plugins.page.field.spec.placeholder': 'npm-пакет@версія або /абсолютний/шлях',
'settings.plugins.page.field.options': 'Параметри (JSON)',
'settings.plugins.page.field.options.invalidJson': 'Некоректний JSON',
'settings.plugins.page.field.fileName': 'Назва файлу',
'settings.plugins.page.field.content': 'Вміст',
'settings.plugins.page.field.scope': 'Область',
'settings.plugins.scope.user': 'Користувач',
'settings.plugins.scope.project': 'Проєкт',
'settings.plugins.page.action.save': 'Зберегти',
'settings.plugins.page.action.discard': 'Скасувати',
'settings.plugins.dialog.add.title': 'Додати плагін',
'settings.plugins.dialog.add.tab.npm': 'З npm',
'settings.plugins.dialog.add.tab.path': 'З локального шляху',
'settings.plugins.dialog.add.tab.file': 'Новий файл',
'settings.plugins.dialog.add.action.submit': 'Додати',
'settings.plugins.dialog.add.action.cancel': 'Скасувати',
'settings.plugins.toast.created': 'Плагін додано',
'settings.plugins.toast.updated': 'Плагін оновлено',
'settings.plugins.toast.reloadFailed': 'Не вдалося перезавантажити opencode; потрібен перезапуск',
'settings.plugins.validation.fileName': 'Назва файлу має бути в нижньому регістрі й закінчуватися на .js / .ts / .mjs / .cjs',
'settings.plugins.validation.specRequired': 'Специфікація обов’язкова',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': 'Доступне оновлення: {current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': 'Специфікація некоректна',
'settings.plugins.registry.badge.missingPackage.tooltip': 'Пакет {name} не знайдено в npm',
'settings.plugins.registry.badge.missingVersion.tooltip': 'Версію {version} пакета {name} не опубліковано',
'settings.plugins.registry.badge.pathMissing.tooltip': 'Файл не знайдено: {path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': 'Файл неможливо прочитати: {path}',
'settings.plugins.registry.badge.network.tooltip': 'Не вдалося підключитися до реєстру npm',
'settings.plugins.registry.banner.updateAvailable.title': 'Доступне оновлення',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': 'Оновити до {latest}',
'settings.plugins.registry.banner.invalid.title': 'Некоректний плагін',
'settings.plugins.registry.banner.invalid.malformed': 'Синтаксис специфікації некоректний',
'settings.plugins.registry.banner.invalid.missingPackage': 'Пакет не знайдено в npm',
'settings.plugins.registry.banner.invalid.missingVersion': 'Версію не опубліковано',
'settings.plugins.registry.banner.invalid.pathMissing': 'Файл не існує',
'settings.plugins.registry.banner.invalid.pathUnreadable': 'Файл неможливо прочитати',
'settings.plugins.sidebar.actions.updateToLatest': 'Оновити до останньої версії',
'settings.plugins.sidebar.actions.refresh': 'Перевірити оновлення',
'settings.plugins.sidebar.group.updatesAvailable_one': 'Доступне {count} оновлення',
'settings.plugins.sidebar.group.updatesAvailable_other': 'Доступно оновлень: {count}',
'settings.plugins.toast.updatedToLatest': 'Плагін оновлено до {version}',
'settings.plugins.toast.refreshing': 'Перевірка оновлень у npm…',
'settings.plugins.toast.refreshFailed': 'Не вдалося перевірити реєстр npm',
"settings.openchamber.keyboardShortcuts.title": "Комбінації клавіш", "settings.openchamber.keyboardShortcuts.title": "Комбінації клавіш",
"settings.openchamber.keyboardShortcuts.actions.resetAll": "Скинути все", "settings.openchamber.keyboardShortcuts.actions.resetAll": "Скинути все",
"settings.openchamber.keyboardShortcuts.actions.overwrite": "Перезаписати", "settings.openchamber.keyboardShortcuts.actions.overwrite": "Перезаписати",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.view.home.cards.skillsCatalog.description': '从目录安装技能', 'settings.view.home.cards.skillsCatalog.description': '从目录安装技能',
'settings.view.home.cards.mcp.title': 'MCP', 'settings.view.home.cards.mcp.title': 'MCP',
'settings.view.home.cards.mcp.description': '配置 MCP 服务器和连接', 'settings.view.home.cards.mcp.description': '配置 MCP 服务器和连接',
'settings.view.home.cards.plugins.title': 'Plugins',
'settings.view.home.cards.plugins.description': 'Manage opencode plugins',
'settings.view.home.cards.usage.title': '用量', 'settings.view.home.cards.usage.title': '用量',
'settings.view.home.cards.usage.description': '配额和花费可见性', 'settings.view.home.cards.usage.description': '配额和花费可见性',
'settings.view.unavailable.title': '不可用', 'settings.view.unavailable.title': '不可用',
@@ -33,6 +35,7 @@ export const settingsDict = {
'settings.page.agents.title': '智能体', 'settings.page.agents.title': '智能体',
'settings.page.commands.title': '命令', 'settings.page.commands.title': '命令',
'settings.page.mcp.title': 'MCP', 'settings.page.mcp.title': 'MCP',
'settings.page.plugins.title': 'Plugins',
'settings.page.skills.title': '技能', 'settings.page.skills.title': '技能',
'settings.page.skillsCatalog.title': '技能目录', 'settings.page.skillsCatalog.title': '技能目录',
'settings.page.git.title': 'Git', 'settings.page.git.title': 'Git',
@@ -766,6 +769,73 @@ export const settingsDict = {
'settings.mcp.sidebar.toast.deleteFailed': '删除 MCP 服务器失败', 'settings.mcp.sidebar.toast.deleteFailed': '删除 MCP 服务器失败',
'settings.mcp.sidebar.toast.serverDeleted': 'MCP 服务器“{name}”已删除', 'settings.mcp.sidebar.toast.serverDeleted': 'MCP 服务器“{name}”已删除',
'settings.mcp.sidebar.toast.refreshListIfStale': '如果界面看起来未更新,请刷新 MCP 列表。', 'settings.mcp.sidebar.toast.refreshListIfStale': '如果界面看起来未更新,请刷新 MCP 列表。',
'settings.plugins.sidebar.title': '插件',
'settings.plugins.sidebar.total': '总计 {count}',
'settings.plugins.sidebar.actions.addTitle': '添加插件',
'settings.plugins.sidebar.actions.deleting': '正在删除…',
'settings.plugins.sidebar.empty.title': '尚未配置插件',
'settings.plugins.sidebar.empty.description': '使用上方的 + 按钮添加一个插件',
'settings.plugins.sidebar.group.userEntries': '用户配置',
'settings.plugins.sidebar.group.userFiles': '用户插件文件',
'settings.plugins.sidebar.group.projectEntries': '项目配置',
'settings.plugins.sidebar.group.projectFiles': '项目插件文件',
'settings.plugins.sidebar.kind.npm': 'npm 包',
'settings.plugins.sidebar.kind.path': '本地路径',
'settings.plugins.sidebar.kind.file': '插件文件',
'settings.plugins.sidebar.deleteDialog.title': '删除插件',
'settings.plugins.sidebar.deleteDialog.description': '确定要删除“{name}”吗?',
'settings.plugins.sidebar.toast.deleted': '插件“{name}”已删除',
'settings.plugins.sidebar.toast.deleteFailed': '删除插件失败',
'settings.plugins.page.empty.select': '选择一个插件以查看或编辑',
'settings.plugins.page.empty.add': '或点击 + 添加新插件',
'settings.plugins.page.header.entry': '已安装插件',
'settings.plugins.page.header.file': '插件文件',
'settings.plugins.page.field.spec': '规格',
'settings.plugins.page.field.spec.placeholder': 'npm-package@version 或 /absolute/path',
'settings.plugins.page.field.options': '选项(JSON',
'settings.plugins.page.field.options.invalidJson': 'JSON 无效',
'settings.plugins.page.field.fileName': '文件名',
'settings.plugins.page.field.content': '内容',
'settings.plugins.page.field.scope': '范围',
'settings.plugins.scope.user': '用户',
'settings.plugins.scope.project': '项目',
'settings.plugins.page.action.save': '保存',
'settings.plugins.page.action.discard': '放弃',
'settings.plugins.dialog.add.title': '添加插件',
'settings.plugins.dialog.add.tab.npm': '从 npm',
'settings.plugins.dialog.add.tab.path': '从本地路径',
'settings.plugins.dialog.add.tab.file': '新建文件',
'settings.plugins.dialog.add.action.submit': '添加',
'settings.plugins.dialog.add.action.cancel': '取消',
'settings.plugins.toast.created': '插件已添加',
'settings.plugins.toast.updated': '插件已更新',
'settings.plugins.toast.reloadFailed': 'opencode 重新加载失败;需要重启',
'settings.plugins.validation.fileName': '文件名必须为小写,并以 .js / .ts / .mjs / .cjs 结尾',
'settings.plugins.validation.specRequired': '规格为必填项',
'settings.plugins.registry.badge.update.label': '↑ {version}',
'settings.plugins.registry.badge.update.tooltip': '有可用更新:{current} → {latest}',
'settings.plugins.registry.badge.malformed.tooltip': '规格格式不正确',
'settings.plugins.registry.badge.missingPackage.tooltip': 'npm 上找不到包 {name}',
'settings.plugins.registry.badge.missingVersion.tooltip': '{name} 的版本 {version} 尚未发布',
'settings.plugins.registry.badge.pathMissing.tooltip': '找不到文件:{path}',
'settings.plugins.registry.badge.pathUnreadable.tooltip': '无法读取文件:{path}',
'settings.plugins.registry.badge.network.tooltip': '无法连接到 npm 注册表',
'settings.plugins.registry.banner.updateAvailable.title': '有可用更新',
'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}',
'settings.plugins.registry.banner.updateAvailable.action': '更新到 {latest}',
'settings.plugins.registry.banner.invalid.title': '插件无效',
'settings.plugins.registry.banner.invalid.malformed': '规格语法格式不正确',
'settings.plugins.registry.banner.invalid.missingPackage': 'npm 上找不到该包',
'settings.plugins.registry.banner.invalid.missingVersion': '版本尚未发布',
'settings.plugins.registry.banner.invalid.pathMissing': '文件不存在',
'settings.plugins.registry.banner.invalid.pathUnreadable': '文件不可读',
'settings.plugins.sidebar.actions.updateToLatest': '更新到最新版',
'settings.plugins.sidebar.actions.refresh': '检查更新',
'settings.plugins.sidebar.group.updatesAvailable_one': '有 {count} 个更新',
'settings.plugins.sidebar.group.updatesAvailable_other': '有 {count} 个更新',
'settings.plugins.toast.updatedToLatest': '插件已更新到 {version}',
'settings.plugins.toast.refreshing': '正在检查 npm 更新…',
'settings.plugins.toast.refreshFailed': '无法检查 npm 注册表',
'settings.openchamber.keyboardShortcuts.title': '键盘快捷键', 'settings.openchamber.keyboardShortcuts.title': '键盘快捷键',
'settings.openchamber.keyboardShortcuts.actions.resetAll': '全部重置', 'settings.openchamber.keyboardShortcuts.actions.resetAll': '全部重置',
'settings.openchamber.keyboardShortcuts.actions.overwrite': '覆盖', 'settings.openchamber.keyboardShortcuts.actions.overwrite': '覆盖',
+8
View File
@@ -10,6 +10,7 @@ export type SettingsPageSlug =
| 'behavior' | 'behavior'
| 'commands' | 'commands'
| 'mcp' | 'mcp'
| 'plugins'
| 'skills.installed' | 'skills.installed'
| 'skills.catalog' | 'skills.catalog'
| 'git' | 'git'
@@ -126,6 +127,13 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
kind: 'split', kind: 'split',
keywords: ['mcp', 'model context protocol', 'servers', 'tools', 'remote', 'stdio'], keywords: ['mcp', 'model context protocol', 'servers', 'tools', 'remote', 'stdio'],
}, },
{
slug: 'plugins',
title: 'Plugins',
group: 'opencode',
kind: 'split',
keywords: ['plugin', 'plugins', 'extensions', 'addons', 'npm', 'opencode-wakatime'],
},
{ {
slug: 'skills.installed', slug: 'skills.installed',
title: 'Skills', title: 'Skills',
@@ -0,0 +1,381 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { PluginEntry, PluginFile, RegistryResult } from './usePluginsStore';
const activeProjectPath = '/workspace/project';
const refreshAfterOpenCodeRestartMock = mock(async () => undefined);
const startConfigUpdateMock = mock(() => undefined);
const finishConfigUpdateMock = mock(() => undefined);
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => ({ path: activeProjectPath }),
}),
},
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => '/fallback/project',
},
}));
mock.module('@/stores/useAgentsStore', () => ({
refreshAfterOpenCodeRestart: refreshAfterOpenCodeRestartMock,
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: startConfigUpdateMock,
finishConfigUpdate: finishConfigUpdateMock,
}));
const { usePluginsStore } = await import('./usePluginsStore');
const entry: PluginEntry = {
id: 'config:user:plugin-a',
spec: 'plugin-a',
scope: 'user',
kind: 'config',
parsedKind: 'npm',
};
const file: PluginFile = {
id: 'file:user:plugin.ts',
fileName: 'plugin.ts',
scope: 'user',
kind: 'file',
};
const pluginListPayload = {
entries: [entry],
files: [file],
};
const okMutationPayload = {
success: true,
requiresReload: false,
message: 'ok',
reloadDelayMs: 800,
reloadFailed: false,
};
const registryOk: RegistryResult = {
kind: 'npm-ok',
spec: 'plugin-a',
name: 'plugin-a',
currentVersion: null,
latestVersion: '1.0.0',
versions: ['1.0.0'],
hasUpdate: false,
};
const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
});
type FetchCall = {
input: RequestInfo | URL;
init?: RequestInit;
};
const fetchCalls: FetchCall[] = [];
let queuedResponses: Response[] = [];
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
fetchCalls.push({ input, init });
return queuedResponses.shift() ?? jsonResponse(pluginListPayload);
});
const queueFetchResponses = (responses: Response[]) => {
queuedResponses = [...responses];
};
const resetStore = () => {
usePluginsStore.setState({
entries: [],
files: [],
selectedId: null,
isLoading: false,
registryInfo: {},
isLoadingRegistry: false,
draft: null,
});
};
const registryCalls = (): FetchCall[] => fetchCalls.filter((call) => String(call.input).includes('/api/config/plugins/registry'));
const requestBody = (callIndex: number): unknown => {
const init = fetchCalls[callIndex]?.init;
return init?.body ? JSON.parse(String(init.body)) : undefined;
};
describe('usePluginsStore', () => {
beforeEach(() => {
resetStore();
fetchCalls.length = 0;
queuedResponses = [];
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
test('loadPlugins calls config plugins endpoint once and populates entries/files', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
expect(result).toBe(true);
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
expect(usePluginsStore.getState().isLoading).toBe(false);
});
test('second loadPlugins within TTL reuses cached store data', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins();
expect(fetchCalls).toHaveLength(2);
});
test('force loadPlugins bypasses TTL cache', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins({ force: true });
expect(fetchCalls).toHaveLength(3);
});
test('createEntry posts spec and scope in request body', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createEntry({ spec: 'a', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ spec: 'a', scope: 'user' });
});
test('createEntry includes options when provided', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().createEntry({ spec: 'a', options: { enabled: true }, scope: 'project' });
expect(requestBody(0)).toEqual({ spec: 'a', options: { enabled: true }, scope: 'project' });
});
test('updateEntry patches entry id path', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().updateEntry('entry-id', { spec: 'b' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/entry-id?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('PATCH');
expect(requestBody(0)).toEqual({ spec: 'b' });
});
test('deleteEntry deletes entry id, invalidates cache, reloads, and clears selected id', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [file] })]);
await usePluginsStore.getState().loadPlugins();
usePluginsStore.getState().setSelected(entry.id);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(fetchCalls[2]?.input).toBe(`/api/config/plugins/entry/${encodeURIComponent(entry.id)}?directory=%2Fworkspace%2Fproject`);
expect(fetchCalls[2]?.init?.method).toBe('DELETE');
expect(fetchCalls[3]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([]);
expect(usePluginsStore.getState().selectedId).toBeNull();
});
test('createFile posts file name, content, and scope', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createFile({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/file?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
});
test('failed mutation returns ok false and leaves plugins unchanged', async () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
queueFetchResponses([jsonResponse({ error: 'boom' }, { status: 500 })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'bad', scope: 'user' });
expect(result).toEqual({ ok: false });
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
});
test('getById returns entries and files by id', () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
expect(usePluginsStore.getState().getById(entry.id)).toEqual(entry);
expect(usePluginsStore.getState().getById(file.id)).toEqual(file);
});
test('readFile fetches plugin file content', async () => {
queueFetchResponses([jsonResponse({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' })]);
const result = await usePluginsStore.getState().readFile(file.id);
expect(fetchCalls[0]?.input).toBe(`/api/config/plugins/file/${encodeURIComponent(file.id)}?directory=%2Fworkspace%2Fproject`);
expect(result).toEqual({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' });
});
test('loadRegistryInfo derives specs from entries and stores registry results', async () => {
usePluginsStore.setState({ entries: [{ ...entry, spec: 'foo@1' }] });
queueFetchResponses([
jsonResponse({
results: [{ kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', hasUpdate: true, versions: ['1', '2'] }],
}),
]);
await usePluginsStore.getState().loadRegistryInfo();
expect(String(fetchCalls[0]?.input)).toContain('specs=foo%401');
expect(usePluginsStore.getState().registryInfo['foo@1']?.kind).toBe('npm-ok');
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadRegistryInfo force adds refresh flag', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['foo@1'], force: true });
expect(String(fetchCalls[0]?.input)).toContain('refresh=true');
});
test('loadRegistryInfo accepts explicit comma-joined specs', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['x@1', 'y@2'] });
expect(String(fetchCalls[0]?.input)).toContain('specs=x%401,y%402');
});
test('loadRegistryInfo skips empty specs and clears loading flag', async () => {
usePluginsStore.setState({ isLoadingRegistry: true });
await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
expect(fetchCalls).toHaveLength(0);
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadPlugins success triggers registry load without blocking result', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
expect(result).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(registryCalls()).toHaveLength(1);
});
test('createEntry success refreshes registry for new spec with force', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'new-plugin@1', scope: 'user' });
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=new-plugin%401');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes changed spec with force', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { spec: 'plugin-b@2' });
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-b%402');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes existing spec when spec unchanged', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { options: { enabled: true } });
expect(result.ok).toBe(true);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-a');
});
test('deleteEntry success removes deleted spec from registryInfo', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [] })]);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(usePluginsStore.getState().registryInfo[entry.spec]).toBe(undefined);
});
test('updateToLatest updates npm-ok entry to latest version', async () => {
usePluginsStore.setState({
entries: [{ ...entry, id: 'X', spec: 'foo@1' }],
registryInfo: {
'foo@1': { kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', versions: ['1', '2'], hasUpdate: true },
},
});
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateToLatest('X');
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/X?directory=%2Fworkspace%2Fproject');
expect(requestBody(0)).toEqual({ spec: 'foo@2' });
});
test('updateToLatest returns ok false when hasUpdate is false', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('updateToLatest returns ok false for missing package registry result', async () => {
usePluginsStore.setState({
entries: [entry],
registryInfo: { [entry.spec]: { kind: 'npm-missing-package', spec: entry.spec, name: entry.spec, error: 'missing' } },
});
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('loadRegistryInfo chunks long spec lists into multiple registry requests', async () => {
const entries = Array.from({ length: 50 }, (_, index): PluginEntry => ({
...entry,
id: `config:user:plugin-${index}`,
spec: `plugin-${index}-${'x'.repeat(20)}@1.0.0`,
}));
usePluginsStore.setState({ entries });
queueFetchResponses([jsonResponse({ results: [] }), jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo();
expect(registryCalls()).toHaveLength(2);
});
});
+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;
}
@@ -521,6 +521,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/config/snippets') || req.path.startsWith('/api/config/snippets') ||
req.path.startsWith('/api/config/settings') || req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/config/skills') || req.path.startsWith('/api/config/skills') ||
req.path.startsWith('/api/config/plugins') ||
req.path.startsWith('/api/projects') || req.path.startsWith('/api/projects') ||
req.path.startsWith('/api/fs') || req.path.startsWith('/api/fs') ||
req.path.startsWith('/api/git') || req.path.startsWith('/api/git') ||
@@ -9,6 +9,9 @@ import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js'; import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js'; import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
import { registerSkillRoutes } from './skill-routes.js'; import { registerSkillRoutes } from './skill-routes.js';
import { registerPluginRoutes } from './plugin-routes.js';
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
import { registerOpenCodeRoutes } from './routes.js'; import { registerOpenCodeRoutes } from './routes.js';
export const createFeatureRoutesRuntime = (dependencies) => { export const createFeatureRoutesRuntime = (dependencies) => {
@@ -129,6 +132,17 @@ export const createFeatureRoutesRuntime = (dependencies) => {
updateSnippet, updateSnippet,
deleteSnippet, deleteSnippet,
expandSnippets, expandSnippets,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
} = await import('./index.js'); } = await import('./index.js');
registerConfigEntityRoutes(app, { registerConfigEntityRoutes(app, {
@@ -158,6 +172,27 @@ export const createFeatureRoutesRuntime = (dependencies) => {
expandSnippets, expandSnippets,
}); });
registerPluginRoutes(app, {
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
getNpmInfo,
parseNpmSpec,
parsePathSpec,
isExactSemver,
});
const { const {
getSkillSources, getSkillSources,
discoverSkills, discoverSkills,
+19
View File
@@ -66,6 +66,22 @@ export {
deleteMcpConfig, deleteMcpConfig,
} from './mcp.js'; } from './mcp.js';
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
} from './plugins.js';
export { export {
listSnippets, listSnippets,
getSnippet, getSnippet,
@@ -74,3 +90,6 @@ export {
deleteSnippet, deleteSnippet,
expandSnippets, expandSnippets,
} from './snippets.js'; } from './snippets.js';
export { getNpmInfo, lookupNpmPackage, clearCache as clearNpmCache } from './npm-registry.js';
export { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
@@ -0,0 +1,157 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
export const NPM_CACHE_TTL_MS = 3_600_000;
export const NPM_FETCH_TIMEOUT_MS = 5_000;
export const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
/**
* @typedef {Object} NpmPackagePayload
* @property {true} ok
* @property {string|null} latest
* @property {string[]} versions
* @property {Record<string, string>} distTags
*
* @typedef {Object} NpmLookupError
* @property {false} ok
* @property {number|'network'} status
* @property {string} error
*
* @typedef {NpmPackagePayload | NpmLookupError} NpmLookupResult
* @typedef {{ forceRefresh?: boolean }} NpmInfoOptions
* @typedef {{ fetchedAt: number, payload: NpmLookupResult }} CacheEntry
*/
/** @type {Map<string, CacheEntry>} */
const _cache = new Map();
/** @type {Map<string, Promise<NpmLookupResult>>} */
const _inFlight = new Map();
/** @type {string | null} */
let _userAgent = null;
function _getPackageJsonPath() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(__dirname, '..', '..', '..', '..', '..', 'package.json');
}
function _getUserAgent() {
if (_userAgent) return _userAgent;
try {
const pkg = JSON.parse(fs.readFileSync(_getPackageJsonPath(), 'utf8'));
_userAgent = `openchamber-server/${typeof pkg.version === 'string' ? pkg.version : '0.0.0'}`;
} catch {
_userAgent = 'openchamber-server/dev';
}
return _userAgent;
}
function encodeName(name) {
return encodeURIComponent(name).replace(/^%40/, '@');
}
function parseDistTags(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry) => typeof entry[1] === 'string'),
);
}
function parseVersions(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [];
}
return Object.keys(value);
}
function cacheResult(name, payload) {
if (payload.ok || payload.status === 404) {
_cache.set(name, { fetchedAt: Date.now(), payload });
}
}
/**
* Fetch package metadata directly from the npm registry.
*
* @param {string} name npm package name
* @returns {Promise<NpmLookupResult>}
*/
export async function lookupNpmPackage(name) {
try {
const response = await fetch(`${NPM_REGISTRY_BASE}/${encodeName(name)}`, {
headers: {
'User-Agent': _getUserAgent(),
Accept: 'application/json',
},
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
});
if (response.ok) {
const data = await response.json();
const distTags = parseDistTags(data?.['dist-tags']);
return {
ok: true,
latest: distTags.latest ?? null,
versions: parseVersions(data?.versions),
distTags,
};
}
if (response.status === 404) {
return { ok: false, status: 404, error: 'Package not found' };
}
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
} catch (error) {
return { ok: false, status: 'network', error: String(error?.message ?? error) };
}
}
/**
* Fetch package metadata with TTL cache and in-flight request deduplication.
*
* @param {string} name npm package name
* @param {NpmInfoOptions} [options]
* @returns {Promise<NpmLookupResult>}
*/
export async function getNpmInfo(name, options = {}) {
const { forceRefresh = false } = options;
const cached = _cache.get(name);
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
return cached.payload;
}
const existing = _inFlight.get(name);
if (existing && !forceRefresh) {
return existing;
}
const lookup = (async () => {
const result = await lookupNpmPackage(name);
cacheResult(name, result);
return result;
})();
_inFlight.set(name, lookup);
try {
return await lookup;
} finally {
if (_inFlight.get(name) === lookup) {
_inFlight.delete(name);
}
}
}
export function clearCache() {
_cache.clear();
_inFlight.clear();
}
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import * as npm from './npm-registry.js';
const originalFetch = globalThis.fetch;
const originalDateNow = Date.now;
let fetchMock;
function jsonResponse(body, status = 200) {
return Promise.resolve(new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
}));
}
describe('npm registry client', () => {
beforeEach(() => {
npm.clearCache();
Date.now = originalDateNow;
fetchMock = mock(() => jsonResponse({}));
globalThis.fetch = fetchMock;
});
afterEach(() => {
npm.clearCache();
globalThis.fetch = originalFetch;
Date.now = originalDateNow;
});
test('200 success returns latest versions and dist tags', async () => {
fetchMock.mockImplementation(() => jsonResponse({
'dist-tags': { latest: '1.2.0' },
versions: { '1.0.0': {}, '1.2.0': {} },
}));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({
ok: true,
latest: '1.2.0',
versions: ['1.0.0', '1.2.0'],
distTags: { latest: '1.2.0' },
});
});
test('200 success handles missing dist-tags and versions', async () => {
fetchMock.mockImplementation(() => jsonResponse({}));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
});
test('404 returns package not found', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 404));
const result = await npm.lookupNpmPackage('missing');
expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
});
test('500 returns registry error', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 500));
const result = await npm.lookupNpmPackage('foo');
expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
});
test('network error returns network status', async () => {
fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));
const result = await npm.lookupNpmPackage('foo');
expect(result.ok).toBe(false);
expect(result.status).toBe('network');
expect(result.error).toBe('socket closed');
});
test('timeout plumbs AbortSignal to fetch', async () => {
fetchMock.mockImplementation((_url, init) => {
expect(init.signal).toBeInstanceOf(AbortSignal);
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
});
const result = await npm.lookupNpmPackage('foo');
expect(result.ok).toBe(false);
expect(result.status).toBe('network');
expect(result.error).toContain('aborted');
});
test('cache hit reuses definitive success', async () => {
fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));
const first = await npm.getNpmInfo('foo');
const second = await npm.getNpmInfo('foo');
expect(first).toEqual(second);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('cache miss after ttl fetches again', async () => {
let now = 1_000;
Date.now = mock(() => now);
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
await npm.getNpmInfo('foo');
now += 3_600_001;
await npm.getNpmInfo('foo');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('forceRefresh bypasses cache', async () => {
fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));
await npm.getNpmInfo('foo');
await npm.getNpmInfo('foo', { forceRefresh: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('in-flight requests dedup by package name', async () => {
let release;
const wait = new Promise((resolve) => {
release = resolve;
});
fetchMock.mockImplementation(async () => {
await wait;
return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
});
const requests = Promise.all([
npm.getNpmInfo('foo'),
npm.getNpmInfo('foo'),
npm.getNpmInfo('foo'),
]);
release();
const results = await requests;
expect(results.every((result) => result.ok)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('network failure is not cached', async () => {
fetchMock
.mockImplementationOnce(() => Promise.reject(new Error('down')))
.mockImplementationOnce(() => jsonResponse({ versions: {} }));
const first = await npm.getNpmInfo('foo');
const second = await npm.getNpmInfo('foo');
expect(first.status).toBe('network');
expect(second.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('404 is cached', async () => {
fetchMock.mockImplementation(() => jsonResponse({}, 404));
await npm.getNpmInfo('missing');
await npm.getNpmInfo('missing');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('scoped names encode slash in registry url', async () => {
await npm.getNpmInfo('@scope/pkg');
expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
});
test('user-agent header is present', async () => {
await npm.getNpmInfo('foo');
expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
});
});
@@ -0,0 +1,373 @@
import fs from 'fs';
import os from 'os';
import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js';
import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js';
const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']);
const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']);
const NOT_FOUND_CODES = new Set(['NOT_FOUND', 'ENOENT']);
const BAD_REQUEST_CODES = new Set(['INVALID_FILENAME', 'INVALID_SCOPE', 'INVALID_SPEC', 'EINVAL']);
export const registerPluginRoutes = (app, dependencies) => {
const {
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
getNpmInfo = defaultGetNpmInfo,
parseNpmSpec = defaultParseNpmSpec,
parsePathSpec = defaultParsePathSpec,
isExactSemver = defaultIsExactSemver,
isPathSpec = defaultIsPathSpec,
} = dependencies;
const parsedKindForSpec = (spec) => (isPathSpec(spec) ? 'path' : 'npm');
const resolveDirectory = async (req, res) => {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
res.status(400).json({ error });
return null;
}
return directory || null;
};
const successPayload = (message) => ({
success: true,
requiresReload: true,
message,
reloadDelayMs: clientReloadDelayMs,
reloadFailed: false,
warning: undefined,
});
const completePluginMutation = async (res, operation, _noun, applyChange) => {
applyChange();
const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated');
try {
await refreshOpenCodeAfterConfigChange(`plugin ${operation}`);
return res.json(successPayload(`Plugin ${pastTense}. Reloading interface…`));
} catch (error) {
console.error(`[API:plugin ${operation}] Reload failed after config write:`, error);
return res.json({
success: true,
requiresReload: false,
message: `Plugin ${pastTense}, but OpenCode reload failed.`,
reloadDelayMs: clientReloadDelayMs,
reloadFailed: true,
warning: error.message || 'OpenCode reload failed after plugin config changed',
});
}
};
const validateEntryId = (id) => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
const error = new Error('Plugin entry not found');
error.code = 'NOT_FOUND';
throw error;
}
};
const validateFileId = (id) => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
const error = new Error('Plugin file not found');
error.code = 'NOT_FOUND';
throw error;
}
};
const handlePluginError = (res, error, fallbackMessage, context, existsKind = null) => {
const code = error?.code;
if ((existsKind === 'entry' && ENTRY_EXISTS_CODES.has(code)) || (existsKind === 'file' && FILE_EXISTS_CODES.has(code))) {
return res.status(409).json({ error: error.message });
}
if (NOT_FOUND_CODES.has(code)) {
return res.status(404).json({ error: error.message });
}
if (BAD_REQUEST_CODES.has(code)) {
return res.status(400).json({ error: error.message });
}
console.error(context, error);
return res.status(500).json({ error: fallbackMessage });
};
app.get('/api/config/plugins', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
res.json({
entries: listPluginEntries(directory),
files: listPluginDirFiles(directory),
});
} catch (error) {
console.error('[API:GET /api/config/plugins] Failed:', error);
res.status(500).json({ error: 'Failed to list plugins' });
}
});
app.get('/api/config/plugins/registry', async (req, res) => {
try {
const { directory, error: directoryError } = await resolveOptionalProjectDirectory(req);
if (directoryError) {
return res.status(400).json({ error: directoryError });
}
const rawSpecs = (req.query.specs || '').toString();
const specs = rawSpecs
? rawSpecs.split(',').map((spec) => {
try {
return decodeURIComponent(spec);
} catch {
return spec;
}
}).filter((spec) => spec.length > 0)
: [];
const uniqueSpecs = Array.from(new Set(specs));
if (uniqueSpecs.length > 100) {
return res.status(400).json({ error: 'too many specs' });
}
const refresh = req.query.refresh === 'true';
const npmJobs = new Map();
const malformedSpecs = new Set();
for (const spec of uniqueSpecs) {
if (parsedKindForSpec(spec) !== 'npm') continue;
const parsed = parseNpmSpec(spec);
if (parsed.malformed) {
malformedSpecs.add(spec);
continue;
}
const job = npmJobs.get(parsed.name) || { specs: [], parsedBySpec: new Map() };
job.specs.push(spec);
job.parsedBySpec.set(spec, parsed);
npmJobs.set(parsed.name, job);
}
const npmInfoByName = new Map();
await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
npmInfoByName.set(name, await getNpmInfo(name, { forceRefresh: refresh }));
}));
const results = [];
for (const spec of uniqueSpecs) {
if (malformedSpecs.has(spec)) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
if (parsedKindForSpec(spec) === 'path') {
const { absolutePath } = parsePathSpec(spec, { homedir: os.homedir(), cwd: directory || os.homedir() });
try {
fs.statSync(absolutePath);
} catch {
results.push({ kind: 'path-missing', spec, absolutePath });
continue;
}
try {
fs.accessSync(absolutePath, fs.constants.R_OK);
results.push({ kind: 'path-ok', spec, absolutePath });
} catch {
results.push({ kind: 'path-unreadable', spec, absolutePath });
}
continue;
}
const parsed = parseNpmSpec(spec);
const info = npmInfoByName.get(parsed.name);
if (!info.ok) {
if (info.status === 404) {
results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
continue;
}
results.push({ kind: 'npm-network', spec, error: info.status === 'network' ? info.error : `Registry returned ${info.status}` });
continue;
}
const currentVersion = parsed.version;
if (currentVersion !== null && isExactSemver(currentVersion) && !info.versions.includes(currentVersion)) {
results.push({
kind: 'npm-missing-version',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
});
continue;
}
results.push({
kind: 'npm-ok',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
hasUpdate: currentVersion !== null && isExactSemver(currentVersion) && currentVersion !== info.latest,
});
}
return res.json({ results });
} catch (error) {
console.error('[API:GET /api/config/plugins/registry]', error);
return res.status(500).json({ error: 'Failed to query npm registry' });
}
});
app.get('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
const entry = getPluginEntry(req.params.id, directory);
if (!entry) {
return res.status(404).json({ error: 'Plugin entry not found' });
}
return res.json(entry);
} catch (error) {
return handlePluginError(res, error, 'Failed to get plugin entry', '[API:GET /api/config/plugins/entry/:id] Failed:');
}
});
app.post('/api/config/plugins/entry', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
await completePluginMutation(res, 'entry creation', 'entry', () => {
createPluginEntry({
spec: req.body?.spec,
options: req.body?.options,
scope: req.body?.scope,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to create plugin entry', '[API:POST /api/config/plugins/entry] Failed:', 'entry');
}
});
app.patch('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
await completePluginMutation(res, 'entry update', 'entry', () => {
updatePluginEntry(req.params.id, {
spec: req.body?.spec,
options: req.body?.options,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to update plugin entry', '[API:PATCH /api/config/plugins/entry/:id] Failed:', 'entry');
}
});
app.delete('/api/config/plugins/entry/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateEntryId(req.params.id);
await completePluginMutation(res, 'entry deletion', 'entry', () => {
deletePluginEntry(req.params.id, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to delete plugin entry', '[API:DELETE /api/config/plugins/entry/:id] Failed:', 'entry');
}
});
app.get('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
const file = readPluginDirFile(req.params.id, directory);
if (!file) {
return res.status(404).json({ error: 'Plugin file not found' });
}
return res.json(file);
} catch (error) {
return handlePluginError(res, error, 'Failed to read plugin file', '[API:GET /api/config/plugins/file/:id] Failed:');
}
});
app.post('/api/config/plugins/file', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
const id = encodePluginId('file', `${req.body?.scope || 'user'}:${req.body?.fileName || ''}`);
await completePluginMutation(res, 'file creation', 'file', () => {
validateFileId(id);
writePluginDirFile({
fileName: req.body?.fileName,
content: req.body?.content,
scope: req.body?.scope,
}, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to create plugin file', '[API:POST /api/config/plugins/file] Failed:', 'file');
}
});
app.put('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
const existing = readPluginDirFile(req.params.id, directory);
if (!existing) {
return res.status(404).json({ error: 'Plugin file not found' });
}
await completePluginMutation(res, 'file update', 'file', () => {
writePluginDirFile({
fileName: existing.fileName,
content: req.body?.content,
scope: existing.scope,
}, directory, { overwrite: true });
});
} catch (error) {
return handlePluginError(res, error, 'Failed to update plugin file', '[API:PUT /api/config/plugins/file/:id] Failed:', 'file');
}
});
app.delete('/api/config/plugins/file/:id', async (req, res) => {
try {
const directory = await resolveDirectory(req, res);
if (directory === null && res.headersSent) return;
validateFileId(req.params.id);
await completePluginMutation(res, 'file deletion', 'file', () => {
deletePluginDirFile(req.params.id, directory);
});
} catch (error) {
return handlePluginError(res, error, 'Failed to delete plugin file', '[API:DELETE /api/config/plugins/file/:id] Failed:', 'file');
}
});
};
@@ -0,0 +1,384 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
import express from 'express';
import fs from 'fs';
import os from 'os';
import path from 'path';
import request from 'supertest';
import { registerPluginRoutes } from './plugin-routes.js';
let projectDir;
let userConfigPath;
let rootDir;
let plugins;
let refreshOpenCodeAfterConfigChange;
let app;
let cleanupPaths;
const testUnlessRoot = typeof process.getuid === 'function' && process.getuid() === 0 ? test.skip : test;
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function createApp(overrides = {}) {
const testApp = express();
testApp.use(express.json());
registerPluginRoutes(testApp, {
resolveOptionalProjectDirectory: async () => ({ directory: projectDir, error: null }),
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs: 25,
listPluginEntries: plugins.listPluginEntries,
getPluginEntry: plugins.getPluginEntry,
createPluginEntry: plugins.createPluginEntry,
updatePluginEntry: plugins.updatePluginEntry,
deletePluginEntry: plugins.deletePluginEntry,
listPluginDirFiles: plugins.listPluginDirFiles,
readPluginDirFile: plugins.readPluginDirFile,
writePluginDirFile: plugins.writePluginDirFile,
deletePluginDirFile: plugins.deletePluginDirFile,
encodePluginId: plugins.encodePluginId,
decodePluginId: plugins.decodePluginId,
...overrides,
});
return testApp;
}
function createRegistryApp(getNpmInfo) {
app = createApp({ getNpmInfo });
return app;
}
async function createEntry(spec = 'a') {
return request(app)
.post('/api/config/plugins/entry')
.send({ spec, scope: 'user' })
.expect(200);
}
async function createFile(fileName = 'test.js', content = '//x') {
return request(app)
.post('/api/config/plugins/file')
.send({ fileName, content, scope: 'user' })
.expect(200);
}
describe('opencode plugin routes', () => {
beforeAll(async () => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugin-routes-'));
userConfigPath = path.join(rootDir, 'user-opencode.json');
process.env.OPENCODE_CONFIG = userConfigPath;
plugins = await import('./plugins.js');
});
beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
fs.rmSync(userConfigPath, { force: true });
fs.rmSync(path.join(rootDir, 'plugins'), { recursive: true, force: true });
refreshOpenCodeAfterConfigChange = mock(async () => undefined);
cleanupPaths = [];
app = createApp();
});
afterEach(() => {
for (const target of cleanupPaths) {
try {
fs.chmodSync(target, 0o600);
} catch {
}
}
});
afterAll(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
delete process.env.OPENCODE_CONFIG;
});
test('GET /api/config/plugins empty returns entries and files arrays', async () => {
const response = await request(app).get('/api/config/plugins').expect(200);
expect(response.body).toEqual({ entries: [], files: [] });
});
test('GET /registry with empty specs returns empty results', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
createRegistryApp(getNpmInfo);
const response = await request(app).get('/api/config/plugins/registry?specs=').expect(200);
expect(response.body).toEqual({ results: [] });
expect(getNpmInfo).not.toHaveBeenCalled();
});
test('GET /registry reports update for exact npm version behind latest', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
expect(response.body.results[0]).toMatchObject({
kind: 'npm-ok',
spec: 'foo@1.0.0',
name: 'foo',
currentVersion: '1.0.0',
latestVersion: '2.0.0',
hasUpdate: true,
});
});
test('GET /registry reports no update when exact npm version matches latest', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', hasUpdate: false, latestVersion: '1.0.0', currentVersion: '1.0.0' });
});
test('GET /registry reports missing exact npm version', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo@99.99.99').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-version', name: 'foo', currentVersion: '99.99.99', latestVersion: '2.0.0' });
});
test('GET /registry reports missing npm package', async () => {
createRegistryApp(mock(async () => ({ ok: false, status: 404, error: 'Package not found' })));
const response = await request(app).get('/api/config/plugins/registry?specs=nonexistent@1.0.0').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-package', spec: 'nonexistent@1.0.0', name: 'nonexistent', error: 'Package not found' });
});
test('GET /registry reports malformed npm spec', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
createRegistryApp(getNpmInfo);
const response = await request(app).get('/api/config/plugins/registry?specs=%40%40malformed').expect(200);
expect(response.body.results[0]).toEqual({ kind: 'npm-malformed', spec: '@@malformed', error: 'Spec syntax is malformed' });
expect(getNpmInfo).not.toHaveBeenCalled();
});
test('GET /registry reports existing path plugin ok', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-path-')), 'plugin.js');
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
expect(response.body.results[0]).toEqual({ kind: 'path-ok', spec: tmpFile, absolutePath: tmpFile });
});
test('GET /registry reports missing path plugin', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=%2Fnonexistent%2F__path%2Fxyz.js').expect(200);
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: '/nonexistent/__path/xyz.js', absolutePath: '/nonexistent/__path/xyz.js' });
});
test('GET /registry treats Windows absolute paths as local paths', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
createRegistryApp(getNpmInfo);
const windowsPath = 'C:\\Users\\me\\plugin.js';
const response = await request(app)
.get(`/api/config/plugins/registry?specs=${encodeURIComponent(windowsPath)}`)
.expect(200);
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: windowsPath, absolutePath: windowsPath });
expect(getNpmInfo).not.toHaveBeenCalled();
});
testUnlessRoot('GET /registry reports unreadable path plugin', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-unreadable-')), 'plugin.js');
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
cleanupPaths.push(tmpFile);
fs.chmodSync(tmpFile, 0);
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
expect(response.body.results[0]).toEqual({ kind: 'path-unreadable', spec: tmpFile, absolutePath: tmpFile });
});
test('GET /registry reports npm network failure without failing route', async () => {
createRegistryApp(mock(async () => ({ ok: false, status: 'network', error: 'socket closed' })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-network', spec: 'foo@1.0.0', error: 'socket closed' });
});
test('GET /registry deduplicates npm package lookups by name', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '3.0.0', versions: ['1', '2', '3'], distTags: { latest: '3.0.0' } }));
createRegistryApp(getNpmInfo);
await request(app).get('/api/config/plugins/registry?specs=foo@1,foo@2,foo@3').expect(200);
expect(getNpmInfo).toHaveBeenCalledTimes(1);
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: false });
});
test('GET /registry forwards refresh true to npm lookup', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
createRegistryApp(getNpmInfo);
await request(app).get('/api/config/plugins/registry?specs=foo&refresh=true').expect(200);
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: true });
});
test('GET /registry rejects more than 100 unique specs', async () => {
const specs = Array.from({ length: 101 }, (_, index) => `pkg-${index}`).join(',');
const response = await request(app).get(`/api/config/plugins/registry?specs=${specs}`).expect(400);
expect(response.body).toEqual({ error: 'too many specs' });
});
test('GET /registry reports bare npm name with null current version', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo', name: 'foo', currentVersion: null, hasUpdate: false });
});
test('GET /registry accepts non-exact npm range without missing-version noise', async () => {
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
const response = await request(app).get('/api/config/plugins/registry?specs=foo@%5E1.0').expect(200);
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo@^1.0', name: 'foo', currentVersion: '^1.0', hasUpdate: false });
});
test('GET /registry supports scoped npm package specs', async () => {
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
createRegistryApp(getNpmInfo);
const response = await request(app).get('/api/config/plugins/registry?specs=%40scope%2Ffoo%401.0.0').expect(200);
expect(getNpmInfo).toHaveBeenCalledWith('@scope/foo', { forceRefresh: false });
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
});
test('POST /entry creates entry and requires reload', async () => {
const response = await createEntry('a');
expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
});
test('GET after POST returns created entry', async () => {
await createEntry('a');
const response = await request(app).get('/api/config/plugins').expect(200);
expect(response.body.entries).toEqual([expect.objectContaining({ spec: 'a', scope: 'user' })]);
});
test('POST duplicate entry returns 409', async () => {
await createEntry('a');
const response = await request(app)
.post('/api/config/plugins/entry')
.send({ spec: 'a', scope: 'user' })
.expect(409);
expect(response.body.error).toContain('already exists');
});
test('PATCH /entry/:id updates entry in same array index', async () => {
await createEntry('a');
const before = await request(app).get('/api/config/plugins').expect(200);
const id = before.body.entries[0].id;
const response = await request(app)
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
.send({ spec: 'b' })
.expect(200);
expect(response.body.success).toBe(true);
const after = await request(app).get('/api/config/plugins').expect(200);
expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update');
});
test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
await createEntry('a');
const listed = await request(app).get('/api/config/plugins').expect(200);
const id = listed.body.entries[0].id;
await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
const after = await request(app).get('/api/config/plugins').expect(200);
expect(after.body.entries).toEqual([]);
expect(readJson(userConfigPath).plugin).toBeUndefined();
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
});
test('POST /file writes plugin dir file', async () => {
const response = await createFile('test.js', '//x');
expect(response.body).toMatchObject({ success: true, requiresReload: true });
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
});
test('POST duplicate file returns 409', async () => {
await createFile('test.js', '//x');
const response = await request(app)
.post('/api/config/plugins/file')
.send({ fileName: 'test.js', content: '//again', scope: 'user' })
.expect(409);
expect(response.body.error).toContain('already exists');
});
test('PUT /file/:id updates file content', async () => {
await createFile('test.js', '//x');
const listed = await request(app).get('/api/config/plugins').expect(200);
const id = listed.body.files[0].id;
await request(app)
.put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
.send({ content: '//y' })
.expect(200);
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
});
test('DELETE /file/:id unlinks file', async () => {
await createFile('test.js', '//x');
const listed = await request(app).get('/api/config/plugins').expect(200);
const id = listed.body.files[0].id;
await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
});
test('PATCH unknown entry id returns 404', async () => {
const id = plugins.encodePluginId('config', 'user:missing');
const response = await request(app)
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
.send({ spec: 'b' })
.expect(404);
expect(response.body.error).toContain('not found');
});
test('POST invalid fileName returns 400', async () => {
const response = await request(app)
.post('/api/config/plugins/file')
.send({ fileName: '../escape.js', content: '//x', scope: 'user' })
.expect(400);
expect(response.body.error).toContain('Plugin file name');
});
});
@@ -0,0 +1,107 @@
import path from 'path';
/**
* @typedef {Object} ParsedNpmSpec
* @property {string} name
* @property {string|null} version
*/
/**
* @typedef {Object} MalformedSpec
* @property {true} malformed
* @property {string} raw
*/
/**
* @typedef {Object} ParsedPathSpec
* @property {string} absolutePath
*/
/**
* Parse an npm package spec string into name + version.
* Handles scoped packages (`@scope/name[@version]`) and unscoped (`name[@version]`).
* Non-string inputs are coerced via `String()` and returned as malformed.
*
* @param {unknown} spec
* @returns {ParsedNpmSpec | MalformedSpec}
*/
export function parseNpmSpec(spec) {
if (typeof spec !== 'string') {
return { malformed: true, raw: String(spec) };
}
if (spec.startsWith('@')) {
// scoped: '@scope/name' or '@scope/name@version'
const slashIdx = spec.indexOf('/');
if (slashIdx < 2) return { malformed: true, raw: spec }; // '@' or '@/foo'
const afterSlash = spec.slice(slashIdx + 1);
if (afterSlash === '') return { malformed: true, raw: spec }; // '@scope/'
const atIdx = afterSlash.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
const namePart = spec.slice(0, slashIdx + 1 + atIdx); // '@scope/name'
const versionPart = afterSlash.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // '@scope/foo@'
return { name: namePart, version: versionPart };
}
// unscoped
if (spec === '') return { malformed: true, raw: spec };
const atIdx = spec.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
if (atIdx === 0) return { malformed: true, raw: spec }; // bare '@'
const namePart = spec.slice(0, atIdx);
const versionPart = spec.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // 'foo@'
return { name: namePart, version: versionPart };
}
/**
* Check whether a version string is an exact semver (no range operators).
* Accepts optional pre-release (`-label`) or build metadata (`+label`) suffixes.
*
* @param {string} version
* @returns {boolean}
*/
export function isExactSemver(version) {
return /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
}
/**
* Check whether a plugin spec is path-like instead of an npm package spec.
* Includes Windows absolute paths so local paths are never queried against npm.
*
* @param {string} spec
* @returns {boolean}
*/
export function isPathSpec(spec) {
return spec.startsWith('/')
|| spec.startsWith('./')
|| spec.startsWith('../')
|| spec.startsWith('~')
|| path.win32.isAbsolute(spec);
}
/**
* Resolve a path-style plugin spec to an absolute path.
* Supports `~` (home), `./`, `../` (relative to cwd), and absolute paths.
* Pure no filesystem access; uses only `path.resolve`.
*
* @param {string} spec
* @param {{ homedir: string, cwd: string }} options
* @returns {ParsedPathSpec}
*/
export function parsePathSpec(spec, { homedir, cwd }) {
if (spec === '~') {
return { absolutePath: path.resolve(homedir) };
}
if (spec.startsWith('~/')) {
return { absolutePath: path.resolve(homedir, spec.slice(2)) };
}
if (spec.startsWith('./') || spec.startsWith('../')) {
return { absolutePath: path.resolve(cwd, spec) };
}
if (path.win32.isAbsolute(spec)) {
return { absolutePath: spec };
}
return { absolutePath: path.resolve(spec) };
}
@@ -0,0 +1,154 @@
import { describe, expect, test } from 'bun:test';
import * as spec from './plugin-spec.js';
describe('parseNpmSpec', () => {
test('unscoped: no version', () => {
expect(spec.parseNpmSpec('foo')).toEqual({ name: 'foo', version: null });
});
test('unscoped: exact version', () => {
expect(spec.parseNpmSpec('foo@1.2.3')).toEqual({ name: 'foo', version: '1.2.3' });
});
test('unscoped: range version', () => {
expect(spec.parseNpmSpec('foo@^1.2.0')).toEqual({ name: 'foo', version: '^1.2.0' });
});
test('unscoped: dist-tag', () => {
expect(spec.parseNpmSpec('foo@latest')).toEqual({ name: 'foo', version: 'latest' });
});
test('scoped: no version', () => {
expect(spec.parseNpmSpec('@scope/foo')).toEqual({ name: '@scope/foo', version: null });
});
test('scoped: exact version', () => {
expect(spec.parseNpmSpec('@scope/foo@1.2.3')).toEqual({ name: '@scope/foo', version: '1.2.3' });
});
test('scoped: dist-tag', () => {
expect(spec.parseNpmSpec('@scope/foo@beta')).toEqual({ name: '@scope/foo', version: 'beta' });
});
test('malformed: empty string', () => {
expect(spec.parseNpmSpec('')).toEqual({ malformed: true, raw: '' });
});
test('malformed: bare @', () => {
expect(spec.parseNpmSpec('@')).toEqual({ malformed: true, raw: '@' });
});
test('malformed: @@', () => {
expect(spec.parseNpmSpec('@@')).toEqual({ malformed: true, raw: '@@' });
});
test('malformed: empty version after @', () => {
expect(spec.parseNpmSpec('foo@')).toEqual({ malformed: true, raw: 'foo@' });
});
test('malformed: scoped empty name after slash', () => {
expect(spec.parseNpmSpec('@scope/')).toEqual({ malformed: true, raw: '@scope/' });
});
test('malformed: scoped empty version', () => {
expect(spec.parseNpmSpec('@scope/foo@')).toEqual({ malformed: true, raw: '@scope/foo@' });
});
test('malformed: null input', () => {
expect(spec.parseNpmSpec(null)).toEqual({ malformed: true, raw: 'null' });
});
test('malformed: undefined input', () => {
expect(spec.parseNpmSpec(undefined)).toEqual({ malformed: true, raw: 'undefined' });
});
test('malformed: number input', () => {
expect(spec.parseNpmSpec(42)).toEqual({ malformed: true, raw: '42' });
});
test('malformed: array input', () => {
expect(spec.parseNpmSpec(['foo'])).toEqual({ malformed: true, raw: 'foo' });
});
test('malformed: object input', () => {
expect(spec.parseNpmSpec({})).toEqual({ malformed: true, raw: '[object Object]' });
});
});
describe('isExactSemver', () => {
test('plain semver', () => {
expect(spec.isExactSemver('1.2.3')).toBe(true);
});
test('semver with pre-release', () => {
expect(spec.isExactSemver('1.2.3-beta.1')).toBe(true);
});
test('semver with build metadata', () => {
expect(spec.isExactSemver('1.2.3+build.5')).toBe(true);
});
test('range: caret', () => {
expect(spec.isExactSemver('^1.2.0')).toBe(false);
});
test('dist-tag', () => {
expect(spec.isExactSemver('latest')).toBe(false);
});
test('empty string', () => {
expect(spec.isExactSemver('')).toBe(false);
});
test('partial: major.minor only', () => {
expect(spec.isExactSemver('1.2')).toBe(false);
});
test('partial: major only', () => {
expect(spec.isExactSemver('1')).toBe(false);
});
});
describe('parsePathSpec', () => {
test('identifies Windows absolute paths as path specs', () => {
expect(spec.isPathSpec('C:\\Users\\me\\plugin.js')).toBe(true);
expect(spec.isPathSpec('\\\\server\\share\\plugin.js')).toBe(true);
expect(spec.isPathSpec('@scope/plugin')).toBe(false);
});
test('tilde home shorthand with subpath', () => {
expect(spec.parsePathSpec('~/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/home/u/x.js',
});
});
test('bare tilde = homedir', () => {
expect(spec.parsePathSpec('~', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/home/u',
});
});
test('relative ./', () => {
expect(spec.parsePathSpec('./x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/p/x.js',
});
});
test('relative ../', () => {
expect(spec.parsePathSpec('../x.js', { homedir: '/home/u', cwd: '/p/a' })).toEqual({
absolutePath: '/p/x.js',
});
});
test('absolute path passthrough', () => {
expect(spec.parsePathSpec('/abs/x.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: '/abs/x.js',
});
});
test('Windows absolute path passthrough', () => {
expect(spec.parsePathSpec('C:\\Users\\me\\plugin.js', { homedir: '/home/u', cwd: '/p' })).toEqual({
absolutePath: 'C:\\Users\\me\\plugin.js',
});
});
});
+393
View File
@@ -0,0 +1,393 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_SCOPE,
readConfigFile,
writeConfig,
} from './shared.js';
import { isPathSpec } from './plugin-spec.js';
const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
/**
* @typedef {'user' | 'project'} PluginScope
* @typedef {'npm' | 'path'} PluginParsedKind
* @typedef {Object} PluginEntry
* @property {string} id base64url encoded "config:scope:spec"
* @property {string} spec
* @property {Record<string, unknown>} [options]
* @property {PluginScope} scope
* @property {'config'} kind
* @property {PluginParsedKind} parsedKind
* @property {string} sourcePath absolute path to the config file
* @typedef {Object} PluginFile
* @property {string} id base64url encoded "file:scope:fileName"
* @property {string} fileName
* @property {PluginScope} scope
* @property {'file'} kind
* @property {string} absolutePath
*/
function codedError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function validateScope(scope) {
if (scope !== AGENT_SCOPE.USER && scope !== AGENT_SCOPE.PROJECT) {
throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
}
}
function validatePluginSpec(spec) {
if (typeof spec !== 'string' || !spec.trim()) {
throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
}
if (spec.includes('\0')) {
throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
}
return spec.trim();
}
function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOptions(options) {
return isRecord(options) && Object.keys(options).length > 0;
}
function parsedKindForSpec(spec) {
// Path indicators must include Windows paths; scoped npm packages also contain '/'.
// Do NOT use `includes(path.sep)` — scoped npm packages legitimately contain '/' (e.g. `@gitlab/opencode-gitlab-auth`).
return isPathSpec(spec) ? 'path' : 'npm';
}
function getActiveOpencodeConfigDir() {
const customConfigPath = process.env.OPENCODE_CONFIG;
if (customConfigPath) {
return path.dirname(path.resolve(customConfigPath));
}
return path.join(os.homedir(), '.config', 'opencode');
}
function getActiveUserConfigPaths() {
const configDir = getActiveOpencodeConfigDir();
return [
path.join(configDir, 'config.json'),
path.join(configDir, 'opencode.json'),
path.join(configDir, 'opencode.jsonc'),
];
}
function getActiveCustomConfigPath() {
return process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
}
function getPrimaryUserConfigPath() {
const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
for (const userPath of [defaultPath, ...fallbackPaths]) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return defaultPath;
}
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
const candidates = [
path.join(workingDirectory, 'opencode.json'),
path.join(workingDirectory, 'opencode.jsonc'),
path.join(workingDirectory, '.opencode', 'opencode.json'),
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
function readPluginConfigLayers(workingDirectory) {
const customPath = getActiveCustomConfigPath();
const userPath = getPrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory);
return {
userConfig: readConfigFile(userPath),
projectConfig: readConfigFile(projectPath),
customConfig: readConfigFile(customPath),
paths: {
userPath,
projectPath,
customPath,
},
};
}
function validateFileName(fileName) {
if (typeof fileName !== 'string' || !fileName) {
throw codedError('Plugin file name is required', 'INVALID_FILENAME');
}
if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..') || !PLUGIN_FILE_NAME_PATTERN.test(fileName)) {
throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
}
return fileName;
}
function ensureProjectConfigPath(workingDirectory) {
if (!workingDirectory) {
throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
}
const configDir = path.join(workingDirectory, '.opencode');
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, 'opencode.json');
}
function configSources(layers) {
const sources = [];
if (layers.paths.customPath) {
sources.push({ config: layers.customConfig, filePath: layers.paths.customPath, scope: AGENT_SCOPE.USER });
} else {
sources.push({ config: layers.userConfig, filePath: layers.paths.userPath, scope: AGENT_SCOPE.USER });
}
if (layers.paths.projectPath) {
sources.push({ config: layers.projectConfig, filePath: layers.paths.projectPath, scope: AGENT_SCOPE.PROJECT });
}
return sources;
}
function splitScopedValue(value) {
const separator = value.indexOf(':');
if (separator === -1) {
throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
}
return {
scope: value.slice(0, separator),
value: value.slice(separator + 1),
};
}
function getPluginTarget(id, workingDirectory) {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
}
const { scope, value: spec } = splitScopedValue(decoded.value);
validateScope(scope);
const layers = readPluginConfigLayers(workingDirectory);
const source = configSources(layers).find((candidate) => candidate.scope === scope);
const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
if (!source || index === -1) {
return null;
}
return { source, plugin, index };
}
function pluginDirForScope(scope, workingDirectory) {
validateScope(scope);
if (scope === AGENT_SCOPE.PROJECT) {
if (!workingDirectory) {
throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
}
return path.join(workingDirectory, '.opencode', 'plugins');
}
return path.join(getActiveOpencodeConfigDir(), 'plugins');
}
function fileTargetFromId(id, workingDirectory) {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
}
const { scope, value: fileName } = splitScopedValue(decoded.value);
validateScope(scope);
validateFileName(fileName);
return {
fileName,
scope,
absolutePath: path.join(pluginDirForScope(scope, workingDirectory), fileName),
};
}
function encodePluginId(prefix, value) {
return Buffer.from(`${prefix}:${value}`).toString('base64url');
}
function decodePluginId(id) {
const decoded = Buffer.from(id, 'base64url').toString('utf8');
const separator = decoded.indexOf(':');
if (separator === -1) {
throw codedError('Invalid plugin id', 'INVALID_SPEC');
}
return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
}
function parsePluginRaw(raw) {
if (typeof raw === 'string') {
return { spec: validatePluginSpec(raw) };
}
if (Array.isArray(raw) && raw.length === 2 && isRecord(raw[1])) {
return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
}
throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
}
function serializePluginEntry(entry) {
const spec = validatePluginSpec(entry?.spec);
if (hasOptions(entry?.options)) {
return [spec, { ...entry.options }];
}
return spec;
}
function listPluginEntries(workingDirectory) {
const layers = readPluginConfigLayers(workingDirectory);
return configSources(layers).flatMap((source) => {
if (!Array.isArray(source.config?.plugin)) {
return [];
}
return source.config.plugin.map((raw) => {
const parsed = parsePluginRaw(raw);
return {
id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
spec: parsed.spec,
...(parsed.options !== undefined ? { options: parsed.options } : {}),
scope: source.scope,
kind: 'config',
parsedKind: parsedKindForSpec(parsed.spec),
sourcePath: source.filePath,
};
});
});
}
function getPluginEntry(id, workingDirectory) {
return listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
}
function createPluginEntry(entry, workingDirectory) {
const spec = validatePluginSpec(entry?.spec);
const scope = entry?.scope || AGENT_SCOPE.USER;
validateScope(scope);
const layers = readPluginConfigLayers(workingDirectory);
const existing = configSources(layers).find((source) => (
source.scope === scope
&& Array.isArray(source.config?.plugin)
&& source.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec)
));
if (existing) {
throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
}
let targetPath = getPrimaryUserConfigPath();
let config = {};
if (scope === AGENT_SCOPE.PROJECT) {
targetPath = ensureProjectConfigPath(workingDirectory);
config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
} else {
targetPath = layers.paths.customPath || layers.paths.userPath;
config = layers.paths.customPath ? layers.customConfig : layers.userConfig;
}
if (!Array.isArray(config.plugin)) {
config.plugin = [];
}
config.plugin.push(serializePluginEntry({ spec, options: entry.options }));
writeConfig(config, targetPath);
}
function updatePluginEntry(id, updates, workingDirectory) {
const target = getPluginTarget(id, workingDirectory);
if (!target) {
throw codedError('Plugin entry not found', 'NOT_FOUND');
}
const existing = parsePluginRaw(target.plugin[target.index]);
const nextSpec = updates?.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
const nextOptions = updates?.options === undefined ? existing.options : updates.options;
target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
writeConfig(target.source.config, target.source.filePath);
}
function deletePluginEntry(id, workingDirectory) {
const target = getPluginTarget(id, workingDirectory);
if (!target) {
throw codedError('Plugin entry not found', 'NOT_FOUND');
}
target.plugin.splice(target.index, 1);
if (target.plugin.length === 0) {
delete target.source.config.plugin;
}
writeConfig(target.source.config, target.source.filePath);
}
function listPluginDirFiles(workingDirectory) {
const scopes = [AGENT_SCOPE.USER];
if (workingDirectory) {
scopes.push(AGENT_SCOPE.PROJECT);
}
return scopes.flatMap((scope) => {
const dir = pluginDirForScope(scope, workingDirectory);
if (!fs.existsSync(dir)) {
return [];
}
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && PLUGIN_FILE_NAME_PATTERN.test(entry.name) && !entry.name.includes('..'))
.map((entry) => ({
id: encodePluginId('file', `${scope}:${entry.name}`),
fileName: entry.name,
scope,
kind: 'file',
absolutePath: path.join(dir, entry.name),
}));
});
}
function readPluginDirFile(id, workingDirectory) {
const target = fileTargetFromId(id, workingDirectory);
if (!fs.existsSync(target.absolutePath)) {
return null;
}
return {
fileName: target.fileName,
scope: target.scope,
content: fs.readFileSync(target.absolutePath, 'utf8'),
};
}
function writePluginDirFile(file, workingDirectory, opts = {}) {
const fileName = validateFileName(file?.fileName);
const scope = file?.scope || AGENT_SCOPE.USER;
validateScope(scope);
const dir = pluginDirForScope(scope, workingDirectory);
const absolutePath = path.join(dir, fileName);
if (!opts.overwrite && fs.existsSync(absolutePath)) {
throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(absolutePath, file?.content ?? '', 'utf8');
}
function deletePluginDirFile(id, workingDirectory) {
const target = fileTargetFromId(id, workingDirectory);
if (!fs.existsSync(target.absolutePath)) {
throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
}
fs.unlinkSync(target.absolutePath);
}
export {
listPluginEntries,
getPluginEntry,
createPluginEntry,
updatePluginEntry,
deletePluginEntry,
listPluginDirFiles,
readPluginDirFile,
writePluginDirFile,
deletePluginDirFile,
encodePluginId,
decodePluginId,
parsePluginRaw,
serializePluginEntry,
};
@@ -0,0 +1,176 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
let rootDir;
let projectDir;
let userConfigPath;
let plugins;
function thrownBy(fn) {
try {
fn();
} catch (error) {
return error;
}
throw new Error('Expected function to throw');
}
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
describe('opencode plugins data layer', () => {
beforeAll(async () => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugins-'));
userConfigPath = path.join(rootDir, 'user-opencode.json');
process.env.OPENCODE_CONFIG = userConfigPath;
plugins = await import('./plugins.js');
});
beforeEach(() => {
process.env.OPENCODE_CONFIG = userConfigPath;
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
fs.rmSync(userConfigPath, { force: true });
});
afterAll(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
delete process.env.OPENCODE_CONFIG;
});
test('parses raw plugin entries', () => {
expect(plugins.parsePluginRaw('foo')).toEqual({ spec: 'foo' });
expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
});
test('serializes plugin entries', () => {
expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
});
test('rejects invalid specs and file names', () => {
expect(() => plugins.createPluginEntry({ spec: 123, scope: 'user' }, projectDir)).toThrow('Plugin spec');
expect(() => plugins.writePluginDirFile({ fileName: '', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: '../bad.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'a/b.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'A.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
});
test('creates string and tuple entries with duplicate rejection', () => {
plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
});
test('routes project entries to project config and user entries to custom user config', () => {
plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
});
test('re-resolves custom config env between calls', () => {
const firstConfigPath = path.join(rootDir, 'first', 'opencode.json');
const secondConfigPath = path.join(rootDir, 'second', 'opencode.json');
process.env.OPENCODE_CONFIG = firstConfigPath;
plugins.createPluginEntry({ spec: 'first-plugin', scope: 'user' }, projectDir);
plugins.writePluginDirFile({ fileName: 'first.js', content: 'one', scope: 'user' }, projectDir);
process.env.OPENCODE_CONFIG = secondConfigPath;
plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
});
test('updates entries in place and transitions between string and tuple', () => {
writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
});
test('deletes entries and prunes empty plugin key', () => {
writeJson(userConfigPath, { plugin: ['only'] });
plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:only'), projectDir);
expect(readJson(userConfigPath)).toEqual({});
});
test('lists entries from user and project layers with scopes and parsed kinds', () => {
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
const entries = plugins.listPluginEntries(projectDir);
expect(entries).toEqual([
expect.objectContaining({ spec: 'npm-plugin', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
expect.objectContaining({ spec: '/abs/plugin.js', scope: 'user', kind: 'config', parsedKind: 'path', sourcePath: userConfigPath }),
expect.objectContaining({ spec: '@scope/pkg@1.0.0', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
expect.objectContaining({ spec: './local-plugin.js', scope: 'project', kind: 'config', parsedKind: 'path', sourcePath: path.join(projectDir, '.opencode', 'opencode.json') }),
]);
fs.rmSync(userConfigPath, { force: true });
expect(plugins.listPluginEntries(projectDir)).toEqual([
expect.objectContaining({ spec: './local-plugin.js', scope: 'project' }),
]);
});
test('encodes and decodes ids', () => {
const id = plugins.encodePluginId('config', 'user:oh-my-openagent@4.3.0');
expect(plugins.decodePluginId(id)).toEqual({ prefix: 'config', value: 'user:oh-my-openagent@4.3.0' });
});
test('round-trips plugin dir files', () => {
plugins.writePluginDirFile({ fileName: 'my-plugin.ts', content: 'export default {}', scope: 'project' }, projectDir);
const file = plugins.listPluginDirFiles(projectDir).find((candidate) => candidate.fileName === 'my-plugin.ts');
expect(file).toEqual(expect.objectContaining({ fileName: 'my-plugin.ts', scope: 'project', kind: 'file' }));
expect(plugins.readPluginDirFile(file.id, projectDir)).toEqual({ fileName: 'my-plugin.ts', scope: 'project', content: 'export default {}' });
plugins.deletePluginDirFile(file.id, projectDir);
expect(plugins.listPluginDirFiles(projectDir).filter((candidate) => candidate.scope === 'project')).toEqual([]);
expect(() => plugins.deletePluginDirFile(file.id, projectDir)).toThrow('not found');
});
test('rejects duplicate plugin dir files unless overwrite is true', () => {
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'one', scope: 'project' }, projectDir);
expect(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir)).toThrow('already exists');
expect(thrownBy(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir))).toHaveProperty('code', 'FILE_EXISTS');
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir, { overwrite: true });
expect(fs.readFileSync(path.join(projectDir, '.opencode', 'plugins', 'dup.js'), 'utf8')).toBe('two');
});
test('lists only valid plugin dir files', () => {
const dir = path.join(projectDir, '.opencode', 'plugins');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'valid.mjs'), '', 'utf8');
fs.writeFileSync(path.join(dir, 'README.md'), '', 'utf8');
expect(plugins.listPluginDirFiles(projectDir).filter((file) => file.scope === 'project')).toEqual([
expect.objectContaining({ fileName: 'valid.mjs', scope: 'project' }),
]);
});
});