Skip to content

Commit 84419e8

Browse files
feat: plugin manager UI for adding and removing OpenCode plugins
CodeNomad shows which plugins are loaded in the Status tab but there was no way to add or remove them from the UI — you had to edit opencode.jsonc by hand. This adds a Plugin Manager modal that lets you: - See all configured plugins (including tuple [spec, opts] format) - Add new plugins by spec (npm:, file://, https://, /paths, ./relative) - Remove plugins with a confirmation step - Get inline validation feedback for invalid specs - All changes persist across workspace restarts How it works: The modal reads and writes plugins to two places: the global OpenCode config file (~/.config/opencode/opencode.jsonc) and CodeNomad server settings (environmentVariables). Writing to both means plugin changes survive restarts — the server config is what CodeNomad reads when launching new workspaces. A "Manage" button sits next to the Plugins heading in the Status tab right panel. Clicking it opens the modal with an input field for new plugin specs, a list of installed plugins with Remove buttons, and a notice that changes need a restart. The API endpoint at /api/opencode-plugin-config handles the config file I/O with JSONC support (comments, trailing commas). The endpoint also syncs to CodeNomad server settings so the next workspace launch picks up the changed plugin list. Edge cases covered: - Empty config file -- creates it on first add - Broken JSON -- returns empty list gracefully - Duplicate specs -- silently skipped - Whitespace -- trimmed before storage - Invalid specs -- rejected with a message - Tuple format [spec, {enabled:false}] -- read correctly - Concurrent add/remove -- button disabled while in flight - Remove confirmation -- Cancel goes back, Remove confirms - Empty plugin list -- removes plugin field from config entirely - Original config preserved -- $schema, mcp, etc. untouched A shared stripJsonc module was extracted to config/jsonc.ts used by both the settings routes and the existing opencode-plugin.ts.
1 parent f900af1 commit 84419e8

14 files changed

Lines changed: 724 additions & 2 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
export function stripJsonc(input: string): string {
2+
let output = ""
3+
let inString = false
4+
let escape = false
5+
6+
for (let index = 0; index < input.length; index += 1) {
7+
const char = input[index]
8+
const next = input[index + 1] as string | undefined
9+
10+
if (escape) {
11+
output += char
12+
escape = false
13+
continue
14+
}
15+
16+
if (char === "\\" && inString) {
17+
output += char
18+
escape = true
19+
continue
20+
}
21+
22+
if (char === '"') {
23+
output += char
24+
inString = !inString
25+
continue
26+
}
27+
28+
if (!inString && char === "/" && next === "/") {
29+
while (index < input.length && input[index] !== "\n") {
30+
index += 1
31+
}
32+
output += "\n"
33+
continue
34+
}
35+
36+
if (!inString && char === "/" && next === "*") {
37+
index += 2
38+
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) {
39+
output += input[index] === "\n" ? "\n" : ""
40+
index += 1
41+
}
42+
index += 1
43+
continue
44+
}
45+
46+
if (!inString && char === ",") {
47+
let lookahead = index + 1
48+
while (lookahead < input.length && /\s/.test(input[lookahead]!)) {
49+
lookahead += 1
50+
}
51+
if (input[lookahead] === "}" || input[lookahead] === "]") {
52+
continue
53+
}
54+
}
55+
56+
output += char
57+
}
58+
59+
return output
60+
}

packages/server/src/opencode-plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from "fs"
22
import path from "path"
33
import { fileURLToPath, pathToFileURL } from "url"
44
import { createLogger } from "./logger"
5+
import { stripJsonc } from "./config/jsonc"
56

67
const log = createLogger({ component: "opencode-plugin" })
78
const pluginPackageName = "@codenomad/codenomad-opencode-plugin"

packages/server/src/server/routes/settings.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { FastifyInstance } from "fastify"
22
import { z } from "zod"
3+
import { readFile, writeFile } from "fs/promises"
4+
import { existsSync } from "fs"
5+
import path from "path"
6+
import os from "os"
37
import { probeBinaryVersion } from "../../workspaces/spawn"
48
import type { SettingsService } from "../../settings/service"
59
import type { Logger } from "../../logger"
610
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
11+
import { stripJsonc } from "../../config/jsonc"
712

813
interface RouteDeps {
914
settings: SettingsService
@@ -14,6 +19,11 @@ const ValidateBinarySchema = z.object({
1419
path: z.string(),
1520
})
1621

22+
const PluginEntrySchema = z.string().min(1)
23+
const UpdatePluginConfigSchema = z.object({
24+
plugins: z.array(PluginEntrySchema),
25+
})
26+
1727
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
1828
const result = probeBinaryVersion(binaryPath)
1929
return { valid: result.valid, version: result.version, error: result.error }
@@ -49,6 +59,95 @@ export function enforceSpeechCredentialPairing(body: unknown, currentSpeech?: un
4959
return patch
5060
}
5161

62+
function safeJsonParse(raw: string): Record<string, unknown> | null {
63+
try {
64+
const cleaned = stripJsonc(raw).replace(/,\s*}/g, "}").replace(/,\s*\]/g, "]")
65+
const parsed = JSON.parse(cleaned)
66+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
67+
return parsed as Record<string, unknown>
68+
}
69+
return null
70+
} catch {
71+
return null
72+
}
73+
}
74+
75+
type PluginEntry = { spec: string; enabled: boolean }
76+
77+
function normalizePluginEntry(entry: unknown): PluginEntry | null {
78+
if (typeof entry === "string" && entry.trim().length > 0) {
79+
return { spec: entry.trim(), enabled: true }
80+
}
81+
if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && entry[0].trim().length > 0) {
82+
const opts = entry[1] as Record<string, unknown> | null | undefined
83+
const enabled = opts && typeof opts === "object" && "enabled" in opts ? opts.enabled !== false : true
84+
return { spec: entry[0].trim(), enabled }
85+
}
86+
return null
87+
}
88+
89+
function readPluginsFromConfig(config: Record<string, unknown>): PluginEntry[] {
90+
const raw = config.plugin
91+
if (!Array.isArray(raw)) return []
92+
return raw.map(normalizePluginEntry).filter((entry): entry is PluginEntry => entry !== null)
93+
}
94+
95+
function getGlobalConfigPath(): string {
96+
return path.join(os.homedir(), ".config", "opencode", "opencode.jsonc")
97+
}
98+
99+
async function readGlobalConfigPlugins(): Promise<PluginEntry[]> {
100+
const configPath = getGlobalConfigPath()
101+
if (!existsSync(configPath)) return []
102+
try {
103+
const raw = await readFile(configPath, "utf-8")
104+
const config = safeJsonParse(raw)
105+
if (!config) return []
106+
return readPluginsFromConfig(config)
107+
} catch {
108+
return []
109+
}
110+
}
111+
112+
async function writeGlobalConfigPlugins(entries: PluginEntry[]): Promise<void> {
113+
const configPath = getGlobalConfigPath()
114+
let config: Record<string, unknown> = {}
115+
if (existsSync(configPath)) {
116+
try {
117+
const raw = await readFile(configPath, "utf-8")
118+
config = safeJsonParse(raw) ?? {}
119+
} catch { /* use empty config */ }
120+
}
121+
122+
const serialized: unknown[] = entries.map((entry) => {
123+
if (entry.enabled) return entry.spec
124+
return [entry.spec, { enabled: false }]
125+
})
126+
127+
if (serialized.length === 0) {
128+
delete config.plugin
129+
} else {
130+
config.plugin = serialized
131+
}
132+
133+
config["$schema"] = config["$schema"] ?? "https://opencode.ai/config.json"
134+
const dir = path.dirname(configPath)
135+
const { mkdir } = await import("fs/promises")
136+
await mkdir(dir, { recursive: true })
137+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
138+
}
139+
140+
function isValidPluginSpec(spec: string): boolean {
141+
return (
142+
spec.startsWith("npm:") ||
143+
spec.startsWith("file://") ||
144+
spec.startsWith("https://") ||
145+
spec.startsWith("http://") ||
146+
spec.startsWith("/") ||
147+
spec.startsWith(".")
148+
)
149+
}
150+
52151
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
53152
// Full-document access
54153
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
@@ -127,4 +226,53 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
127226
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
128227
}
129228
})
229+
230+
// Plugin config management — reads/writes the global opencode.jsonc plugin array
231+
app.get("/api/opencode-plugin-config", async () => {
232+
try {
233+
const plugins = await readGlobalConfigPlugins()
234+
return { plugins: plugins.map((p) => ({ spec: p.spec, enabled: p.enabled })) }
235+
} catch {
236+
return { plugins: [] }
237+
}
238+
})
239+
240+
app.put("/api/opencode-plugin-config", async (request, reply) => {
241+
try {
242+
const body = UpdatePluginConfigSchema.parse(request.body ?? {})
243+
for (const spec of body.plugins) {
244+
if (!isValidPluginSpec(spec.trim())) {
245+
reply.code(400)
246+
return { error: `Invalid plugin spec: "${spec}". Must start with npm:, file://, https://, /, or .` }
247+
}
248+
}
249+
const entries: PluginEntry[] = body.plugins.map((spec) => ({ spec: spec.trim(), enabled: true }))
250+
await writeGlobalConfigPlugins(entries)
251+
252+
// Also update CodeNomad server config so workspaces pick up plugins on launch
253+
const rawConfig = deps.settings.getOwner("config", "server") ?? {}
254+
const existingEnvVars =
255+
typeof rawConfig === "object" && !Array.isArray(rawConfig) && rawConfig !== null
256+
? (rawConfig as Record<string, unknown>).environmentVariables
257+
: undefined
258+
const envVars =
259+
typeof existingEnvVars === "object" && !Array.isArray(existingEnvVars) && existingEnvVars !== null
260+
? { ...(existingEnvVars as Record<string, unknown>) }
261+
: {}
262+
envVars.OPENCODE_CONFIG_CONTENT = JSON.stringify({ plugin: entries.map((p) => p.spec) })
263+
if (entries.length === 0) {
264+
delete envVars.OPENCODE_CONFIG_CONTENT
265+
}
266+
deps.settings.mergePatchOwner("config", "server", { environmentVariables: envVars })
267+
268+
return { plugins: entries.map((p) => ({ spec: p.spec, enabled: p.enabled })) }
269+
} catch (error) {
270+
if (error instanceof z.ZodError) {
271+
reply.code(400)
272+
return { error: error.issues.map((i) => i.message).join("; ") }
273+
}
274+
reply.code(400)
275+
return { error: error instanceof Error ? error.message : "Invalid plugin config" }
276+
}
277+
})
130278
}

packages/ui/src/components/instance-service-status.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
22
import Switch from "@suid/material/Switch"
3+
import { Settings } from "lucide-solid"
34
import type { Instance, RawMcpStatus } from "../types/instance"
45
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
56
import { useI18n } from "../lib/i18n"
67
import { getLogger } from "../lib/logger"
8+
import { PluginManagerModal } from "./provider-auth/plugin-manager-modal"
79

810
const log = getLogger("session")
911

@@ -75,6 +77,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
7577

7678

7779
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
80+
const [pluginsModalOpen, setPluginsModalOpen] = createSignal(false)
7881

7982
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
8083
setPendingMcpActions((prev) => {
@@ -227,8 +230,30 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
227230
const renderPluginsSection = () => (
228231
<section class="space-y-1.5">
229232
<Show when={showHeadings()}>
230-
<div class="text-xs font-medium text-muted uppercase tracking-wide">
231-
{t("instanceServiceStatus.sections.plugins")}
233+
<div class="flex items-center justify-between">
234+
<div class="text-xs font-medium text-muted uppercase tracking-wide">
235+
{t("instanceServiceStatus.sections.plugins")}
236+
</div>
237+
<button
238+
type="button"
239+
class="text-[11px] text-secondary hover:text-primary transition-colors flex items-center gap-1"
240+
onClick={() => setPluginsModalOpen(true)}
241+
>
242+
<Settings class="w-3 h-3" />
243+
{t("instanceServiceStatus.plugins.manage")}
244+
</button>
245+
</div>
246+
</Show>
247+
<Show when={!showHeadings()}>
248+
<div class="flex items-center justify-end">
249+
<button
250+
type="button"
251+
class="text-[11px] text-secondary hover:text-primary transition-colors flex items-center gap-1"
252+
onClick={() => setPluginsModalOpen(true)}
253+
>
254+
<Settings class="w-3 h-3" />
255+
{t("instanceServiceStatus.plugins.manage")}
256+
</button>
232257
</div>
233258
</Show>
234259
<Show
@@ -253,6 +278,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
253278
<Show when={includeLsp()}>{renderLspSection()}</Show>
254279
<Show when={includeMcp()}>{renderMcpSection()}</Show>
255280
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
281+
<PluginManagerModal open={pluginsModalOpen()} onOpenChange={setPluginsModalOpen} />
256282
</div>
257283
)
258284
}

0 commit comments

Comments
 (0)