You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/24 14:42:30 UTC

(casbin-gateway) 01/02: feat(agent): detect opencode and switch its provider

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 831bb16e98333ddb553599a0161d0ffa7e3b6ff0
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Mon Aug 24 22:41:31 2026 +0800

    feat(agent): detect opencode and switch its provider
---
 agent/fingerprint.go                     |   1 +
 agent/fingerprint_load.go                |   2 +-
 agent/fingerprint_load_test.go           |   3 +-
 agent/fingerprints/opencode-desktop.json |   7 +
 agent/fingerprints/opencode.json         |   7 +
 agent/scan_darwin.go                     |   1 +
 agent/scan_linux.go                      |   1 +
 agent/scan_unix.go                       |  22 +++
 agent/scan_windows.go                    |   1 +
 agentconfig/layout.go                    |  26 ++++
 agentconfig/mcp_json.go                  |   9 ++
 agentconfig/mcp_opencode.go              | 117 +++++++++++++++
 agentpatch/unsupported.go                |   2 +-
 agentprovider/opencode.go                | 248 +++++++++++++++++++++++++++++++
 internal/jsonc/jsonc.go                  | 101 +++++++++++++
 web/src/components/AgentIcon.tsx         |   2 +
 web/src/lib/agents.ts                    |   3 +
 web/src/locales/en/data.json             |   1 +
 web/src/locales/zh/data.json             |   1 +
 19 files changed, 552 insertions(+), 3 deletions(-)

diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index a3ffd7e..3e4ce33 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -34,6 +34,7 @@ type Fingerprint struct {
 	DesktopInstallerDir string              `json:"desktopInstallerDir,omitempty"`
 	WindowsProgramDirs  []string            `json:"windowsProgramDirs,omitempty"`
 	WindowsUserDirs     []string            `json:"windowsUserDirs,omitempty"`
+	HomeDirs            []string            `json:"homeDirs,omitempty"`
 	HomebrewCasks       []string            `json:"homebrewCasks,omitempty"`
 	SystemPackage       string              `json:"systemPackage,omitempty"`
 	BuildInfoModule     string              `json:"buildInfoModule,omitempty"`
diff --git a/agent/fingerprint_load.go b/agent/fingerprint_load.go
index f4c8543..a26dac3 100644
--- a/agent/fingerprint_load.go
+++ b/agent/fingerprint_load.go
@@ -122,7 +122,7 @@ func hasScanSource(f Fingerprint) bool {
 	}
 	return f.StateDir != "" || f.NpmPackage != "" || f.WingetPackage != "" ||
 		f.MSIXFamily != "" || f.DesktopInstallerDir != "" ||
-		len(f.WindowsProgramDirs) != 0 || len(f.WindowsUserDirs) != 0 ||
+		len(f.WindowsProgramDirs) != 0 || len(f.WindowsUserDirs) != 0 || len(f.HomeDirs) != 0 ||
 		len(f.HomebrewCasks) != 0 || f.SystemPackage != ""
 }
 
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
index aed8072..96810b9 100644
--- a/agent/fingerprint_load_test.go
+++ b/agent/fingerprint_load_test.go
@@ -23,7 +23,8 @@ import (
 func TestFingerprintsLoad(t *testing.T) {
 	want := []string{
 		"claude-code", "claude-desktop", "codex-cli", "codex", "cursor-agent",
-		"cursor", "dsh", "openagent", "openclaw", "windsurf",
+		"cursor", "dsh", "openagent", "openclaw", "opencode-desktop", "opencode",
+		"windsurf",
 	}
 
 	loaded, err := loadFingerprints(fingerprintFS)
diff --git a/agent/fingerprints/opencode-desktop.json b/agent/fingerprints/opencode-desktop.json
new file mode 100644
index 0000000..c8ce666
--- /dev/null
+++ b/agent/fingerprints/opencode-desktop.json
@@ -0,0 +1,7 @@
+{
+  "id": "opencode-desktop",
+  "displayName": "opencode Desktop",
+  "execName": "OpenCode",
+  "desktop": true,
+  "windowsProgramDirs": ["@opencode-aidesktop"]
+}
diff --git a/agent/fingerprints/opencode.json b/agent/fingerprints/opencode.json
new file mode 100644
index 0000000..6a30483
--- /dev/null
+++ b/agent/fingerprints/opencode.json
@@ -0,0 +1,7 @@
+{
+  "id": "opencode",
+  "displayName": "opencode",
+  "execName": "opencode",
+  "npmPackage": "opencode-ai",
+  "homeDirs": [".opencode/bin"]
+}
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
index 1784344..4b76dab 100644
--- a/agent/scan_darwin.go
+++ b/agent/scan_darwin.go
@@ -42,6 +42,7 @@ func scan(ctx context.Context) []Installation {
 				return installations
 			}
 			installations = append(installations, scanNative(fingerprint, home)...)
+			installations = append(installations, scanHomeDirs(fingerprint, home)...)
 			installations = append(installations, scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path), home.owner, fileOwner)...)
 		}
 		for _, prefix := range []string{"/opt/homebrew", "/usr/local"} {
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
index 26b9921..ecb7bbc 100644
--- a/agent/scan_linux.go
+++ b/agent/scan_linux.go
@@ -44,6 +44,7 @@ func scan(ctx context.Context) []Installation {
 				return installations
 			}
 			installations = append(installations, scanNative(fingerprint, home)...)
+			installations = append(installations, scanHomeDirs(fingerprint, home)...)
 			installations = append(installations, scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path), home.owner, fileOwner)...)
 		}
 		installations = append(installations, scanSystemNpm(ctx, fingerprint)...)
diff --git a/agent/scan_unix.go b/agent/scan_unix.go
index 3e7e780..79b7498 100644
--- a/agent/scan_unix.go
+++ b/agent/scan_unix.go
@@ -55,6 +55,28 @@ func scanNative(fingerprint *Fingerprint, home homeDir) []Installation {
 	}}
 }
 
+// scanHomeDirs reports a launcher an agent's own installer script put in a
+// fixed directory under the home rather than in a shared bin directory.
+func scanHomeDirs(fingerprint *Fingerprint, home homeDir) []Installation {
+	if fingerprint.ExecName == "" {
+		return nil
+	}
+
+	var result []Installation
+	for _, dir := range fingerprint.HomeDirs {
+		launcher := filepath.Join(home.path, filepath.FromSlash(dir), fingerprint.ExecName)
+		info, err := os.Stat(launcher)
+		if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
+			continue
+		}
+		result = append(result, Installation{
+			Name: fingerprint.DisplayName, Path: launcher,
+			InstallMethod: "native", Owner: home.owner,
+		})
+	}
+	return result
+}
+
 // userNpmPatterns are the per-user Node package manager layouts that can hold a
 // globally installed agent package, plus any bundled runtime the agent declares.
 func userNpmPatterns(fingerprint *Fingerprint, home string) []string {
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
index 14db6e0..addf750 100644
--- a/agent/scan_windows.go
+++ b/agent/scan_windows.go
@@ -53,6 +53,7 @@ func scan(ctx context.Context) []Installation {
 			installations = append(installations, scanWindowsWinget(ctx, fingerprint, home)...)
 			installations = append(installations, scanWindowsNpm(ctx, fingerprint, home)...)
 			installations = append(installations, scanWindowsUserPrograms(ctx, fingerprint, home)...)
+			installations = append(installations, scanWindowsInstallDirs(ctx, fingerprint, home.path, fingerprint.HomeDirs, home.owner, "native")...)
 		}
 		installations = append(installations, scanWindowsDesktop(ctx, fingerprint, homes)...)
 		installations = append(installations, scanMachineWinget(ctx, fingerprint)...)
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index e1504f9..0ad1e30 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -179,6 +179,8 @@ var layouts = map[string]layout{
 			store: &jsonStore{paths: [][]string{{"mcp", "servers"}, {"mcp", "mcpServers"}, {"mcpServers"}}},
 		},
 	},
+	"opencode":         opencodeLayout,
+	"opencode-desktop": opencodeLayout,
 }
 
 var codexLayout = layout{
@@ -189,6 +191,16 @@ var codexLayout = layout{
 	mcp: &mcpLayout{file: under(".codex", "config.toml"), store: &tomlStore{table: "mcp_servers"}},
 }
 
+// The opencode CLI and its desktop app read one ~/.config/opencode, on every
+// platform, so the two ids share a layout.
+var opencodeLayout = layout{
+	skills: &skillLayout{sources: []skillSource{
+		userSkills(".config", "opencode", "skill"),
+		userSkills(".config", "opencode", "skills"),
+	}},
+	mcp: &mcpLayout{file: opencodeConfig, store: newOpencodeStore()},
+}
+
 var cursorLayout = layout{
 	skills: &skillLayout{sources: []skillSource{
 		userSkills(".cursor", "skills-cursor"),
@@ -226,6 +238,20 @@ func under(segments ...string) func(string) string {
 	}
 }
 
+// opencodeConfig is the config file to edit. opencode reads config.json,
+// opencode.json and opencode.jsonc in that order and merges them, so they are
+// tried here in reverse: the last one present is the one whose settings win. A
+// home with none of them gets the documented name.
+func opencodeConfig(home string) string {
+	dir := filepath.Join(home, ".config", "opencode")
+	for _, name := range []string{"opencode.jsonc", "opencode.json", "config.json"} {
+		if path := filepath.Join(dir, name); exists(path) {
+			return path
+		}
+	}
+	return filepath.Join(dir, "opencode.json")
+}
+
 func claudeDesktopConfig(home string) string {
 	switch runtime.GOOS {
 	case "windows":
diff --git a/agentconfig/mcp_json.go b/agentconfig/mcp_json.go
index e001845..5981e6c 100644
--- a/agentconfig/mcp_json.go
+++ b/agentconfig/mcp_json.go
@@ -19,6 +19,8 @@ import (
 	"fmt"
 	"os"
 	"strings"
+
+	"github.com/apache/casbin-gateway/internal/jsonc"
 )
 
 // jsonStore holds MCP servers in a JSON object somewhere in a JSON config file.
@@ -26,6 +28,10 @@ import (
 // the first is where a new entry goes when the file has none of them yet.
 type jsonStore struct {
 	paths [][]string
+	// relaxed reads a file whose agent accepts comments and trailing commas.
+	// Rewriting such a file drops the comments, which is the price of editing
+	// it at all.
+	relaxed bool
 }
 
 func (store *jsonStore) read(file string) (map[string]map[string]any, error) {
@@ -117,6 +123,9 @@ func (store *jsonStore) load(file string) (map[string]any, os.FileMode, bool, er
 	if len(strings.TrimSpace(string(data))) == 0 {
 		return map[string]any{}, mode, len(data) > 0, nil
 	}
+	if store.relaxed {
+		data = jsonc.Strip(data)
+	}
 
 	config := map[string]any{}
 	if err := json.Unmarshal(data, &config); err != nil {
diff --git a/agentconfig/mcp_opencode.go b/agentconfig/mcp_opencode.go
new file mode 100644
index 0000000..65c67bc
--- /dev/null
+++ b/agentconfig/mcp_opencode.go
@@ -0,0 +1,117 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentconfig
+
+import (
+	"errors"
+	"fmt"
+)
+
+// opencodeStore holds MCP servers in the "mcp" object of opencode's config.
+// opencode spells an entry its own way: the transport is always named, a local
+// server's command and arguments are one list, and its environment is called
+// something else. Entries are translated on the way in and back on the way out,
+// so a server copied from another agent arrives in a shape opencode reads.
+type opencodeStore struct {
+	json jsonStore
+}
+
+func newOpencodeStore() *opencodeStore {
+	return &opencodeStore{json: jsonStore{paths: [][]string{{"mcp"}}, relaxed: true}}
+}
+
+func (store *opencodeStore) read(file string) (map[string]map[string]any, error) {
+	entries, err := store.json.read(file)
+	if err != nil {
+		return nil, err
+	}
+	for name, entry := range entries {
+		entries[name] = opencodeCommonEntry(entry)
+	}
+	return entries, nil
+}
+
+func (store *opencodeStore) write(file string, name string, entry map[string]any) error {
+	converted, err := opencodeEntry(entry)
+	if err != nil {
+		return err
+	}
+	return store.json.write(file, name, converted)
+}
+
+func (store *opencodeStore) remove(file string, name string) error {
+	return store.json.remove(file, name)
+}
+
+// opencodeEntry is one server as opencode's config spells it.
+func opencodeEntry(entry map[string]any) (map[string]any, error) {
+	if entry == nil {
+		return nil, errors.New("the definition of this MCP server is empty")
+	}
+
+	result := map[string]any{"enabled": true}
+	if transportOf(entry) == TransportStdio {
+		command := stringField(entry, "command")
+		if command == "" {
+			return nil, errors.New("a local MCP server needs a command to run")
+		}
+		arguments, _ := entry["args"].([]any)
+		result["type"] = "local"
+		result["command"] = append([]any{command}, arguments...)
+		if environment, ok := entry["env"].(map[string]any); ok && len(environment) > 0 {
+			result["environment"] = environment
+		}
+		return result, nil
+	}
+
+	url := stringField(entry, "url", "serverUrl", "endpoint")
+	if url == "" {
+		return nil, errors.New("a remote MCP server needs a URL")
+	}
+	result["type"] = "remote"
+	result["url"] = url
+	if headers, ok := entry["headers"].(map[string]any); ok && len(headers) > 0 {
+		result["headers"] = headers
+	}
+	return result, nil
+}
+
+// opencodeCommonEntry is the same server in the spelling every other agent
+// shares, which is what a listing and a copy to another agent read.
+func opencodeCommonEntry(entry map[string]any) map[string]any {
+	result := map[string]any{}
+	for key, value := range entry {
+		result[key] = value
+	}
+
+	switch stringField(entry, "type") {
+	case "local":
+		result["type"] = TransportStdio
+		delete(result, "command")
+		if command, ok := entry["command"].([]any); ok && len(command) > 0 {
+			result["command"] = fmt.Sprint(command[0])
+			if len(command) > 1 {
+				result["args"] = command[1:]
+			}
+		}
+		if environment, ok := result["environment"]; ok {
+			result["env"] = environment
+			delete(result, "environment")
+		}
+	case "remote":
+		result["type"] = TransportHttp
+	}
+	return result
+}
diff --git a/agentpatch/unsupported.go b/agentpatch/unsupported.go
index 81d9155..aea890b 100644
--- a/agentpatch/unsupported.go
+++ b/agentpatch/unsupported.go
@@ -15,7 +15,7 @@
 package agentpatch
 
 func init() {
-	for _, id := range []string{"cursor", "cursor-agent", "windsurf"} {
+	for _, id := range []string{"cursor", "cursor-agent", "windsurf", "opencode", "opencode-desktop"} {
 		register(unimplemented{id: id})
 	}
 }
diff --git a/agentprovider/opencode.go b/agentprovider/opencode.go
new file mode 100644
index 0000000..0e37276
--- /dev/null
+++ b/agentprovider/opencode.go
@@ -0,0 +1,248 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/apache/casbin-gateway/agentconfig"
+	"github.com/apache/casbin-gateway/agenthome"
+	"github.com/apache/casbin-gateway/internal/jsonc"
+)
+
+const (
+	// opencodeProvider is the provider entry Gateway owns in opencode.json.
+	// Every other provider in the file is left alone.
+	opencodeProvider = "casbin-gateway"
+	// opencodeNpm is the AI SDK package opencode loads for an endpoint that is
+	// not one of the services it ships, which is what the gateway is.
+	opencodeNpm = "@ai-sdk/openai-compatible"
+)
+
+// The root keys Gateway owns, remembered under these names.
+const (
+	opencodeModelKey      = "model"
+	opencodeSmallModelKey = "small_model"
+)
+
+// errOpencodeNoKey rejects a provider that forwards the caller's own
+// credentials: the key written here is the only one this provider entry sends.
+var errOpencodeNoKey = errors.New("opencode needs an API key, so it cannot use a provider that forwards the credentials of the caller")
+
+// errOpencodeNoModel rejects a provider entry opencode could not use: an
+// endpoint it does not know has no model catalog of its own.
+var errOpencodeNoModel = errors.New("opencode needs a model name, so bind a provider that lists at least one model")
+
+type opencodeWriter struct {
+	id string
+}
+
+func init() {
+	register(opencodeWriter{id: "opencode"})
+	// The desktop app drives the same agent, and with it the same config file.
+	register(opencodeWriter{id: "opencode-desktop"})
+}
+
+func (w opencodeWriter) AgentId() string { return w.id }
+
+func (opencodeWriter) Protocol() string { return "openai" }
+
+func (w opencodeWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+	if err := w.check(endpoint); err != nil {
+		return nil, err
+	}
+	path, err := w.configPath(target)
+	if err != nil {
+		return nil, err
+	}
+
+	preview := endpoint
+	preview.ApiKey = maskSecret(endpoint.ApiKey)
+	data, err := encodeJSON(map[string]any{
+		"provider": map[string]any{opencodeProvider: w.provider(preview)},
+		"model":    w.model(endpoint),
+	})
+	if err != nil {
+		return nil, err
+	}
+	return []File{{Path: path, Format: "json", Preview: string(data)}}, nil
+}
+
+func (w opencodeWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) {
+	if err := w.check(endpoint); err != nil {
+		return nil, err
+	}
+	path, err := w.configPath(target)
+	if err != nil {
+		return nil, err
+	}
+	config, err := w.load(path)
+	if err != nil {
+		return nil, err
+	}
+
+	previous := map[string]string{}
+	for _, key := range []string{opencodeModelKey, opencodeSmallModelKey} {
+		if value := stringAt(config, key); value != "" {
+			previous[key] = value
+		}
+	}
+
+	providers := objectAt(config, "provider")
+	if providers == nil {
+		providers = map[string]any{}
+	}
+	providers[opencodeProvider] = w.provider(endpoint)
+	config["provider"] = providers
+	config[opencodeModelKey] = w.model(endpoint)
+	// Without one, opencode uses the model above for its own small requests
+	// too; a leftover selection would send them to another provider.
+	delete(config, opencodeSmallModelKey)
+
+	return previous, w.save(path, config)
+}
+
+func (w opencodeWriter) Restore(target Target, previous map[string]string) error {
+	path, err := w.configPath(target)
+	if err != nil {
+		return err
+	}
+	config, err := w.load(path)
+	if err != nil {
+		return err
+	}
+
+	if providers := objectAt(config, "provider"); providers != nil {
+		delete(providers, opencodeProvider)
+		if len(providers) == 0 {
+			delete(config, "provider")
+		}
+	}
+	for _, key := range []string{opencodeModelKey, opencodeSmallModelKey} {
+		if value, ok := previous[key]; ok {
+			config[key] = value
+		} else {
+			delete(config, key)
+		}
+	}
+	return w.save(path, config)
+}
+
+func (w opencodeWriter) Current(target Target) (string, error) {
+	path, err := w.configPath(target)
+	if err != nil {
+		return "", err
+	}
+	config, err := w.load(path)
+	if err != nil {
+		return "", err
+	}
+
+	selected, _, _ := strings.Cut(stringAt(config, opencodeModelKey), "/")
+	if selected == "" {
+		return "", nil
+	}
+	provider := objectAt(objectAt(config, "provider"), selected)
+	if baseUrl := stringAt(objectAt(provider, "options"), "baseURL"); baseUrl != "" {
+		return baseUrl, nil
+	}
+	// A provider without a base URL is one opencode knows itself, which is
+	// still worth naming: it is not the endpoint Gateway wrote.
+	return selected, nil
+}
+
+// check reports why opencode cannot be pointed at endpoint.
+func (opencodeWriter) check(endpoint Endpoint) error {
+	if endpoint.ApiKey == "" {
+		return errOpencodeNoKey
+	}
+	if endpoint.Model == "" {
+		return errOpencodeNoModel
+	}
+	return nil
+}
+
+// provider is the entry opencode loads the endpoint through. opencode ships no
+// catalog for a provider it does not know, so the entry carries the whole of
+// it: the SDK package, the endpoint, the key and the models.
+func (opencodeWriter) provider(endpoint Endpoint) map[string]any {
+	return map[string]any{
+		"name": "Casbin Gateway",
+		"npm":  opencodeNpm,
+		"options": map[string]any{
+			"baseURL": endpoint.BaseUrl,
+			"apiKey":  endpoint.ApiKey,
+		},
+		"models": map[string]any{endpoint.Model: map[string]any{"name": endpoint.Model}},
+	}
+}
+
+// model is how opencode names one model: by the provider it is served by.
+func (opencodeWriter) model(endpoint Endpoint) string {
+	return opencodeProvider + "/" + endpoint.Model
+}
+
+// load reads the config file. opencode accepts comments and trailing commas in
+// it, so a file that has them is read rather than refused; saving writes plain
+// JSON, which is what editing the file this way costs.
+func (opencodeWriter) load(path string) (map[string]any, error) {
+	data, _, _, err := readFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	config := map[string]any{}
+	if strings.TrimSpace(string(data)) == "" {
+		return config, nil
+	}
+	if err := json.Unmarshal(jsonc.Strip(data), &config); err != nil {
+		return nil, fmt.Errorf("parse %s: %w", path, err)
+	}
+	if config == nil {
+		return nil, fmt.Errorf("parse %s: the root must be a JSON object", path)
+	}
+	return config, nil
+}
+
+func (opencodeWriter) save(path string, config map[string]any) error {
+	data, err := encodeJSON(config)
+	if err != nil {
+		return err
+	}
+
+	changes := &txn{}
+	if err := changes.write(path, data); err != nil {
+		changes.abort()
+		return err
+	}
+	return changes.commit()
+}
+
+// configPath is the file opencode reads its settings from, which is the same
+// one its MCP servers are listed in.
+func (w opencodeWriter) configPath(target Target) (string, error) {
+	home, err := agenthome.Resolve(target.Owner)
+	if err != nil {
+		return "", err
+	}
+	path, ok := agentconfig.McpConfigPath(w.id, home)
+	if !ok {
+		return "", fmt.Errorf("no configuration layout for %s", w.id)
+	}
+	return path, nil
+}
diff --git a/internal/jsonc/jsonc.go b/internal/jsonc/jsonc.go
new file mode 100644
index 0000000..7788aa6
--- /dev/null
+++ b/internal/jsonc/jsonc.go
@@ -0,0 +1,101 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package jsonc reads the relaxed JSON some agents accept in their config
+// files: comments and trailing commas, which encoding/json rejects.
+package jsonc
+
+// Strip returns data as plain JSON, blanking comments and dropping trailing
+// commas. Byte offsets are preserved, so a parse error still points at the
+// place in the file the operator sees.
+func Strip(data []byte) []byte {
+	result := make([]byte, len(data))
+	copy(result, data)
+
+	// The last comma seen outside a string, to be dropped if the next thing is
+	// a closing bracket rather than another value.
+	comma := -1
+	for i := 0; i < len(result); i++ {
+		switch result[i] {
+		case '"':
+			i = skipString(result, i)
+			comma = -1
+		case '/':
+			if end := skipComment(result, i); end > i {
+				blank(result, i, end)
+				i = end - 1
+			}
+		case ',':
+			comma = i
+		case '}', ']':
+			if comma >= 0 {
+				result[comma] = ' '
+			}
+			comma = -1
+		case ' ', '\t', '\r', '\n':
+		default:
+			comma = -1
+		}
+	}
+	return result
+}
+
+// skipString returns the index of the closing quote of the string starting at
+// start, or the end of the data when it is unterminated.
+func skipString(data []byte, start int) int {
+	for i := start + 1; i < len(data); i++ {
+		switch data[i] {
+		case '\\':
+			i++
+		case '"':
+			return i
+		}
+	}
+	return len(data)
+}
+
+// skipComment returns the index just past the comment starting at start, or
+// start when nothing there is a comment.
+func skipComment(data []byte, start int) int {
+	if start+1 >= len(data) {
+		return start
+	}
+	switch data[start+1] {
+	case '/':
+		for i := start + 2; i < len(data); i++ {
+			if data[i] == '\n' {
+				return i
+			}
+		}
+		return len(data)
+	case '*':
+		for i := start + 2; i+1 < len(data); i++ {
+			if data[i] == '*' && data[i+1] == '/' {
+				return i + 2
+			}
+		}
+		return len(data)
+	}
+	return start
+}
+
+// blank replaces a comment with spaces, keeping the newlines so the line
+// numbers of everything after it do not move.
+func blank(data []byte, start, end int) {
+	for i := start; i < end; i++ {
+		if data[i] != '\n' {
+			data[i] = ' '
+		}
+	}
+}
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index eb62fd7..1a1e9cc 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -26,6 +26,8 @@ const agentSites: Record<string, string> = {
   "Cursor Agent": "cursor.com",
   Windsurf: "windsurf.com",
   OpenAgent: "openagentai.org",
+  opencode: "opencode.ai",
+  "opencode Desktop": "opencode.ai",
   "DeepSeek Harness": "deepseek.com",
 };
 
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index fd558b3..77b78dc 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -61,6 +61,9 @@ export function agentSetupNoteKey(agentId: string) {
     return "agent:Claude Code config hint";
   case "codex-cli":
     return "agent:Codex CLI config hint";
+  case "opencode":
+  case "opencode-desktop":
+    return "agent:opencode config hint";
   default:
     return "";
   }
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 974c25b..1dbcba5 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -153,6 +153,7 @@
     "Write confirm hint": "Gateway writes the selected provider into this agent's own configuration file, replacing the file in one step. Restart any session that is already running.",
     "Write the configuration of {agent}?": "Write the configuration of {agent}?",
     "Written by Gateway": "Written by Gateway",
+    "opencode config hint": "opencode reads no base URL from the environment: the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, loaded through @ai-sdk/openai-compatible and selected with \"model\": \"<provider>/<model>\".",
     "{count} messages": "{count} messages"
   },
   "agentConfig": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 6cfef02..b784b39 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -153,6 +153,7 @@
     "Write confirm hint": "Gateway 会把所选供应商以该 Agent 自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
     "Write the configuration of {agent}?": "写入 {agent} 的配置?",
     "Written by Gateway": "已由 Gateway 写入",
+    "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 ~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 @ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。",
     "{count} messages": "{count} 条消息"
   },
   "agentConfig": {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org