Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/server/src/config/jsonc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export function stripJsonc(input: string): string {
let output = ""
let inString = false
let escape = false

for (let index = 0; index < input.length; index += 1) {
const char = input[index]
const next = input[index + 1] as string | undefined

if (escape) {
output += char
escape = false
continue
}

if (char === "\\" && inString) {
output += char
escape = true
continue
}

if (char === '"') {
output += char
inString = !inString
continue
}

if (!inString && char === "/" && next === "/") {
while (index < input.length && input[index] !== "\n") {
index += 1
}
output += "\n"
continue
}

if (!inString && char === "/" && next === "*") {
index += 2
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) {
output += input[index] === "\n" ? "\n" : ""
index += 1
}
index += 1
continue
}

if (!inString && char === ",") {
let lookahead = index + 1
while (lookahead < input.length && /\s/.test(input[lookahead]!)) {
lookahead += 1
}
if (input[lookahead] === "}" || input[lookahead] === "]") {
continue
}
}

output += char
}

return output
}
62 changes: 1 addition & 61 deletions packages/server/src/opencode-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readdirSync } from "fs"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { createLogger } from "./logger"
import { stripJsonc } from "./config/jsonc"

const log = createLogger({ component: "opencode-plugin" })
const pluginPackageName = "@codenomad/codenomad-opencode-plugin"
Expand Down Expand Up @@ -112,64 +113,3 @@ function normalizePluginEntries(value: unknown): string[] {
}
throw new Error("OPENCODE_CONFIG_CONTENT plugin field must be a string or string array")
}

function stripJsonc(input: string): string {
let output = ""
let inString = false
let escape = false

for (let index = 0; index < input.length; index += 1) {
const char = input[index]
const next = input[index + 1]

if (escape) {
output += char
escape = false
continue
}

if (char === "\\" && inString) {
output += char
escape = true
continue
}

if (char === '"') {
output += char
inString = !inString
continue
}

if (!inString && char === "/" && next === "/") {
while (index < input.length && input[index] !== "\n") {
index += 1
}
output += "\n"
continue
}

if (!inString && char === "/" && next === "*") {
index += 2
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) {
output += input[index] === "\n" ? "\n" : ""
index += 1
}
index += 1
continue
}

if (!inString && char === ",") {
let lookahead = index + 1
while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1
}
if (input[lookahead] === "}" || input[lookahead] === "]") {
continue
}
}

output += char
}

return output
}
148 changes: 148 additions & 0 deletions packages/server/src/server/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { readFile, writeFile } from "fs/promises"
import { existsSync } from "fs"
import path from "path"
import os from "os"
import { probeBinaryVersion } from "../../workspaces/spawn"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
import { stripJsonc } from "../../config/jsonc"

interface RouteDeps {
settings: SettingsService
Expand All @@ -14,6 +19,11 @@ const ValidateBinarySchema = z.object({
path: z.string(),
})

const PluginEntrySchema = z.string().min(1)
const UpdatePluginConfigSchema = z.object({
plugins: z.array(PluginEntrySchema),
})

function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.error }
Expand Down Expand Up @@ -49,6 +59,95 @@ export function enforceSpeechCredentialPairing(body: unknown, currentSpeech?: un
return patch
}

function safeJsonParse(raw: string): Record<string, unknown> | null {
try {
const cleaned = stripJsonc(raw).replace(/,\s*}/g, "}").replace(/,\s*\]/g, "]")
const parsed = JSON.parse(cleaned)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
return null
} catch {
return null
}
}

type PluginEntry = { spec: string; enabled: boolean }

function normalizePluginEntry(entry: unknown): PluginEntry | null {
if (typeof entry === "string" && entry.trim().length > 0) {
return { spec: entry.trim(), enabled: true }
}
if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && entry[0].trim().length > 0) {
const opts = entry[1] as Record<string, unknown> | null | undefined
const enabled = opts && typeof opts === "object" && "enabled" in opts ? opts.enabled !== false : true
return { spec: entry[0].trim(), enabled }
}
return null
}

function readPluginsFromConfig(config: Record<string, unknown>): PluginEntry[] {
const raw = config.plugin
if (!Array.isArray(raw)) return []
return raw.map(normalizePluginEntry).filter((entry): entry is PluginEntry => entry !== null)
}

function getGlobalConfigPath(): string {
return path.join(os.homedir(), ".config", "opencode", "opencode.jsonc")
}

async function readGlobalConfigPlugins(): Promise<PluginEntry[]> {
const configPath = getGlobalConfigPath()
if (!existsSync(configPath)) return []
try {
const raw = await readFile(configPath, "utf-8")
const config = safeJsonParse(raw)
if (!config) return []
return readPluginsFromConfig(config)
} catch {
return []
}
}

async function writeGlobalConfigPlugins(entries: PluginEntry[]): Promise<void> {
const configPath = getGlobalConfigPath()
let config: Record<string, unknown> = {}
if (existsSync(configPath)) {
try {
const raw = await readFile(configPath, "utf-8")
config = safeJsonParse(raw) ?? {}
} catch { /* use empty config */ }
}

const serialized: unknown[] = entries.map((entry) => {
if (entry.enabled) return entry.spec
return [entry.spec, { enabled: false }]
})

if (serialized.length === 0) {
delete config.plugin
} else {
config.plugin = serialized
}

config["$schema"] = config["$schema"] ?? "https://opencode.ai/config.json"
const dir = path.dirname(configPath)
const { mkdir } = await import("fs/promises")
await mkdir(dir, { recursive: true })
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
}

function isValidPluginSpec(spec: string): boolean {
return (
spec.startsWith("npm:") ||
spec.startsWith("file://") ||
spec.startsWith("https://") ||
spec.startsWith("http://") ||
spec.startsWith("/") ||
spec.startsWith(".")
)
}

export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
Expand Down Expand Up @@ -127,4 +226,53 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
}
})

// Plugin config management — reads/writes the global opencode.jsonc plugin array
app.get("/api/opencode-plugin-config", async () => {
try {
const plugins = await readGlobalConfigPlugins()
return { plugins: plugins.map((p) => ({ spec: p.spec, enabled: p.enabled })) }
} catch {
return { plugins: [] }
}
})

app.put("/api/opencode-plugin-config", async (request, reply) => {
try {
const body = UpdatePluginConfigSchema.parse(request.body ?? {})
for (const spec of body.plugins) {
if (!isValidPluginSpec(spec.trim())) {
reply.code(400)
return { error: `Invalid plugin spec: "${spec}". Must start with npm:, file://, https://, /, or .` }
}
}
const entries: PluginEntry[] = body.plugins.map((spec) => ({ spec: spec.trim(), enabled: true }))
await writeGlobalConfigPlugins(entries)

// Also update CodeNomad server config so workspaces pick up plugins on launch
const rawConfig = deps.settings.getOwner("config", "server") ?? {}
const existingEnvVars =
typeof rawConfig === "object" && !Array.isArray(rawConfig) && rawConfig !== null
? (rawConfig as Record<string, unknown>).environmentVariables
: undefined
const envVars =
typeof existingEnvVars === "object" && !Array.isArray(existingEnvVars) && existingEnvVars !== null
? { ...(existingEnvVars as Record<string, unknown>) }
: {}
envVars.OPENCODE_CONFIG_CONTENT = JSON.stringify({ plugin: entries.map((p) => p.spec) })
if (entries.length === 0) {
delete envVars.OPENCODE_CONFIG_CONTENT
}
deps.settings.mergePatchOwner("config", "server", { environmentVariables: envVars })

return { plugins: entries.map((p) => ({ spec: p.spec, enabled: p.enabled })) }
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400)
return { error: error.issues.map((i) => i.message).join("; ") }
}
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid plugin config" }
}
})
}
30 changes: 28 additions & 2 deletions packages/ui/src/components/instance-service-status.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch"
import { Settings } from "lucide-solid"
import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { PluginManagerModal } from "./provider-auth/plugin-manager-modal"

const log = getLogger("session")

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


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

const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
setPendingMcpActions((prev) => {
Expand Down Expand Up @@ -227,8 +230,30 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
const renderPluginsSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
{t("instanceServiceStatus.sections.plugins")}
<div class="flex items-center justify-between">
<div class="text-xs font-medium text-muted uppercase tracking-wide">
{t("instanceServiceStatus.sections.plugins")}
</div>
<button
type="button"
class="text-[11px] text-secondary hover:text-primary transition-colors flex items-center gap-1"
onClick={() => setPluginsModalOpen(true)}
>
<Settings class="w-3 h-3" />
{t("instanceServiceStatus.plugins.manage")}
</button>
</div>
</Show>
<Show when={!showHeadings()}>
<div class="flex items-center justify-end">
<button
type="button"
class="text-[11px] text-secondary hover:text-primary transition-colors flex items-center gap-1"
onClick={() => setPluginsModalOpen(true)}
>
<Settings class="w-3 h-3" />
{t("instanceServiceStatus.plugins.manage")}
</button>
</div>
</Show>
<Show
Expand All @@ -253,6 +278,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
<PluginManagerModal open={pluginsModalOpen()} onOpenChange={setPluginsModalOpen} />
</div>
)
}
Expand Down
Loading
Loading