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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to