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]
