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