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]

Reply via email to