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
The following commit(s) were added to refs/heads/master by this push:
new 9024917 feat(import): claim ccswitch:// links and import MCP, prompts
and skills
9024917 is described below
commit 9024917849dccb46152d2ee3421d468ff77c36a1
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 14:50:58 2026 +0800
feat(import): claim ccswitch:// links and import MCP, prompts and skills
---
controllers/import_link.go | 102 +++++++
desktop/api.go | 10 +
desktop/deeplink.go | 110 ++++++++
desktop/main.go | 11 +
desktop/scheme_linux.go | 98 +++++++
desktop/scheme_other.go | 27 ++
desktop/scheme_windows.go | 151 ++++++++++
desktop/shortcut.go | 14 +-
desktop/shortcut_linux.go | 8 +-
desktop/tray.go | 16 +-
object/import_link.go | 312 +++++++++++++++++++++
object/import_link_pending.go | 65 +++++
object/provider_link.go | 33 +--
routers/router.go | 6 +
web/src/App.tsx | 2 +
web/src/backend/ImportLinkBackend.ts | 29 ++
web/src/nav.ts | 4 +
web/src/pages/ImportLinkPage.tsx | 514 +++++++++++++++++++++++++++++++++++
web/src/pages/ProviderListPage.tsx | 36 ++-
web/src/types.ts | 36 +++
20 files changed, 1548 insertions(+), 36 deletions(-)
diff --git a/controllers/import_link.go b/controllers/import_link.go
new file mode 100644
index 0000000..82abd6a
--- /dev/null
+++ b/controllers/import_link.go
@@ -0,0 +1,102 @@
+// 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 controllers
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/casbin-gateway/object"
+)
+
+// ParseImportLink reads a vendor's "add this to Gateway" link and answers with
+// what it would fill in, without storing anything: the link comes from a
+// website, so the person importing it sees the values first.
+func (c *ApiController) ParseImportLink() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ Link string `json:"link"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ link, err := object.ParseImportLink(c.GetSessionUsername(), form.Link)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(link)
+}
+
+// OpenImportLink is how a link that was clicked outside the browser reaches
the
+// page. The URL scheme handler runs as a program on this machine and hands the
+// link over here rather than putting it in the address of the page it opens:
+// a provider link carries an API key, and an address is kept in the browser's
+// history and sent to whatever the page later navigates to.
+func (c *ApiController) OpenImportLink() {
+ if c.RequireLocalAdmin() {
+ return
+ }
+
+ var form struct {
+ Link string `json:"link"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ raw := strings.TrimSpace(form.Link)
+ if raw == "" || len(raw) > object.MaxImportLinkLength {
+ c.ResponseError("this is not a link Gateway can import")
+ return
+ }
+ // Read once here so that a link nothing can be made of is reported to
+ // whoever clicked it, rather than opening a page that says so.
+ if _, err := object.ParseImportLink(c.GetSessionUsername(), raw); err
!= nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ object.SetPendingImportLink(raw)
+ c.ResponseOk()
+}
+
+// GetPendingImportLink is what the import page asks for on the way in: the
link
+// the scheme handler left, read and then forgotten, so that reloading the page
+// cannot import the same link a second time.
+func (c *ApiController) GetPendingImportLink() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ raw := object.TakePendingImportLink()
+ if raw == "" {
+ c.ResponseOk(nil)
+ return
+ }
+
+ link, err := object.ParseImportLink(c.GetSessionUsername(), raw)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(link)
+}
diff --git a/desktop/api.go b/desktop/api.go
index d1a20b9..b81a70f 100644
--- a/desktop/api.go
+++ b/desktop/api.go
@@ -63,6 +63,16 @@ func fetchTrayMenu() (*trayMenu, error) {
return menu, nil
}
+// openImportLinkOnServer leaves a clicked link with the server for the import
+// page to pick up.
+func openImportLinkOnServer(link string) error {
+ body, err := json.Marshal(map[string]string{"link": link})
+ if err != nil {
+ return err
+ }
+ return callApi(http.MethodPost, "/api/open-import-link", body, nil)
+}
+
func setAgentProvider(agentId string, providerId string) error {
body, err := json.Marshal(map[string]string{"agentId": agentId,
"provider": providerId})
if err != nil {
diff --git a/desktop/deeplink.go b/desktop/deeplink.go
new file mode 100644
index 0000000..eebc32f
--- /dev/null
+++ b/desktop/deeplink.go
@@ -0,0 +1,110 @@
+// 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 main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// importScheme is the URL scheme a vendor's "add this to my agent manager"
+// button opens. It is CC Switch's, because that is the one vendors already
+// publish buttons for, and holding it is what makes those buttons work here.
+const importScheme = "ccswitch"
+
+// openLinkSubcommand is how this executable is invoked as the handler of one
+// such link.
+const openLinkSubcommand = "open-link"
+
+// importPagePath is the page that shows what a link carries and asks whether
+// to keep it.
+const importPagePath = "/import"
+
+// maxLinkLength mirrors object.MaxImportLinkLength on the server: what arrives
+// here is a URL a browser handed to a command line, and nothing longer.
+const maxLinkLength = 8192
+
+func isImportLink(argument string) bool {
+ return strings.HasPrefix(strings.ToLower(argument), importScheme+"://")
+}
+
+// importLinkArg is the link this process was started to open, if it was. The
+// Windows registration names the subcommand before the URL; a Linux desktop
+// entry started with %u passes the URL on its own, so both shapes arrive here.
+func importLinkArg(args []string) string {
+ if len(args) > 2 && args[1] == openLinkSubcommand {
+ return args[2]
+ }
+ if len(args) > 1 && isImportLink(args[1]) {
+ return args[1]
+ }
+ return ""
+}
+
+// openImportLink hands one clicked link to the Gateway server and opens the
+// page that asks what to do with it.
+//
+// The link travels in the body of an API call rather than in the address of
+// that page: a provider link carries an API key, and an address is kept in
+// history and handed on to wherever the page navigates next.
+func openImportLink(link string) error {
+ if !isImportLink(link) || len(link) > maxLinkLength {
+ return fmt.Errorf("%q is not a link Gateway can import", link)
+ }
+
+ if _, err := startServer(httpPort()); err != nil {
+ return err
+ }
+ if err := openImportLinkOnServer(link); err != nil {
+ return err
+ }
+ return showImportPage()
+}
+
+// showImportPage opens a window on the import page, and the default browser
+// where no window could be created. It is a window of its own: the browser
+// started this process, which has no hold on the one the tray may already have
+// open.
+func showImportPage() error {
+ address := gatewayUrl() + importPagePath
+
+ executable, err := os.Executable()
+ if err == nil {
+ cmd := exec.Command(executable, "window", address)
+ cmd.Dir = gatewayHome()
+ hideConsole(cmd)
+ if err = cmd.Run(); err == nil {
+ return nil
+ }
+ }
+ return openBrowser(address)
+}
+
+// schemeMarker records that Gateway has claimed the scheme once, and holds the
+// command it took it from. CC Switch hands out these same links and may
already
+// hold the scheme here; taking it is a thing to do once, on the way in, and to
+// undo on the way out - not something to redo behind someone who has pointed
+// the scheme back at whatever they prefer.
+func schemeMarker() string {
+ return filepath.Join(gatewayHome(), ".scheme-"+importScheme)
+}
+
+func claimedScheme() bool {
+ _, err := os.Stat(schemeMarker())
+ return err == nil
+}
diff --git a/desktop/main.go b/desktop/main.go
index d3fedba..a53df95 100644
--- a/desktop/main.go
+++ b/desktop/main.go
@@ -70,6 +70,17 @@ func main() {
return
}
+ // A "ccswitch://" link the browser was asked to open Gateway with. It
is
+ // routed to the running server and shown on a page before anything of
it
+ // is kept, because a link like this arrives from a vendor's website.
+ if link := importLinkArg(os.Args); link != "" {
+ if err := openImportLink(link); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ os.Exit(1)
+ }
+ return
+ }
+
if len(os.Args) > 1 && os.Args[1] == "window" {
url := ""
if len(os.Args) > 2 {
diff --git a/desktop/scheme_linux.go b/desktop/scheme_linux.go
new file mode 100644
index 0000000..9b47948
--- /dev/null
+++ b/desktop/scheme_linux.go
@@ -0,0 +1,98 @@
+// 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.
+
+//go:build linux
+
+package main
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// schemeMimeType is how a URL scheme is named to the desktop.
+const schemeMimeType = "x-scheme-handler/" + importScheme
+
+// registerScheme points "ccswitch://" links at the desktop entry, which is
+// what declares the handler here — the entry carries the MimeType line and the
+// %u that passes the link on. So there is nothing to register where the entry
+// is gone: whoever deleted the shortcut is not asking for it back.
+//
+// Like on Windows, the scheme is taken from whatever held it only the first
+// time, and what it replaced is kept for unregisterScheme to put back.
+func registerScheme() error {
+ entry := appId + ".desktop"
+
+ dataDir := xdgDataHome()
+ if dataDir == "" {
+ return nil
+ }
+ applications := filepath.Join(dataDir, "applications")
+ if _, err := os.Stat(filepath.Join(applications, entry)); err != nil {
+ return nil
+ }
+ // The MimeType line is read from the index, which a hand-unpacked
archive
+ // writing the entry itself has not rebuilt. Best-effort: the
association
+ // below is written either way, and desktops that keep no index have no
+ // command for it.
+ _ = exec.Command("update-desktop-database", applications).Run()
+
+ registered := schemeDefault()
+ switch {
+ case registered == entry:
+ return nil
+ case claimedScheme():
+ // Ours once, and not ours now: the scheme was given back on
purpose.
+ return nil
+ }
+
+ if err := exec.Command("xdg-mime", "default", entry,
schemeMimeType).Run(); err != nil {
+ return err
+ }
+ return os.WriteFile(schemeMarker(), []byte(registered), 0o644)
+}
+
+// unregisterScheme hands the scheme back to whatever held it before Gateway.
+// Where nothing did there is nothing to undo: removing the desktop entry is
+// what takes the handler away.
+func unregisterScheme() error {
+ replaced, err := os.ReadFile(schemeMarker())
+ if err != nil {
+ return nil
+ }
+
+ if schemeDefault() == appId+".desktop" {
+ if previous := strings.TrimSpace(string(replaced)); previous !=
"" {
+ if err := exec.Command("xdg-mime", "default", previous,
schemeMimeType).Run(); err != nil {
+ return err
+ }
+ }
+ }
+ if err := os.Remove(schemeMarker()); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+// schemeDefault is the desktop entry that opens these links now, empty where
+// nothing does or where the desktop has no xdg-mime to ask.
+func schemeDefault() string {
+ out, err := exec.Command("xdg-mime", "query", "default",
schemeMimeType).Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
diff --git a/desktop/scheme_other.go b/desktop/scheme_other.go
new file mode 100644
index 0000000..dc10da6
--- /dev/null
+++ b/desktop/scheme_other.go
@@ -0,0 +1,27 @@
+// 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.
+
+//go:build !windows && !linux
+
+package main
+
+// On macOS a URL scheme belongs to an application bundle, and LaunchServices
+// delivers the link as an Apple Event rather than as an argument — which this
+// launcher has no event loop to receive. Declaring the scheme would therefore
+// take links away from whatever can open them and drop them. So nothing is
+// claimed here, and a link is imported by pasting it into the import page.
+
+func registerScheme() error { return nil }
+
+func unregisterScheme() error { return nil }
diff --git a/desktop/scheme_windows.go b/desktop/scheme_windows.go
new file mode 100644
index 0000000..57e9ebe
--- /dev/null
+++ b/desktop/scheme_windows.go
@@ -0,0 +1,151 @@
+// 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.
+
+//go:build windows
+
+package main
+
+import (
+ "errors"
+ "os"
+ "strings"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+// schemeRoot is where Windows records what a URL scheme opens with, under the
+// account's own class registrations: a per-user handler needs no elevation and
+// leaves other accounts on the machine alone.
+const schemeRoot = `Software\Classes\` + importScheme
+
+const schemeCommandKey = schemeRoot + `\shell\open\command`
+
+// registerScheme makes this launcher what a "ccswitch://" link opens.
+//
+// A registration that is already ours is rewritten, because it records a path
+// and an update or a move would leave links opening a Gateway that is no
longer
+// there. One that is somebody else's is taken only the first time, and what it
+// replaced is kept for unregisterScheme to put back.
+func registerScheme() error {
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+
+ registered, err := readSchemeCommand()
+ if err != nil {
+ return err
+ }
+ claiming := false
+ switch {
+ case schemeIsOurs(registered):
+ case claimedScheme():
+ // Ours once, and not ours now: the scheme was given back on
purpose.
+ return nil
+ default:
+ claiming = true
+ }
+
+ key, _, err := registry.CreateKey(registry.CURRENT_USER, schemeRoot,
registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer key.Close()
+ if err := key.SetStringValue("", "URL:"+shortcutName); err != nil {
+ return err
+ }
+ // An empty "URL Protocol" value is what marks a class as a scheme
handler;
+ // without it the shell will not open links with this key at all.
+ if err := key.SetStringValue("URL Protocol", ""); err != nil {
+ return err
+ }
+
+ command, _, err := registry.CreateKey(registry.CURRENT_USER,
schemeCommandKey, registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer command.Close()
+ if err := command.SetStringValue("", schemeCommand(executable)); err !=
nil {
+ return err
+ }
+
+ // Recorded once the scheme is actually ours, so a registration that
failed
+ // halfway is tried again on the next start rather than counting as the
one
+ // claim Gateway makes.
+ if claiming {
+ return os.WriteFile(schemeMarker(), []byte(registered), 0o644)
+ }
+ return nil
+}
+
+// unregisterScheme gives the scheme back to whatever held it before Gateway,
+// and takes it off the account where nothing did. A registration that is not
+// ours is left exactly as it is.
+func unregisterScheme() error {
+ registered, err := readSchemeCommand()
+ if err != nil {
+ return err
+ }
+ if !schemeIsOurs(registered) {
+ return nil
+ }
+
+ replaced, _ := os.ReadFile(schemeMarker())
+ if previous := strings.TrimSpace(string(replaced)); previous != "" {
+ key, _, err := registry.CreateKey(registry.CURRENT_USER,
schemeCommandKey, registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer key.Close()
+ if err := key.SetStringValue("", previous); err != nil {
+ return err
+ }
+ return os.Remove(schemeMarker())
+ }
+
+ for _, path := range []string{schemeCommandKey, schemeRoot +
`\shell\open`, schemeRoot + `\shell`, schemeRoot} {
+ if err := registry.DeleteKey(registry.CURRENT_USER, path); err
!= nil && !errors.Is(err, registry.ErrNotExist) {
+ return err
+ }
+ }
+ if err := os.Remove(schemeMarker()); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func readSchemeCommand() (string, error) {
+ key, err := registry.OpenKey(registry.CURRENT_USER, schemeCommandKey,
registry.QUERY_VALUE)
+ if err != nil {
+ if errors.Is(err, registry.ErrNotExist) {
+ return "", nil
+ }
+ return "", err
+ }
+ defer key.Close()
+
+ value, _, err := key.GetStringValue("")
+ if errors.Is(err, registry.ErrNotExist) {
+ return "", nil
+ }
+ return value, err
+}
+
+func schemeIsOurs(command string) bool {
+ return strings.Contains(strings.ToLower(command), openLinkSubcommand)
+}
+
+func schemeCommand(executable string) string {
+ return `"` + executable + `" ` + openLinkSubcommand + ` "%1"`
+}
diff --git a/desktop/shortcut.go b/desktop/shortcut.go
index 7416d3f..ffca94f 100644
--- a/desktop/shortcut.go
+++ b/desktop/shortcut.go
@@ -40,8 +40,18 @@ func setShortcuts(enabled bool) (string, error) {
return "", err
}
path = installed
- } else if err := removeShortcuts(); err != nil {
- return "", err
+ // A vendor's "add this provider" button opens a link rather
than a
+ // page, so the entries are only half of what an install puts
in place.
+ if err := registerScheme(); err != nil {
+ return "", err
+ }
+ } else {
+ if err := unregisterScheme(); err != nil {
+ return "", err
+ }
+ if err := removeShortcuts(); err != nil {
+ return "", err
+ }
}
_ = os.WriteFile(shortcutMarker(), []byte(path), 0o644)
diff --git a/desktop/shortcut_linux.go b/desktop/shortcut_linux.go
index 93d3490..22fcdc7 100644
--- a/desktop/shortcut_linux.go
+++ b/desktop/shortcut_linux.go
@@ -83,17 +83,21 @@ func desktopEntry(executable string) string {
// Path is where the entry starts it: the Gateway keeps its database,
logs
// and temporary files in the working directory, so an entry that
started it
// elsewhere would start a second, empty installation.
+ // The %u is what hands a clicked link to the launcher, and MimeType is
what
+ // offers the entry as the thing that opens one. Started from the menu
the
+ // %u expands to nothing, which is the ordinary start.
return fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=%s
Comment=Local gateway for AI agents
-Exec="%s"
+Exec="%s" %%u
Path=%s
Icon=%s
Terminal=false
Categories=Development;Network;
+MimeType=%s;
StartupWMClass=%s-desktop
-`, shortcutName, executable, gatewayHome(), appId, serverName)
+`, shortcutName, executable, gatewayHome(), appId, schemeMimeType, serverName)
}
// installDesktopIcon puts the same entry on the desktop itself. Most desktops
diff --git a/desktop/tray.go b/desktop/tray.go
index 7d16d74..e86a3cd 100644
--- a/desktop/tray.go
+++ b/desktop/tray.go
@@ -36,9 +36,19 @@ var ownsServer struct {
}
func runTray() {
- // An archive that was unpacked by hand has no way in but the executable
- // itself, so the first start is what gives it one.
- go ensureShortcuts()
+ go func() {
+ // An archive that was unpacked by hand has no way in but the
executable
+ // itself, so the first start is what gives it one.
+ ensureShortcuts()
+
+ // Unlike the shortcuts, this is reasserted on every start: the
handler
+ // records a path, and an update or a move would otherwise
leave links
+ // opening a Gateway that is no longer there. It follows the
shortcuts
+ // because on Linux the desktop entry is what carries the
registration.
+ if err := registerScheme(); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop: could
not claim ccswitch:// links:", err)
+ }
+ }()
// The tray menu is drawn by this process too, so it blurs on a scaled
// display for the same reason the window did.
diff --git a/object/import_link.go b/object/import_link.go
new file mode 100644
index 0000000..97d6b97
--- /dev/null
+++ b/object/import_link.go
@@ -0,0 +1,312 @@
+// 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 object
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+ "unicode/utf8"
+)
+
+// The resources an "add this to Gateway" link can carry. The names are CC
+// Switch's, because the links Gateway reads are the ones vendors already
+// publish for it.
+const (
+ ImportResourceProvider = "provider"
+ ImportResourceMcp = "mcp"
+ ImportResourcePrompt = "prompt"
+ ImportResourceSkill = "skill"
+)
+
+// importLinkAgents maps an app a link names to the agents on this host that
+// read that app's own configuration. Gateway manages agents CC Switch does not
+// and the other way round, so an app with nothing here is reported back rather
+// than silently dropped: the page says which part of the link it cannot land.
+//
+// Two ids appear where the same files are read under both — the Codex CLI and
+// ChatGPT Desktop share ~/.codex, opencode and its desktop app share
+// ~/.config/opencode — because the listing keeps one entry per location, under
+// whichever of the two was found first.
+var importLinkAgents = map[string][]string{
+ "claude": {"claude-code"},
+ "codex": {"codex-cli", "codex"},
+ "gemini": {"gemini-cli"},
+ "opencode": {"opencode", "opencode-desktop"},
+ "openclaw": {"openclaw"},
+ "hermes": {"hermes-agent"},
+}
+
+// ImportLink is what one link carries, read and handed to the page. Nothing in
+// it is stored: a link like this arrives from a website, so the person it
+// arrives at sees the values before any of them are written.
+type ImportLink struct {
+ Resource string `json:"resource"`
+ Provider *Provider `json:"provider,omitempty"`
+ Mcp *McpImport `json:"mcp,omitempty"`
+ Prompt *PromptImport `json:"prompt,omitempty"`
+ Skill *SkillImport `json:"skill,omitempty"`
+}
+
+// McpImport is one or more MCP servers, kept as the JSON block the link
+// carried. Reading that block is the browser's job: the page already knows
+// every shape a server is written in, and this way the reader sees the same
+// text the link held.
+type McpImport struct {
+ Name string `json:"name"`
+ Config string `json:"config"`
+ // Targets are the agents the link asks for, as ids of this host's
agents.
+ Targets []string `json:"targets"`
+ // Unknown are the apps it named that Gateway does not manage.
+ Unknown []string `json:"unknown"`
+}
+
+// PromptImport is a set of instructions for an agent to read before every
+// session. Applying it replaces the file the agent has, which is why the
+// content travels to the page in full.
+type PromptImport struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Content string `json:"content"`
+ Targets []string `json:"targets"`
+ Unknown []string `json:"unknown"`
+}
+
+// SkillImport is a place to install skills from, in the shape agentconfig
+// records a GitHub source in.
+type SkillImport struct {
+ Name string `json:"name"`
+ Repo string `json:"repo"`
+ Ref string `json:"ref"`
+ Subdir string `json:"subdir"`
+}
+
+// ParseImportLink reads one link and answers with what it would fill in. It
+// stores nothing and reaches no agent: every resource here is applied by the
+// page, through the same endpoint that adds one by hand.
+//
+// The parameters Gateway has no use for are ignored rather than refused, so a
+// link written for a fuller CC Switch still imports the part that means
+// something here: a provider's whole config file, the script it queries a
+// balance with, and the flags that would activate what was imported — Gateway
+// asks the reader that instead.
+func ParseImportLink(owner string, raw string) (*ImportLink, error) {
+ query, err := importLinkQuery(raw)
+ if err != nil {
+ return nil, err
+ }
+
+ link := &ImportLink{Resource: query.Get("resource")}
+ switch link.Resource {
+ case ImportResourceProvider:
+ link.Provider, err = providerFromLink(owner, query)
+ case ImportResourceMcp:
+ link.Mcp, err = mcpFromLink(query)
+ case ImportResourcePrompt:
+ link.Prompt, err = promptFromLink(query)
+ case ImportResourceSkill:
+ link.Skill, err = skillFromLink(query)
+ default:
+ return nil, fmt.Errorf("this link carries a %q, which Gateway
cannot import", link.Resource)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return link, nil
+}
+
+// importLinkQuery checks the envelope every link shares and returns what it
+// carries.
+func importLinkQuery(raw string) (url.Values, error) {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return nil, fmt.Errorf("this is not a link: %w", err)
+ }
+ if parsed.Scheme != ImportLinkScheme {
+ return nil, fmt.Errorf("an import link starts with %q, not %q",
ImportLinkScheme+"://", parsed.Scheme)
+ }
+ if parsed.Host != "v1" {
+ return nil, fmt.Errorf("unsupported link version: %s",
parsed.Host)
+ }
+ if strings.Trim(parsed.Path, "/") != "import" {
+ return nil, fmt.Errorf("unsupported link path: %s", parsed.Path)
+ }
+ return parsed.Query(), nil
+}
+
+func mcpFromLink(query url.Values) (*McpImport, error) {
+ config, err := linkJson("config", query.Get("config"))
+ if err != nil {
+ return nil, err
+ }
+
+ targets, unknown := linkTargets(strings.Split(query.Get("apps"), ","))
+ return &McpImport{
+ Name: strings.TrimSpace(query.Get("name")),
+ Config: config,
+ Targets: targets,
+ Unknown: unknown,
+ }, nil
+}
+
+func promptFromLink(query url.Values) (*PromptImport, error) {
+ name := strings.TrimSpace(query.Get("name"))
+ if name == "" {
+ return nil, fmt.Errorf("the link does not say what to call
these instructions")
+ }
+ content := linkText(query.Get("content"))
+ if strings.TrimSpace(content) == "" {
+ return nil, fmt.Errorf("the link carries no instructions")
+ }
+
+ targets, unknown := linkTargets([]string{query.Get("app")})
+ return &PromptImport{
+ Name: name,
+ Description: strings.TrimSpace(query.Get("description")),
+ Content: content,
+ Targets: targets,
+ Unknown: unknown,
+ }, nil
+}
+
+func skillFromLink(query url.Values) (*SkillImport, error) {
+ repo := strings.Trim(strings.TrimSpace(query.Get("repo")), "/")
+ repoOwner, repoName, found := strings.Cut(repo, "/")
+ if !found || repoOwner == "" || repoName == "" ||
strings.Contains(repoName, "/") {
+ return nil, fmt.Errorf("a skill link names its repository as
owner/name, not %q", repo)
+ }
+
+ ref := strings.TrimSpace(query.Get("branch"))
+ if err := checkLinkPath("branch", ref); err != nil {
+ return nil, err
+ }
+ subdir := strings.Trim(strings.TrimSpace(query.Get("directory")), "/")
+ if err := checkLinkPath("directory", subdir); err != nil {
+ return nil, err
+ }
+
+ name := strings.TrimSpace(query.Get("name"))
+ if name == "" {
+ name = repo
+ }
+ return &SkillImport{Name: name, Repo: repo, Ref: ref, Subdir: subdir},
nil
+}
+
+// checkLinkPath keeps the parts of a skill link that end up in a download URL
+// or a path under Gateway's store to what they are meant to name. A branch is
+// pasted into the address an archive is fetched from, where a "../" would
+// resolve to a different file on the same host — which nobody writing down a
+// branch name ever needs.
+func checkLinkPath(field string, value string) error {
+ if value == "" {
+ return nil
+ }
+ if strings.HasPrefix(value, "/") || strings.Contains(value, `\`) {
+ return fmt.Errorf("the %s in this link is not a name: %q",
field, value)
+ }
+ for _, segment := range strings.Split(value, "/") {
+ if segment == "" || segment == "." || segment == ".." {
+ return fmt.Errorf("the %s in this link is not a name:
%q", field, value)
+ }
+ }
+ for _, r := range value {
+ if r <= ' ' || r == 0x7f || strings.ContainsRune(`%?#"'`, r) {
+ return fmt.Errorf("the %s in this link holds a
character a name cannot: %q", field, value)
+ }
+ }
+ return nil
+}
+
+// linkTargets turns the apps a link names into the agents on this host that
+// read their configuration, and reports the ones Gateway does not manage.
+func linkTargets(apps []string) (targets []string, unknown []string) {
+ targets, unknown = []string{}, []string{}
+ seen := map[string]bool{}
+ for _, app := range apps {
+ app = strings.ToLower(strings.TrimSpace(app))
+ if app == "" {
+ continue
+ }
+ agentIds, known := importLinkAgents[app]
+ if !known {
+ unknown = append(unknown, app)
+ continue
+ }
+ for _, agentId := range agentIds {
+ if !seen[agentId] {
+ seen[agentId] = true
+ targets = append(targets, agentId)
+ }
+ }
+ }
+ return targets, unknown
+}
+
+// linkText reads a parameter carrying a document rather than a word. The
format
+// base64-encodes those, and encoders disagree about padding and about which
+// alphabet, so all four are tried; a value that is not base64 is taken as the
+// text it already is, which is what a link written by hand carries. Guessing
+// wrong is visible either way: the page shows what was read before it is
+// written anywhere.
+func linkText(raw string) string {
+ value := strings.TrimSpace(raw)
+ if decoded, ok := decodeLinkBase64(value); ok {
+ return decoded
+ }
+ return value
+}
+
+// linkJson reads a parameter carrying JSON, which is the one case where a
wrong
+// guess about base64 can be ruled out rather than shown.
+func linkJson(field string, raw string) (string, error) {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return "", fmt.Errorf("the link carries no %s", field)
+ }
+ if decoded, ok := decodeLinkBase64(value); ok &&
json.Valid([]byte(decoded)) {
+ return decoded, nil
+ }
+ if !json.Valid([]byte(value)) {
+ return "", fmt.Errorf("the %s in this link is not JSON", field)
+ }
+ return value, nil
+}
+
+func decodeLinkBase64(value string) (string, bool) {
+ if value == "" {
+ return "", false
+ }
+
+ candidates := []string{value}
+ // A "+" of the standard alphabet arrives as a space when the link was
put
+ // together without escaping it.
+ if strings.Contains(value, " ") {
+ candidates = append([]string{strings.ReplaceAll(value, " ",
"+")}, candidates...)
+ }
+ for _, candidate := range candidates {
+ for _, encoding := range []*base64.Encoding{
+ base64.StdEncoding, base64.RawStdEncoding,
+ base64.URLEncoding, base64.RawURLEncoding,
+ } {
+ decoded, err := encoding.DecodeString(candidate)
+ if err == nil && utf8.Valid(decoded) {
+ return string(decoded), true
+ }
+ }
+ }
+ return "", false
+}
diff --git a/object/import_link_pending.go b/object/import_link_pending.go
new file mode 100644
index 0000000..37fcc29
--- /dev/null
+++ b/object/import_link_pending.go
@@ -0,0 +1,65 @@
+// 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 object
+
+import (
+ "sync"
+ "time"
+)
+
+// pendingLinkTtl is how long a link waits for the page to come and get it. A
+// browser that has to start Gateway first takes a few seconds; anything longer
+// than this is a link nobody opened a page for, and it is dropped rather than
+// shown to whoever opens one next.
+const pendingLinkTtl = 2 * time.Minute
+
+// MaxImportLinkLength caps what is accepted as a link, which arrives from a
+// browser and is only ever a URL.
+const MaxImportLinkLength = 8192
+
+// pendingLink is the one link the URL scheme handler has handed over and the
+// page has not read yet. It lives in memory: a link is on its way to a page
+// that is opening right now, and a Gateway that restarts in between has lost
+// the browser window it was for anyway.
+var pendingLink struct {
+ sync.Mutex
+ raw string
+ expires time.Time
+}
+
+// SetPendingImportLink records the link a page is about to be opened for,
+// replacing one nobody came for.
+func SetPendingImportLink(raw string) {
+ pendingLink.Lock()
+ defer pendingLink.Unlock()
+
+ pendingLink.raw = raw
+ pendingLink.expires = time.Now().Add(pendingLinkTtl)
+}
+
+// TakePendingImportLink returns the waiting link and forgets it, so a page
that
+// is reloaded does not import the same link twice.
+func TakePendingImportLink() string {
+ pendingLink.Lock()
+ defer pendingLink.Unlock()
+
+ raw := pendingLink.raw
+ expired := time.Now().After(pendingLink.expires)
+ pendingLink.raw = ""
+ if expired {
+ return ""
+ }
+ return raw
+}
diff --git a/object/provider_link.go b/object/provider_link.go
index 3d90377..e35093b 100644
--- a/object/provider_link.go
+++ b/object/provider_link.go
@@ -20,10 +20,10 @@ import (
"strings"
)
-// providerLinkScheme is CC Switch's. Vendors already publish "add to CC
Switch"
+// ImportLinkScheme is CC Switch's. Vendors already publish "add this provider"
// links in this format, so Gateway reads that one rather than inventing a
// second one nobody would be given.
-const providerLinkScheme = "ccswitch"
+const ImportLinkScheme = "ccswitch"
// providerLinkApps are the values CC Switch accepts in "app". Only the wire
// format matters here, and only Claude speaks Anthropic's.
@@ -37,30 +37,21 @@ var providerLinkApps = map[string]string{
"hermes": "openai",
}
-// ParseProviderLink turns a "ccswitch://v1/import?resource=provider&..." link
-// into a provider, filling in what the link says and leaving the rest to the
-// form. Nothing is stored: the caller shows the result and the person adding
it
-// decides, because a link like this arrives from a vendor's website.
+// ParseProviderLink reads a link that has to be a provider, which is what the
+// provider pages import. Every other resource is read by ParseImportLink.
func ParseProviderLink(owner string, raw string) (*Provider, error) {
- parsed, err := url.Parse(strings.TrimSpace(raw))
+ link, err := ParseImportLink(owner, raw)
if err != nil {
- return nil, fmt.Errorf("this is not a link: %w", err)
+ return nil, err
}
- if parsed.Scheme != providerLinkScheme {
- return nil, fmt.Errorf("a provider link starts with %q, not
%q", providerLinkScheme+"://", parsed.Scheme)
- }
- if parsed.Host != "v1" {
- return nil, fmt.Errorf("unsupported link version: %s",
parsed.Host)
- }
- if strings.Trim(parsed.Path, "/") != "import" {
- return nil, fmt.Errorf("unsupported link path: %s", parsed.Path)
- }
-
- query := parsed.Query()
- if resource := query.Get("resource"); resource != "provider" {
- return nil, fmt.Errorf("this link carries a %q, which Gateway
cannot import", resource)
+ if link.Provider == nil {
+ return nil, fmt.Errorf("this link carries a %q, not a
provider", link.Resource)
}
+ return link.Provider, nil
+}
+// providerFromLink fills in what the link says and leaves the rest to the
form.
+func providerFromLink(owner string, query url.Values) (*Provider, error) {
app := query.Get("app")
protocol, known := providerLinkApps[app]
if !known {
diff --git a/routers/router.go b/routers/router.go
index ce2e83e..5d6d2d8 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -111,6 +111,12 @@ func initAPI() {
beego.Router("/api/get-unmanaged-skills", &controllers.ApiController{},
"GET:GetUnmanagedSkills")
beego.Router("/api/adopt-skills", &controllers.ApiController{},
"POST:AdoptSkills")
+ // A vendor's "add this to Gateway" link, whether it was pasted into a
page
+ // or clicked outside the browser and routed here by the URL scheme
handler.
+ beego.Router("/api/parse-import-link", &controllers.ApiController{},
"POST:ParseImportLink")
+ beego.Router("/api/open-import-link", &controllers.ApiController{},
"POST:OpenImportLink")
+ beego.Router("/api/get-pending-import-link",
&controllers.ApiController{}, "GET:GetPendingImportLink")
+
// Provider routes for LLM gateway milestone 1.1.
beego.Router("/api/get-providers", &controllers.ApiController{},
"GET:GetProviders")
beego.Router("/api/get-provider", &controllers.ApiController{},
"GET:GetProvider")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index a5d03da..a4293eb 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -37,6 +37,7 @@ import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
import AuthenticityPage from "@/pages/AuthenticityPage";
import HomePage from "@/pages/HomePage";
+import ImportLinkPage from "@/pages/ImportLinkPage";
import PermissionsPage from "@/pages/PermissionsPage";
import ProviderEditPage from "@/pages/ProviderEditPage";
import ProviderListPage from "@/pages/ProviderListPage";
@@ -290,6 +291,7 @@ export default function App() {
path="/agent-sessions/:sessionKey"
element={requireSignin(user => <AgentSessionPage
account={user} />)}
/>
+ <Route path="/import" element={requireSignin(() =>
<ImportLinkPage />)} />
<Route path="/settings" element={requireSignin(user =>
<SettingPage account={user} />)} />
<Route
path="/permissions"
diff --git a/web/src/backend/ImportLinkBackend.ts
b/web/src/backend/ImportLinkBackend.ts
new file mode 100644
index 0000000..a43d599
--- /dev/null
+++ b/web/src/backend/ImportLinkBackend.ts
@@ -0,0 +1,29 @@
+// 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.
+
+import {request} from "@/backend/request";
+import type {ImportLink} from "@/types";
+
+/** What a vendor's "add this to Gateway" link says, without storing any of
it. */
+export function parseImportLink(link: string) {
+ return request<ImportLink>("/api/parse-import-link", "POST", {link: link});
+}
+
+/**
+ * The link the URL scheme handler left, read once. It answers with nothing
when
+ * the page was opened by hand rather than by a click on a link.
+ */
+export function getPendingImportLink() {
+ return request<ImportLink | null>("/api/get-pending-import-link");
+}
diff --git a/web/src/nav.ts b/web/src/nav.ts
index b26f738..2e9e7e7 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -18,6 +18,7 @@ import {
ChartColumn,
CircleDollarSign,
FileSearch,
+ Link2,
Logs,
MessageSquare,
Plug,
@@ -66,6 +67,9 @@ export const navGroups: NavGroup[] = [
// from it rather than competing with it in the rail.
{key: "/agents", label: "agent:Advanced view", icon: Table2, path:
"/agents", adminOnly: true, hidden: true},
{key: "/providers", label: "provider:Providers", icon: Plug, path:
"/providers"},
+ // Reached by clicking a vendor's "add this to Gateway" link, which is why it
+ // is named by the breadcrumb but not offered in the rail.
+ {key: "/import", label: "link:Import from a link", icon: Link2, path:
"/import", adminOnly: true, hidden: true},
{
key: "/agent-configs",
label: "agentConfig:Skills, MCP & Prompts",
diff --git a/web/src/pages/ImportLinkPage.tsx b/web/src/pages/ImportLinkPage.tsx
new file mode 100644
index 0000000..df4637c
--- /dev/null
+++ b/web/src/pages/ImportLinkPage.tsx
@@ -0,0 +1,514 @@
+// 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.
+
+import * as React from "react";
+import {useNavigate} from "react-router-dom";
+import {Link2} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as ImportLinkBackend from "@/backend/ImportLinkBackend";
+import * as Setting from "@/Setting";
+import {ActionBadge} from "@/components/agent-config/action-badge";
+import {TargetPicker} from "@/components/agent-config/target-picker";
+import {CodeBlock, CodeText, DescriptionList} from "@/components/shared/misc";
+import {Loading} from "@/components/shared/loading";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
+import {Label} from "@/components/ui/label";
+import {Switch} from "@/components/ui/switch";
+import {Textarea} from "@/components/ui/textarea";
+import {parseMcpJson} from "@/lib/mcp";
+import type {
+ AgentConfigInventory,
+ AgentConfigPlanItem,
+ ImportLink,
+ McpImport,
+ PromptImport,
+ SkillImport,
+} from "@/types";
+
+/**
+ * What a vendor's "add this to Gateway" link carries, shown before any of it
is
+ * kept. The link arrives from a website — clicked there and routed here by the
+ * URL scheme handler, or pasted in below — so nothing on this page is written
+ * until the button under it is pressed.
+ *
+ * A provider goes on to the provider page's own form, which is where one is
+ * reviewed and tested. The other three are written from here, through the same
+ * endpoints that add one by hand.
+ */
+export default function ImportLinkPage() {
+ const navigate = useNavigate();
+ const [link, setLink] = React.useState<ImportLink | null>(null);
+ const [pasted, setPasted] = React.useState("");
+ const [loading, setLoading] = React.useState(true);
+ const [reading, setReading] = React.useState(false);
+ const [error, setError] = React.useState("");
+ const taken = React.useRef(false);
+
+ // The waiting link is handed over once and then forgotten, so it is asked
for
+ // once however many times the effect runs.
+ React.useEffect(() => {
+ if (taken.current) {
+ return;
+ }
+ taken.current = true;
+
+ ImportLinkBackend.getPendingImportLink()
+ .then(res => {
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("link:This link cannot be read"));
+ return;
+ }
+ if (res.data) {
+ setLink(res.data);
+ }
+ })
+ .catch(err => setError(`${err}`))
+ .then(() => setLoading(false));
+ }, []);
+
+ // A provider is added on the provider page: its form is what shows the base
+ // URL and the key, and what tests them against the upstream before storing.
+ React.useEffect(() => {
+ if (link?.provider) {
+ navigate("/providers", {replace: true, state: {importProvider:
link.provider}});
+ }
+ }, [link, navigate]);
+
+ const read = () => {
+ const raw = pasted.trim();
+ if (raw === "") {
+ return;
+ }
+ setReading(true);
+ setError("");
+ ImportLinkBackend.parseImportLink(raw)
+ .then(res => {
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("link:This link cannot be read"));
+ return;
+ }
+ setLink(res.data);
+ })
+ .catch(err => setError(`${err}`))
+ .then(() => setReading(false));
+ };
+
+ const discard = () => {
+ setLink(null);
+ setPasted("");
+ setError("");
+ };
+
+ return (
+ <PageContainer>
+ <PageHeader title={i18next.t("link:Import from a link")}
description={i18next.t("link:Import hint")} />
+
+ {error === "" ? null : <MessageAlert title={error} />}
+
+ {loading ? (
+ <Loading />
+ ) : link === null ? (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-[15px]">
+ <Link2 className="size-4" />
+ {i18next.t("link:Paste a link")}
+ </CardTitle>
+ <CardDescription>{i18next.t("link:Paste a link
hint")}</CardDescription>
+ </CardHeader>
+ <CardContent className="flex flex-col gap-3">
+ <Textarea
+ value={pasted}
+ onChange={event => setPasted(event.target.value)}
+ placeholder="ccswitch://v1/import?resource=..."
+ rows={3}
+ className="font-mono text-xs"
+ />
+ <div>
+ <Button onClick={read} disabled={reading || pasted.trim() ===
""}>
+ {i18next.t("link:Read link")}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ ) : link.mcp ? (
+ <McpImportCard value={link.mcp} onDone={discard} />
+ ) : link.prompt ? (
+ <PromptImportCard value={link.prompt} onDone={discard} />
+ ) : link.skill ? (
+ <SkillImportCard value={link.skill} onDone={discard} />
+ ) : null}
+ </PageContainer>
+ );
+}
+
+/** The agents on this host, for the two resources that are written into them.
*/
+function useInventories() {
+ const [inventories, setInventories] =
React.useState<AgentConfigInventory[]>([]);
+ const [loading, setLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ AgentConfigBackend.getAgentConfigs()
+ .then(res => setInventories(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setInventories([]))
+ .then(() => setLoading(false));
+ }, []);
+
+ return {inventories: inventories, loading: loading};
+}
+
+/** The apps a link named that Gateway does not manage, said once. */
+function UnknownApps({apps}: {apps: string[]}) {
+ if (apps.length === 0) {
+ return null;
+ }
+ return (
+ <MessageAlert
+ variant="warning"
+ title={i18next.t("link:Some apps are not managed here")}
+ description={i18next.t("link:Some apps are not managed here
hint").replace("{apps}", apps.join(", "))}
+ />
+ );
+}
+
+/** What a write did, agent by agent. */
+function PlanResult({items}: {items: AgentConfigPlanItem[]}) {
+ return (
+ <div className="flex flex-col gap-2">
+ {items.map((item, index) => (
+ <div key={`${item.agentId}-${item.name}-${index}`} className="flex
items-center gap-2 text-sm">
+ <ActionBadge action={item.action} />
+ <span className="truncate">{item.agentId}</span>
+ {item.reason ? <span className="text-muted-foreground truncate
text-xs">{item.reason}</span> : null}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function McpImportCard({value, onDone}: {value: McpImport; onDone: () =>
void}) {
+ const {inventories, loading} = useInventories();
+ const [targets, setTargets] = React.useState<string[]>([]);
+ const [overwrite, setOverwrite] = React.useState(false);
+ const [busy, setBusy] = React.useState(false);
+ const [result, setResult] = React.useState<AgentConfigPlanItem[] |
null>(null);
+ const [error, setError] = React.useState("");
+
+ const parsed = React.useMemo(() => parseMcpJson(value.config),
[value.config]);
+
+ // A server is written into one account's home directory, so the agents
+ // offered are the ones reading the same home as the first the link asked
for.
+ const asked = inventories.find(inventory =>
value.targets.includes(inventory.agentId));
+ const source = asked ?? inventories[0];
+ const candidates = source ? inventories.filter(inventory => inventory.home
=== source.home) : [];
+
+ React.useEffect(() => {
+ setTargets(candidates.filter(inventory =>
value.targets.includes(inventory.agentId)).map(item => item.agentId));
+ // The candidates are derived from the inventories, which is what changing
+ // here really means.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [inventories, value.targets]);
+
+ const toggle = (agentId: string) => {
+ setTargets(previous =>
+ previous.includes(agentId) ? previous.filter(item => item !== agentId) :
[...previous, agentId],
+ );
+ };
+
+ const add = () => {
+ setBusy(true);
+ setError("");
+
+ // One request per server, in order, so a block naming several of them is
+ // reported server by server and agent by agent.
+ parsed.servers
+ .reduce(
+ (chain, server) =>
+ chain.then(items =>
+ AgentConfigBackend.addAgentConfigMcp({
+ owner: source?.owner ?? "",
+ to: targets,
+ name: server.name.trim() === "" ? value.name :
server.name.trim(),
+ transport: server.transport,
+ command: server.transport === "stdio" ? server.command :
undefined,
+ args: server.transport === "stdio" ? server.args : undefined,
+ env: server.transport === "stdio" ? server.env : undefined,
+ url: server.transport === "http" ? server.url : undefined,
+ headers: server.transport === "http" ? server.headers :
undefined,
+ overwrite: overwrite,
+ }).then(res => {
+ if (res.status !== "ok") {
+ throw new Error(res.msg || i18next.t("agentConfig:Failed to
add this MCP server"));
+ }
+ return [...items, ...(res.data ?? [])];
+ }),
+ ),
+ Promise.resolve([] as AgentConfigPlanItem[]),
+ )
+ .then(written => {
+ setResult(written);
+ const failed = written.filter(item => item.action === "failed").length;
+ Setting.showMessage(
+ failed > 0 ? "error" : "success",
+ failed > 0 ? i18next.t("link:Import finished with failures") :
i18next.t("link:Imported"),
+ );
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setBusy(false));
+ };
+
+ const names = parsed.servers.map(server => (server.name === "" ? value.name
: server.name)).filter(Boolean);
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-[15px]">{i18next.t("link:MCP servers from
this link")}</CardTitle>
+ <CardDescription>{i18next.t("link:MCP servers hint")}</CardDescription>
+ </CardHeader>
+ <CardContent className="flex flex-col gap-4">
+ {parsed.error === "" ? null : <MessageAlert
title={i18next.t("link:This link cannot be read")} />}
+ {error === "" ? null : <MessageAlert title={error} />}
+ <UnknownApps apps={value.unknown} />
+
+ <DescriptionList
+ items={[
+ {label: i18next.t("general:Name"), value: <CodeText>{names.join(",
")}</CodeText>},
+ ]}
+ />
+ <CodeBlock maxHeight="16rem">{value.config}</CodeBlock>
+
+ {result === null ? (
+ <>
+ <div className="flex flex-col gap-2">
+ <Label>{i18next.t("agentConfig:Add to")}</Label>
+ {loading ? (
+ <Loading />
+ ) : (
+ <TargetPicker
+ candidates={candidates}
+ kind="mcp"
+ selected={targets}
+ onToggle={toggle}
+ disabled={busy}
+ />
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ <Switch id="mcp-overwrite" checked={overwrite}
onCheckedChange={setOverwrite} disabled={busy} />
+ <Label htmlFor="mcp-overwrite">{i18next.t("agentConfig:Replace
items that already exist")}</Label>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={add} disabled={busy || targets.length === 0 ||
parsed.servers.length === 0}>
+ {i18next.t("link:Import")}
+ </Button>
+ <Button variant="outline" onClick={onDone} disabled={busy}>
+ {i18next.t("link:Discard")}
+ </Button>
+ </div>
+ </>
+ ) : (
+ <>
+ <PlanResult items={result} />
+ <div>
+ <Button variant="outline" onClick={onDone}>
+ {i18next.t("link:Done")}
+ </Button>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
+
+function PromptImportCard({value, onDone}: {value: PromptImport; onDone: () =>
void}) {
+ const {inventories, loading} = useInventories();
+ const [targets, setTargets] = React.useState<string[]>([]);
+ const [busy, setBusy] = React.useState(false);
+ const [result, setResult] = React.useState<AgentConfigPlanItem[] |
null>(null);
+ const [error, setError] = React.useState("");
+
+ React.useEffect(() => {
+ setTargets(inventories.filter(item =>
value.targets.includes(item.agentId)).map(item => item.agentId));
+ }, [inventories, value.targets]);
+
+ const toggle = (agentId: string) => {
+ setTargets(previous =>
+ previous.includes(agentId) ? previous.filter(item => item !== agentId) :
[...previous, agentId],
+ );
+ };
+
+ const write = () => {
+ setBusy(true);
+ setError("");
+
+ const picked = inventories.filter(inventory =>
targets.includes(inventory.agentId));
+ picked
+ .reduce(
+ (chain, inventory) =>
+ chain.then(items =>
+ AgentConfigBackend.saveAgentConfigPrompt(inventory.agentId,
inventory.owner, value.content).then(res => [
+ ...items,
+ {
+ agentId: inventory.agentId,
+ name: value.name,
+ action: res.status === "ok" ? "overwrite" : "failed",
+ reason: res.status === "ok" ? "" : res.msg,
+ } as AgentConfigPlanItem,
+ ]),
+ ),
+ Promise.resolve([] as AgentConfigPlanItem[]),
+ )
+ .then(written => {
+ setResult(written);
+ const failed = written.filter(item => item.action === "failed").length;
+ Setting.showMessage(
+ failed > 0 ? "error" : "success",
+ failed > 0 ? i18next.t("link:Import finished with failures") :
i18next.t("link:Imported"),
+ );
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setBusy(false));
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-[15px]">{value.name}</CardTitle>
+ <CardDescription>
+ {value.description === "" ? i18next.t("link:Instructions hint") :
value.description}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="flex flex-col gap-4">
+ {error === "" ? null : <MessageAlert title={error} />}
+ <UnknownApps apps={value.unknown} />
+ <MessageAlert
+ variant="warning"
+ title={i18next.t("link:This replaces the instructions")}
+ description={i18next.t("link:This replaces the instructions hint")}
+ />
+
+ <CodeBlock maxHeight="20rem">{value.content}</CodeBlock>
+
+ {result === null ? (
+ <>
+ <div className="flex flex-col gap-2">
+ <Label>{i18next.t("agentConfig:Add to")}</Label>
+ {loading ? (
+ <Loading />
+ ) : (
+ <TargetPicker
+ candidates={inventories}
+ kind="prompt"
+ selected={targets}
+ onToggle={toggle}
+ disabled={busy}
+ />
+ )}
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={write} disabled={busy || targets.length === 0}>
+ {i18next.t("link:Import")}
+ </Button>
+ <Button variant="outline" onClick={onDone} disabled={busy}>
+ {i18next.t("link:Discard")}
+ </Button>
+ </div>
+ </>
+ ) : (
+ <>
+ <PlanResult items={result} />
+ <div>
+ <Button variant="outline" onClick={onDone}>
+ {i18next.t("link:Done")}
+ </Button>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
+
+function SkillImportCard({value, onDone}: {value: SkillImport; onDone: () =>
void}) {
+ const navigate = useNavigate();
+ const {inventories} = useInventories();
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState("");
+
+ // Sources belong to the account whose home the agents read, which is the one
+ // the listing is of - not the account signed in to Gateway.
+ const owner = inventories[0]?.owner ?? "";
+
+ const add = () => {
+ setBusy(true);
+ setError("");
+ AgentConfigBackend.addSkillSource({
+ owner: owner,
+ kind: "github",
+ url: value.repo,
+ ref: value.ref,
+ subdir: value.subdir,
+ name: value.name,
+ })
+ .then(res => {
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("agentConfig:Failed to add this
source"));
+ return;
+ }
+ Setting.showMessage("success", i18next.t("agentConfig:Source added"));
+ onDone();
+ navigate("/agent-configs?tab=skill");
+ })
+ .catch(err => setError(`${err}`))
+ .then(() => setBusy(false));
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-[15px]">{i18next.t("link:A place to install
skills from")}</CardTitle>
+ <CardDescription>{i18next.t("link:Skill source
hint")}</CardDescription>
+ </CardHeader>
+ <CardContent className="flex flex-col gap-4">
+ {error === "" ? null : <MessageAlert title={error} />}
+
+ <DescriptionList
+ items={[
+ {label: i18next.t("link:Repository"), value: <CodeText
copyable>{value.repo}</CodeText>},
+ value.ref !== "" && {label: i18next.t("link:Branch"), value:
<CodeText>{value.ref}</CodeText>},
+ value.subdir !== "" && {
+ label: i18next.t("link:Folder"),
+ value: <CodeText>{value.subdir}</CodeText>,
+ },
+ ]}
+ />
+
+ <div className="flex gap-2">
+ <Button onClick={add} disabled={busy}>
+ {i18next.t("link:Import")}
+ </Button>
+ <Button variant="outline" onClick={onDone} disabled={busy}>
+ {i18next.t("link:Discard")}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/pages/ProviderListPage.tsx
b/web/src/pages/ProviderListPage.tsx
index cd09a79..aab1b56 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -13,7 +13,7 @@
// limitations under the License.
import * as React from "react";
-import {Link, useNavigate} from "react-router-dom";
+import {Link, useLocation, useNavigate} from "react-router-dom";
import {
ChevronDown,
ExternalLink,
@@ -118,6 +118,7 @@ function Advanced({defaultOpen, children}: {defaultOpen:
boolean; children: Reac
export default function ProviderListPage({account}: {account: Account}) {
const navigate = useNavigate();
+ const location = useLocation();
const isAdmin = Setting.isAdminUser(account);
const [data, setData] = React.useState<Provider[]>([]);
const [total, setTotal] = React.useState(0);
@@ -288,8 +289,18 @@ export default function ProviderListPage({account}:
{account: Account}) {
setSource(picked);
};
- // A vendor's link fills the same form a card would, and then stops: what it
- // carries — a base URL and often a key — is shown before anything is stored.
+ // What a link carries — a base URL and often a key — fills the same form a
+ // card would, and then stops: it is shown before anything is stored.
+ const fillFromLink = React.useCallback(
+ (parsed: Provider) => {
+ setForm({...newProvider(account.name, parsed.displayName), ...parsed,
name: providerSlug(parsed.displayName)});
+ setNameError("");
+ setSource(providerSources.find(item => item.key === customSource) ??
null);
+ setAddOpen(true);
+ },
+ [account.name],
+ );
+
const importLink = (link: string) => {
return ProviderBackend.parseProviderLink(link)
.then(res => {
@@ -297,15 +308,24 @@ export default function ProviderListPage({account}:
{account: Account}) {
Setting.showMessage("error", res.msg || i18next.t("provider:This
link cannot be read"));
return;
}
- const parsed = res.data;
- setForm({...newProvider(account.name, parsed.displayName), ...parsed,
name: providerSlug(parsed.displayName)});
- setNameError("");
- setSource(providerSources.find(item => item.key === customSource) ??
null);
- setAddOpen(true);
+ fillFromLink(res.data);
})
.catch(error => Setting.showMessage("error", `${error}`));
};
+ // A link clicked outside the browser lands on the import page, which sends
+ // the provider it carried on to here. It travels in history state rather
than
+ // in the address because it holds a key; clearing it is what stops a reload
+ // from opening the dialog again.
+ React.useEffect(() => {
+ const imported = (location.state as {importProvider?: Provider} |
null)?.importProvider;
+ if (imported === undefined) {
+ return;
+ }
+ fillFromLink(imported);
+ navigate(location.pathname, {replace: true, state: null});
+ }, [location, navigate, fillFromLink]);
+
// The upstream is probed before the provider is stored, so a key that was
// pasted wrong is caught here rather than by the first agent that uses it.
const submitProvider = () => {
diff --git a/web/src/types.ts b/web/src/types.ts
index 079b51e..5f5a9af 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1127,6 +1127,42 @@ export interface AgentConfigTrashEntry {
deletedAt: number;
}
+/** What a vendor's "add this to Gateway" link carries. */
+export type ImportResource = "provider" | "mcp" | "prompt" | "skill";
+
+export interface ImportLink {
+ resource: ImportResource;
+ provider?: Provider;
+ mcp?: McpImport;
+ prompt?: PromptImport;
+ skill?: SkillImport;
+}
+
+/** One or more MCP servers, as the JSON block the link carried. */
+export interface McpImport {
+ name: string;
+ config: string;
+ /** The agents the link asks for, as ids of the agents on this host. */
+ targets: string[];
+ /** The apps it named that Gateway does not manage. */
+ unknown: string[];
+}
+
+export interface PromptImport {
+ name: string;
+ description: string;
+ content: string;
+ targets: string[];
+ unknown: string[];
+}
+
+export interface SkillImport {
+ name: string;
+ repo: string;
+ ref: string;
+ subdir: string;
+}
+
/** Where a skill source's content comes from. */
export type SkillSourceKind = "github" | "archive" | "upload" | "local";
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]