This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit aedf45aad7a689f3aa467bbba55cb1cadcbb48e7
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 5 01:18:18 2026 +0800

    feat: bring a whole switch installation over in one go
---
 agentconfig/mcp_parse.go                    | 109 +++++
 ccswitch/ccswitch.go                        | 127 ++++++
 ccswitch/db.go                              | 165 +++++++
 ccswitch/legacy.go                          | 102 +++++
 controllers/ccswitch.go                     |  59 +++
 object/ccswitch.go                          | 509 ++++++++++++++++++++++
 object/ccswitch_settings.go                 | 234 ++++++++++
 routers/router.go                           |   5 +
 web/src/backend/CcSwitchBackend.ts          |  30 ++
 web/src/components/CcSwitchBanner.tsx       | 129 ++++++
 web/src/components/CcSwitchImportCard.tsx   | 418 ++++++++++++++++++
 web/src/components/ProviderSourcePicker.tsx |  22 +-
 web/src/locales/en/data.json                |  62 ++-
 web/src/locales/zh/data.json                |  60 ++-
 web/src/nav.ts                              |   9 +-
 web/src/pages/HomePage.tsx                  | 651 ++++++++++++++--------------
 web/src/pages/ImportLinkPage.tsx            |  68 +--
 web/src/pages/ProviderListPage.tsx          |   4 +-
 web/src/types.ts                            |  88 ++++
 19 files changed, 2463 insertions(+), 388 deletions(-)

diff --git a/agentconfig/mcp_parse.go b/agentconfig/mcp_parse.go
new file mode 100644
index 0000000..ed51c2b
--- /dev/null
+++ b/agentconfig/mcp_parse.go
@@ -0,0 +1,109 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentconfig
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "strings"
+)
+
+// ParseMcpServer reads one MCP server written the way every host writes one: a
+// block with a command or a URL in it. The block a tool hands over is 
sometimes
+// wrapped in the "mcpServers" object of a whole configuration file, so a 
wrapper
+// naming exactly one server is unwrapped and its key taken as the name.
+func ParseMcpServer(raw string) (*McpRequest, error) {
+       text := strings.TrimSpace(raw)
+       if text == "" {
+               return nil, errors.New("this MCP server carries no 
configuration")
+       }
+
+       block := map[string]any{}
+       if err := json.Unmarshal([]byte(text), &block); err != nil {
+               return nil, fmt.Errorf("this is not an MCP server: %w", err)
+       }
+
+       name := ""
+       if wrapped, key, ok := unwrapMcpServers(block); ok {
+               block, name = wrapped, key
+       }
+
+       request := &McpRequest{
+               Name:    name,
+               Command: stringField(block, "command"),
+               Args:    textList(block["args"]),
+               Env:     textPairs(block["env"]),
+               Url:     stringField(block, "url", "serverUrl", "endpoint"),
+               Headers: textPairs(block["headers"]),
+       }
+
+       // A block can also declare "sse" or "streamable-http", which are both 
a URL
+       // reached over HTTP, so what it carries decides rather than what it 
says.
+       switch {
+       case request.Command != "" && transportOf(block) != TransportHttp:
+               request.Transport = TransportStdio
+       case request.Url != "":
+               request.Transport = TransportHttp
+       default:
+               return nil, errors.New("this MCP server names neither a command 
to run nor a URL")
+       }
+       return request, nil
+}
+
+// unwrapMcpServers takes the single server out of a configuration file, and
+// says so. More than one is left wrapped: which of them was meant is not this
+// function's to guess.
+func unwrapMcpServers(block map[string]any) (map[string]any, string, bool) {
+       for _, key := range []string{"mcpServers", "servers", "mcp_servers"} {
+               servers, ok := block[key].(map[string]any)
+               if !ok || len(servers) != 1 {
+                       continue
+               }
+               for name, server := range servers {
+                       if entry, ok := server.(map[string]any); ok {
+                               return entry, name, true
+                       }
+               }
+       }
+       return block, "", false
+}
+
+func textList(value any) []string {
+       values, _ := value.([]any)
+       list := make([]string, 0, len(values))
+       for _, held := range values {
+               if text, ok := held.(string); ok {
+                       list = append(list, text)
+               }
+       }
+       return list
+}
+
+// textPairs keeps the settings that are plain strings.
+func textPairs(value any) map[string]string {
+       block, ok := value.(map[string]any)
+       if !ok {
+               return nil
+       }
+
+       pairs := map[string]string{}
+       for key, held := range block {
+               if text, ok := held.(string); ok {
+                       pairs[key] = text
+               }
+       }
+       return pairs
+}
diff --git a/ccswitch/ccswitch.go b/ccswitch/ccswitch.go
new file mode 100644
index 0000000..53e7b54
--- /dev/null
+++ b/ccswitch/ccswitch.go
@@ -0,0 +1,127 @@
+// 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 ccswitch reads what CC Switch keeps on this machine, so somebody
+// moving to Gateway brings their providers, MCP servers, instructions and 
skill
+// sources over instead of typing them again. Nothing here writes: CC Switch
+// stays installed and unchanged.
+package ccswitch
+
+import (
+       "os"
+       "path/filepath"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// dirName is the folder CC Switch keeps everything in, under the home 
directory
+// on every platform it runs on.
+const dirName = ".cc-switch"
+
+const (
+       databaseName = "cc-switch.db"
+       // legacyName is the single file CC Switch kept before it moved to 
SQLite.
+       legacyName = "config.json"
+)
+
+// Provider is one entry of CC Switch's provider list. Settings is the block it
+// writes into the agent's own configuration file, in that agent's format, 
which
+// is where the endpoint and the key are.
+type Provider struct {
+       Id      string
+       App     string
+       Name    string
+       Website string
+       Notes   string
+       // Current marks the one CC Switch has applied for its app right now.
+       Current  bool
+       Settings string
+}
+
+// McpServer is one server of CC Switch's shared list, with the apps it was
+// switched on for.
+type McpServer struct {
+       Id     string
+       Name   string
+       Config string
+       Apps   []string
+}
+
+// Prompt is one set of instructions CC Switch keeps for an app. There is a
+// library of them per app there and a single file per agent here, so only the
+// enabled one has a place to go.
+type Prompt struct {
+       Id      string
+       App     string
+       Name    string
+       Content string
+       Enabled bool
+}
+
+// SkillRepo is a GitHub repository CC Switch installs skills from.
+type SkillRepo struct {
+       Owner  string
+       Name   string
+       Branch string
+}
+
+// Store is one CC Switch installation, read.
+type Store struct {
+       // Path is the folder it was read from, reported whether or not it is 
there.
+       Path  string
+       Found bool
+       // Legacy marks a store still kept in config.json rather than the 
database.
+       Legacy     bool
+       Providers  []*Provider
+       Mcps       []*McpServer
+       Prompts    []*Prompt
+       SkillRepos []*SkillRepo
+}
+
+// Dir is where CC Switch keeps its data for one account.
+func Dir(owner string) (string, error) {
+       home, err := agenthome.Resolve(owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, dirName), nil
+}
+
+// Read collects everything one account's CC Switch holds. A missing
+// installation is not an error: the answer is a store that was not found, 
which
+// is what the page says.
+func Read(owner string) (*Store, error) {
+       dir, err := Dir(owner)
+       if err != nil {
+               return nil, err
+       }
+
+       store := &Store{Path: dir}
+       if info, err := os.Stat(dir); err != nil || !info.IsDir() {
+               return store, nil
+       }
+       store.Found = true
+
+       if err := readDatabase(filepath.Join(dir, databaseName), store); err != 
nil {
+               return nil, err
+       }
+       // The database is the whole store once CC Switch has migrated to it, 
so the
+       // file it kept before is only read when that migration never happened.
+       if len(store.Providers) == 0 {
+               if err := readLegacy(filepath.Join(dir, legacyName), store); 
err != nil {
+                       return nil, err
+               }
+       }
+       return store, nil
+}
diff --git a/ccswitch/db.go b/ccswitch/db.go
new file mode 100644
index 0000000..0314f8b
--- /dev/null
+++ b/ccswitch/db.go
@@ -0,0 +1,165 @@
+// 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 ccswitch
+
+import (
+       "database/sql"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+
+       _ "modernc.org/sqlite" // read another tool's own database
+)
+
+// mcpAppPrefix starts the per-app switches on an MCP server. The app is the
+// part after it, so a column CC Switch adds for a new app is read without this
+// file changing.
+const mcpAppPrefix = "enabled_"
+
+// readDatabase fills the store from CC Switch's SQLite database. It is opened
+// read-only, and a table this version of CC Switch does not have is read as
+// empty rather than as a failure: the schema grows with its releases.
+func readDatabase(path string, store *Store) error {
+       if _, err := os.Stat(path); err != nil {
+               return nil
+       }
+
+       db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+       if err != nil {
+               return err
+       }
+       defer db.Close()
+
+       store.Providers = readProviderRows(db)
+       store.Mcps = readMcpRows(db)
+       store.Prompts = readPromptRows(db)
+       store.SkillRepos = readSkillRepoRows(db)
+       return nil
+}
+
+func readProviderRows(db *sql.DB) []*Provider {
+       providers := []*Provider{}
+       for _, row := range rowsOf(db, "providers") {
+               providers = append(providers, &Provider{
+                       Id:       row["id"],
+                       App:      row["app_type"],
+                       Name:     row["name"],
+                       Website:  row["website_url"],
+                       Notes:    row["notes"],
+                       Current:  isTrue(row["is_current"]),
+                       Settings: row["settings_config"],
+               })
+       }
+       return providers
+}
+
+func readMcpRows(db *sql.DB) []*McpServer {
+       servers := []*McpServer{}
+       for _, row := range rowsOf(db, "mcp_servers") {
+               apps := []string{}
+               for column, value := range row {
+                       if strings.HasPrefix(column, mcpAppPrefix) && 
isTrue(value) {
+                               apps = append(apps, strings.TrimPrefix(column, 
mcpAppPrefix))
+                       }
+               }
+               sort.Strings(apps)
+
+               servers = append(servers, &McpServer{
+                       Id:     row["id"],
+                       Name:   row["name"],
+                       Config: row["server_config"],
+                       Apps:   apps,
+               })
+       }
+       return servers
+}
+
+func readPromptRows(db *sql.DB) []*Prompt {
+       prompts := []*Prompt{}
+       for _, row := range rowsOf(db, "prompts") {
+               prompts = append(prompts, &Prompt{
+                       Id:      row["id"],
+                       App:     row["app_type"],
+                       Name:    row["name"],
+                       Content: row["content"],
+                       Enabled: isTrue(row["enabled"]),
+               })
+       }
+       return prompts
+}
+
+func readSkillRepoRows(db *sql.DB) []*SkillRepo {
+       repos := []*SkillRepo{}
+       for _, row := range rowsOf(db, "skill_repos") {
+               if !isTrue(row["enabled"]) {
+                       continue
+               }
+               repos = append(repos, &SkillRepo{
+                       Owner:  row["owner"],
+                       Name:   row["name"],
+                       Branch: row["branch"],
+               })
+       }
+       return repos
+}
+
+// rowsOf reads one table as a row per map of column name to value. The columns
+// come from the result rather than from the query because CC Switch has added
+// some over its releases, and a store written by any of them should still be
+// readable here.
+func rowsOf(db *sql.DB, table string) []map[string]string {
+       rows, err := db.Query("SELECT * FROM " + table)
+       if err != nil {
+               return nil
+       }
+       defer rows.Close()
+
+       columns, err := rows.Columns()
+       if err != nil {
+               return nil
+       }
+
+       read := []map[string]string{}
+       for rows.Next() {
+               cells := make([]any, len(columns))
+               for index := range cells {
+                       cells[index] = &sql.NullString{}
+               }
+               if err := rows.Scan(cells...); err != nil {
+                       return read
+               }
+
+               row := map[string]string{}
+               for index, name := range columns {
+                       if cell := cells[index].(*sql.NullString); cell.Valid {
+                               row[name] = cell.String
+                       }
+               }
+               read = append(read, row)
+       }
+       return read
+}
+
+// isTrue reads the booleans, which SQLite stores as 0 and 1 and the legacy 
file
+// as JSON.
+func isTrue(value string) bool {
+       switch strings.ToLower(strings.TrimSpace(value)) {
+       case "1", "true":
+               return true
+       default:
+               return false
+       }
+}
diff --git a/ccswitch/legacy.go b/ccswitch/legacy.go
new file mode 100644
index 0000000..79e4728
--- /dev/null
+++ b/ccswitch/legacy.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 ccswitch
+
+import (
+       "encoding/json"
+       "fmt"
+       "os"
+       "sort"
+)
+
+// legacyFile is config.json, which CC Switch kept its whole provider list in
+// before the SQLite database. Its first version held one app's providers at 
the
+// top level; the second moved them under the app they belong to.
+type legacyFile struct {
+       Providers map[string]legacyProvider `json:"providers"`
+       Current   string                    `json:"current"`
+       Apps      map[string]legacyApp      `json:"apps"`
+}
+
+type legacyApp struct {
+       Providers map[string]legacyProvider `json:"providers"`
+       Current   string                    `json:"current"`
+}
+
+type legacyProvider struct {
+       Id             string          `json:"id"`
+       Name           string          `json:"name"`
+       WebsiteUrl     string          `json:"websiteUrl"`
+       SettingsConfig json.RawMessage `json:"settingsConfig"`
+}
+
+// readLegacy fills the store from config.json. A file that is not there, or
+// carries nothing this understands, leaves the store as it was.
+func readLegacy(path string, store *Store) error {
+       data, err := os.ReadFile(path)
+       if os.IsNotExist(err) {
+               return nil
+       }
+       if err != nil {
+               return err
+       }
+
+       file := &legacyFile{}
+       if err := json.Unmarshal(data, file); err != nil {
+               return fmt.Errorf("read %s: %w", path, err)
+       }
+
+       apps := map[string]legacyApp{}
+       // The one app the first version knew was Claude Code.
+       if len(file.Providers) > 0 {
+               apps["claude"] = legacyApp{Providers: file.Providers, Current: 
file.Current}
+       }
+       for app, held := range file.Apps {
+               apps[app] = held
+       }
+
+       names := make([]string, 0, len(apps))
+       for app := range apps {
+               names = append(names, app)
+       }
+       sort.Strings(names)
+
+       for _, app := range names {
+               held := apps[app]
+               ids := make([]string, 0, len(held.Providers))
+               for id := range held.Providers {
+                       ids = append(ids, id)
+               }
+               sort.Strings(ids)
+
+               for _, id := range ids {
+                       entry := held.Providers[id]
+                       if entry.Id == "" {
+                               entry.Id = id
+                       }
+                       store.Providers = append(store.Providers, &Provider{
+                               Id:       entry.Id,
+                               App:      app,
+                               Name:     entry.Name,
+                               Website:  entry.WebsiteUrl,
+                               Current:  entry.Id == held.Current,
+                               Settings: string(entry.SettingsConfig),
+                       })
+               }
+       }
+
+       store.Legacy = len(store.Providers) > 0
+       return nil
+}
diff --git a/controllers/ccswitch.go b/controllers/ccswitch.go
new file mode 100644
index 0000000..6a6fa3a
--- /dev/null
+++ b/controllers/ccswitch.go
@@ -0,0 +1,59 @@
+// 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"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// GetCcSwitchImport reads the CC Switch installation on this machine and
+// answers with what Gateway would make of it. Nothing is written and the keys
+// are masked: this is the list the page shows before anything is brought over.
+func (c *ApiController) GetCcSwitchImport() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       found, err := object.ScanCcSwitch(c.GetSessionUsername(), 
c.Input().Get("account"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(found)
+}
+
+// ImportCcSwitch brings over the entries the page ticked. The values come from
+// CC Switch's own store rather than from the request, so no key travels 
through
+// the browser to get here.
+func (c *ApiController) ImportCcSwitch() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var selection object.CcSwitchSelection
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &selection); err != 
nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       result, err := object.ImportCcSwitch(c.GetSessionUsername(), &selection)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(result)
+}
diff --git a/object/ccswitch.go b/object/ccswitch.go
new file mode 100644
index 0000000..28dacda
--- /dev/null
+++ b/object/ccswitch.go
@@ -0,0 +1,509 @@
+// 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 (
+       "fmt"
+       "net/url"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agentconfig"
+       "github.com/apache/casbin-gateway/ccswitch"
+)
+
+// CcSwitchImport is a CC Switch installation on this machine, read and ready 
to
+// be brought over. It is a preview: every key in it is masked, and nothing is
+// written until ImportCcSwitch is called with the keys to keep.
+type CcSwitchImport struct {
+       Found bool   `json:"found"`
+       Path  string `json:"path"`
+       // Legacy marks a store CC Switch still keeps in config.json.
+       Legacy    bool                `json:"legacy"`
+       Providers []*CcSwitchProvider `json:"providers"`
+       Mcps      []*CcSwitchMcp      `json:"mcps"`
+       Prompts   []*CcSwitchPrompt   `json:"prompts"`
+       Skills    []*CcSwitchSkill    `json:"skills"`
+       // Skipped are the entries there is nothing to bring over from, said out
+       // loud so a shorter list than CC Switch shows is not a surprise.
+       Skipped []*CcSwitchSkipped `json:"skipped"`
+}
+
+// CcSwitchProvider is one provider as Gateway would store it. Key is what a
+// selection names it by.
+type CcSwitchProvider struct {
+       Key string `json:"key"`
+       App string `json:"app"`
+       // Current marks the one CC Switch has applied for its app right now.
+       Current bool `json:"current"`
+       // Taken marks a provider already here under the same name, which is 
added
+       // beside it rather than over it.
+       Taken    bool      `json:"taken"`
+       Provider *Provider `json:"provider"`
+}
+
+// CcSwitchMcp is one MCP server of CC Switch's shared list, with the agents 
its
+// apps resolve to here.
+type CcSwitchMcp struct {
+       Key     string   `json:"key"`
+       Name    string   `json:"name"`
+       Config  string   `json:"config"`
+       Targets []string `json:"targets"`
+       Unknown []string `json:"unknown"`
+}
+
+// CcSwitchPrompt is one set of instructions. CC Switch keeps a library of them
+// per app and an agent reads a single file, so only the one CC Switch has
+// switched on has somewhere to go, and importing it writes over that file.
+type CcSwitchPrompt struct {
+       Key     string   `json:"key"`
+       Name    string   `json:"name"`
+       App     string   `json:"app"`
+       Content string   `json:"content"`
+       Targets []string `json:"targets"`
+       Unknown []string `json:"unknown"`
+}
+
+// CcSwitchSkill is a repository skills are installed from.
+type CcSwitchSkill struct {
+       Key  string `json:"key"`
+       Repo string `json:"repo"`
+       Ref  string `json:"ref"`
+}
+
+// CcSwitchSkipped is one entry that was left out, and why.
+type CcSwitchSkipped struct {
+       Name   string `json:"name"`
+       App    string `json:"app"`
+       Reason string `json:"reason"`
+}
+
+// CcSwitchSelection is what an import was asked to bring over, by the keys the
+// scan gave out. Nothing that is not named here is written.
+type CcSwitchSelection struct {
+       // Account is the machine account CC Switch is installed under, empty 
for
+       // the one Gateway runs as.
+       Account   string   `json:"account"`
+       Providers []string `json:"providers"`
+       Mcps      []string `json:"mcps"`
+       Prompts   []string `json:"prompts"`
+       Skills    []string `json:"skills"`
+       // Overwrite replaces an MCP server the agent already has under the same
+       // name, rather than leaving it alone.
+       Overwrite bool `json:"overwrite"`
+}
+
+// CcSwitchOutcome is what the import did with one entry, in the vocabulary a
+// configuration copy is reported in.
+type CcSwitchOutcome struct {
+       Key    string `json:"key"`
+       Name   string `json:"name"`
+       Action string `json:"action"`
+       Reason string `json:"reason,omitempty"`
+       // Agent names the agent it was written into, for the entries that go 
into
+       // one rather than into Gateway itself.
+       Agent string `json:"agent,omitempty"`
+}
+
+// CcSwitchResult is one import, entry by entry.
+type CcSwitchResult struct {
+       Providers []*CcSwitchOutcome `json:"providers"`
+       Mcps      []*CcSwitchOutcome `json:"mcps"`
+       Prompts   []*CcSwitchOutcome `json:"prompts"`
+       Skills    []*CcSwitchOutcome `json:"skills"`
+}
+
+// ScanCcSwitch reads what CC Switch holds for one machine account and answers
+// with what Gateway would make of it. owner is who the providers would belong
+// to here; account is whose home the installation is in.
+func ScanCcSwitch(owner string, account string) (*CcSwitchImport, error) {
+       store, err := ccswitch.Read(account)
+       if err != nil {
+               return nil, err
+       }
+
+       found := &CcSwitchImport{
+               Found:     store.Found,
+               Path:      store.Path,
+               Legacy:    store.Legacy,
+               Providers: []*CcSwitchProvider{},
+               Mcps:      []*CcSwitchMcp{},
+               Prompts:   []*CcSwitchPrompt{},
+               Skills:    []*CcSwitchSkill{},
+               Skipped:   []*CcSwitchSkipped{},
+       }
+       if !store.Found {
+               return found, nil
+       }
+
+       for _, entry := range store.Providers {
+               provider, reason := ccSwitchProvider(owner, entry)
+               if provider == nil {
+                       found.Skipped = append(found.Skipped, &CcSwitchSkipped{
+                               Name:   ccSwitchName(entry),
+                               App:    entry.App,
+                               Reason: reason,
+                       })
+                       continue
+               }
+
+               taken, err := getProvider(owner, provider.Name)
+               if err != nil {
+                       return nil, err
+               }
+               found.Providers = append(found.Providers, &CcSwitchProvider{
+                       Key:      ccSwitchProviderKey(entry),
+                       App:      entry.App,
+                       Current:  entry.Current,
+                       Taken:    taken != nil,
+                       Provider: GetMaskedProvider(provider),
+               })
+       }
+
+       for _, server := range store.Mcps {
+               targets, unknown := linkTargets(server.Apps)
+               found.Mcps = append(found.Mcps, &CcSwitchMcp{
+                       Key:     server.Id,
+                       Name:    ccSwitchMcpName(server),
+                       Config:  server.Config,
+                       Targets: targets,
+                       Unknown: unknown,
+               })
+       }
+
+       for _, prompt := range store.Prompts {
+               if !prompt.Enabled || strings.TrimSpace(prompt.Content) == "" {
+                       continue
+               }
+               targets, unknown := linkTargets([]string{prompt.App})
+               found.Prompts = append(found.Prompts, &CcSwitchPrompt{
+                       Key:     ccSwitchPromptKey(prompt),
+                       Name:    prompt.Name,
+                       App:     prompt.App,
+                       Content: prompt.Content,
+                       Targets: targets,
+                       Unknown: unknown,
+               })
+       }
+
+       for _, repo := range store.SkillRepos {
+               found.Skills = append(found.Skills, &CcSwitchSkill{
+                       Key:  ccSwitchSkillKey(repo),
+                       Repo: ccSwitchSkillKey(repo),
+                       Ref:  repo.Branch,
+               })
+       }
+       return found, nil
+}
+
+// ImportCcSwitch brings over what the selection names. One entry that cannot 
be
+// written does not stop the rest: a half-finished import is reported entry by
+// entry rather than rolled back, since what it writes is other tools' files.
+func ImportCcSwitch(owner string, selection *CcSwitchSelection) 
(*CcSwitchResult, error) {
+       store, err := ccswitch.Read(selection.Account)
+       if err != nil {
+               return nil, err
+       }
+       if !store.Found {
+               return nil, fmt.Errorf("no CC Switch installation was found at 
%s", store.Path)
+       }
+
+       result := &CcSwitchResult{
+               Providers: []*CcSwitchOutcome{},
+               Mcps:      []*CcSwitchOutcome{},
+               Prompts:   []*CcSwitchOutcome{},
+               Skills:    []*CcSwitchOutcome{},
+       }
+       importCcSwitchProviders(owner, store, selection, result)
+       importCcSwitchMcps(store, selection, result)
+       importCcSwitchPrompts(store, selection, result)
+       importCcSwitchSkills(store, selection, result)
+       return result, nil
+}
+
+func importCcSwitchProviders(owner string, store *ccswitch.Store, selection 
*CcSwitchSelection, result *CcSwitchResult) {
+       wanted := asSet(selection.Providers)
+       for _, entry := range store.Providers {
+               key := ccSwitchProviderKey(entry)
+               if !wanted[key] {
+                       continue
+               }
+
+               outcome := &CcSwitchOutcome{Key: key, Name: ccSwitchName(entry)}
+               result.Providers = append(result.Providers, outcome)
+
+               provider, reason := ccSwitchProvider(owner, entry)
+               if provider == nil {
+                       outcome.Action, outcome.Reason = 
agentconfig.ActionSkip, reason
+                       continue
+               }
+               if _, err := AddProvider(provider); err != nil {
+                       outcome.Action, outcome.Reason = 
agentconfig.ActionFailed, err.Error()
+                       continue
+               }
+               // AddProvider settles on a free name, which is the one to 
report: it is
+               // what the provider is called in the listing afterwards.
+               outcome.Action, outcome.Name = agentconfig.ActionCreate, 
provider.DisplayName
+       }
+}
+
+func importCcSwitchMcps(store *ccswitch.Store, selection *CcSwitchSelection, 
result *CcSwitchResult) {
+       wanted := asSet(selection.Mcps)
+       for _, server := range store.Mcps {
+               if !wanted[server.Id] {
+                       continue
+               }
+
+               name := ccSwitchMcpName(server)
+               targets, _ := linkTargets(server.Apps)
+               if len(targets) == 0 {
+                       result.Mcps = append(result.Mcps, &CcSwitchOutcome{
+                               Key: server.Id, Name: name,
+                               Action: agentconfig.ActionSkip,
+                               Reason: "none of the apps this server was 
switched on for is managed here",
+                       })
+                       continue
+               }
+
+               request, err := ccSwitchMcpRequest(selection.Account, name, 
targets, server.Config)
+               if err != nil {
+                       result.Mcps = append(result.Mcps, &CcSwitchOutcome{
+                               Key: server.Id, Name: name,
+                               Action: agentconfig.ActionFailed, Reason: 
err.Error(),
+                       })
+                       continue
+               }
+               request.Overwrite = selection.Overwrite
+
+               written, err := agentconfig.AddMcp(*request)
+               if err != nil {
+                       result.Mcps = append(result.Mcps, &CcSwitchOutcome{
+                               Key: server.Id, Name: name,
+                               Action: agentconfig.ActionFailed, Reason: 
err.Error(),
+                       })
+                       continue
+               }
+               for _, item := range written {
+                       result.Mcps = append(result.Mcps, &CcSwitchOutcome{
+                               Key: server.Id, Name: item.Name,
+                               Action: item.Action, Reason: item.Reason, 
Agent: item.AgentId,
+                       })
+               }
+       }
+}
+
+func importCcSwitchPrompts(store *ccswitch.Store, selection 
*CcSwitchSelection, result *CcSwitchResult) {
+       wanted := asSet(selection.Prompts)
+       for _, prompt := range store.Prompts {
+               key := ccSwitchPromptKey(prompt)
+               if !wanted[key] {
+                       continue
+               }
+
+               targets, _ := linkTargets([]string{prompt.App})
+               for _, agentId := range targets {
+                       outcome := &CcSwitchOutcome{Key: key, Name: 
prompt.Name, Agent: agentId}
+                       result.Prompts = append(result.Prompts, outcome)
+
+                       if _, err := agentconfig.SavePrompt(agentId, 
selection.Account, prompt.Content); err != nil {
+                               outcome.Action, outcome.Reason = 
agentconfig.ActionFailed, err.Error()
+                               continue
+                       }
+                       outcome.Action = agentconfig.ActionOverwrite
+               }
+       }
+}
+
+func importCcSwitchSkills(store *ccswitch.Store, selection *CcSwitchSelection, 
result *CcSwitchResult) {
+       wanted := asSet(selection.Skills)
+       for _, repo := range store.SkillRepos {
+               key := ccSwitchSkillKey(repo)
+               if !wanted[key] {
+                       continue
+               }
+
+               outcome := &CcSwitchOutcome{Key: key, Name: key}
+               result.Skills = append(result.Skills, outcome)
+
+               source, err := agentconfig.AddSource(selection.Account, 
&agentconfig.SkillSource{
+                       Kind: agentconfig.SourceGithub,
+                       Url:  key,
+                       Ref:  repo.Branch,
+               })
+               if err != nil {
+                       outcome.Action, outcome.Reason = 
agentconfig.ActionFailed, err.Error()
+                       continue
+               }
+               outcome.Action, outcome.Name = agentconfig.ActionCreate, 
source.Name
+       }
+}
+
+// ccSwitchProvider turns one CC Switch entry into the provider Gateway would
+// store. The second value says why an entry that cannot become one was left
+// out, which the page shows beside it.
+func ccSwitchProvider(owner string, entry *ccswitch.Provider) (*Provider, 
string) {
+       endpoint := readCcSwitchSettings(entry.Settings)
+       if endpoint.baseUrl == "" && endpoint.apiKey == "" {
+               return nil, "this entry signs in to the vendor's own account 
instead of carrying an endpoint and a key"
+       }
+       if endpoint.baseUrl != "" {
+               if err := validateBaseUrl(endpoint.baseUrl); err != nil {
+                       return nil, err.Error()
+               }
+       }
+
+       name := ccSwitchName(entry)
+       return &Provider{
+               Owner:       owner,
+               Name:        ccSwitchSlug(name, entry.Id),
+               DisplayName: name,
+               Type:        ccSwitchProviderType(entry.App, endpoint),
+               Status:      "enabled",
+               AuthMode:    ProviderAuthProvider,
+               BaseUrl:     endpoint.baseUrl,
+               ApiKey:      endpoint.apiKey,
+               Models:      endpoint.models,
+               // CC Switch's own icon field names one of its icons rather 
than a site,
+               // so the vendor's page is what a favicon can be read from.
+               Icon:  strings.TrimSpace(entry.Website),
+               Notes: strings.TrimSpace(entry.Notes),
+       }, ""
+}
+
+// ccSwitchProviderType picks the wire format. The Claude apps speak 
Anthropic's;
+// among the rest only OpenAI's own endpoint serves the Responses API, and a
+// vendor that resells it behind an OpenAI-compatible URL does not.
+func ccSwitchProviderType(app string, endpoint ccSwitchEndpoint) string {
+       if ccSwitchProtocol(app) == ProtocolAnthropic {
+               return "anthropic"
+       }
+       if endpoint.responses || ccSwitchIsOpenAi(endpoint.baseUrl) {
+               return "openai"
+       }
+       return "custom"
+}
+
+// ccSwitchProtocol is the wire format one CC Switch app speaks. An app it 
grows
+// support for after this was written is taken as OpenAI-compatible, which 
every
+// one but Claude's is.
+func ccSwitchProtocol(app string) string {
+       if protocol, known := providerLinkApps[app]; known {
+               return protocol
+       }
+       if strings.HasPrefix(app, "claude") {
+               return ProtocolAnthropic
+       }
+       return ProtocolOpenAi
+}
+
+func ccSwitchIsOpenAi(baseUrl string) bool {
+       parsed, err := url.Parse(baseUrl)
+       if err != nil {
+               return false
+       }
+       host := strings.ToLower(parsed.Hostname())
+       return host == "openai.com" || strings.HasSuffix(host, ".openai.com")
+}
+
+// ccSwitchMcpRequest reads the server block CC Switch stores, which is the 
same
+// JSON every host spells a server in.
+func ccSwitchMcpRequest(account string, name string, targets []string, config 
string) (*agentconfig.McpRequest, error) {
+       server, err := agentconfig.ParseMcpServer(config)
+       if err != nil {
+               return nil, err
+       }
+
+       return &agentconfig.McpRequest{
+               Owner:     account,
+               To:        targets,
+               Name:      name,
+               Transport: server.Transport,
+               Command:   server.Command,
+               Args:      server.Args,
+               Env:       server.Env,
+               Url:       server.Url,
+               Headers:   server.Headers,
+       }, nil
+}
+
+func ccSwitchName(entry *ccswitch.Provider) string {
+       if name := strings.TrimSpace(entry.Name); name != "" {
+               return name
+       }
+       return entry.Id
+}
+
+func ccSwitchMcpName(server *ccswitch.McpServer) string {
+       if name := strings.TrimSpace(server.Name); name != "" {
+               return name
+       }
+       return server.Id
+}
+
+// The keys a selection names an entry by. A provider and a prompt are both 
kept
+// per app in CC Switch, so neither id is unique on its own.
+func ccSwitchProviderKey(entry *ccswitch.Provider) string {
+       return entry.App + "/" + entry.Id
+}
+
+func ccSwitchPromptKey(prompt *ccswitch.Prompt) string {
+       return prompt.App + "/" + prompt.Id
+}
+
+func ccSwitchSkillKey(repo *ccswitch.SkillRepo) string {
+       return repo.Owner + "/" + repo.Name
+}
+
+// ccSwitchSlug mirrors the name the add-provider form derives from what was
+// typed. A name written in a script that leaves nothing behind falls back to 
CC
+// Switch's own id, which reads better in a URL than a numbered "provider".
+func ccSwitchSlug(name string, id string) string {
+       slug := slugOf(name)
+       if slug == "" {
+               slug = slugOf(id)
+       }
+       if slug == "" {
+               return "provider"
+       }
+       return slug
+}
+
+func slugOf(value string) string {
+       var builder strings.Builder
+       for _, letter := range strings.ToLower(value) {
+               switch {
+               case letter >= 'a' && letter <= 'z', letter >= '0' && letter <= 
'9':
+                       builder.WriteRune(letter)
+               default:
+                       builder.WriteByte('-')
+               }
+       }
+
+       slug := strings.Trim(builder.String(), "-")
+       for strings.Contains(slug, "--") {
+               slug = strings.ReplaceAll(slug, "--", "-")
+       }
+       if len(slug) > 60 {
+               slug = strings.Trim(slug[:60], "-")
+       }
+       return slug
+}
+
+func asSet(values []string) map[string]bool {
+       set := map[string]bool{}
+       for _, value := range values {
+               set[value] = true
+       }
+       return set
+}
diff --git a/object/ccswitch_settings.go b/object/ccswitch_settings.go
new file mode 100644
index 0000000..8f22548
--- /dev/null
+++ b/object/ccswitch_settings.go
@@ -0,0 +1,234 @@
+// 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/json"
+       "sort"
+       "strings"
+
+       "github.com/BurntSushi/toml"
+)
+
+// CC Switch stores a provider as the block it writes into the agent's own
+// configuration file, so one of these is read in that agent's format rather
+// than in a format of its own. The keys below are what those formats carry an
+// endpoint, a credential and a model name under; the first one an entry sets
+// wins, which is why the credentials are ordered with the agent's preferred 
one
+// first.
+var (
+       ccSwitchBaseUrlKeys = []string{
+               "ANTHROPIC_BASE_URL", "ANTHROPIC_API_BASE",
+               "OPENAI_BASE_URL", "OPENAI_API_BASE",
+               "GOOGLE_GEMINI_BASE_URL", "GEMINI_BASE_URL", 
"GOOGLE_GEMINI_ENDPOINT",
+               "XAI_BASE_URL", "CODE_BASE_URL", "BASE_URL",
+       }
+       ccSwitchApiKeyKeys = []string{
+               "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
+               "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
+               "XAI_API_KEY", "API_KEY", "AUTH_TOKEN",
+       }
+       ccSwitchModelKeys = []string{
+               "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", 
"ANTHROPIC_DEFAULT_SONNET_MODEL",
+               "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL",
+               "OPENAI_MODEL", "GEMINI_MODEL", "MODEL",
+       }
+)
+
+// ccSwitchEndpoint is what one CC Switch entry says: where its requests go,
+// what they carry, and which models it was set up for.
+type ccSwitchEndpoint struct {
+       baseUrl string
+       apiKey  string
+       models  []string
+       // responses records that Codex was told this upstream answers on the 
OpenAI
+       // Responses API, which is what tells a real OpenAI endpoint from the
+       // OpenAI-compatible vendors that stop at chat completions.
+       responses bool
+}
+
+// readCcSwitchSettings reads one entry's stored configuration block. Every
+// shape CC Switch writes is tried against it rather than picked by app, so an
+// app it grows support for is read as far as it looks like one of them.
+func readCcSwitchSettings(raw string) ccSwitchEndpoint {
+       endpoint := ccSwitchEndpoint{}
+
+       document := map[string]any{}
+       if json.Unmarshal([]byte(strings.TrimSpace(raw)), &document) != nil {
+               return endpoint
+       }
+
+       // Claude and Gemini keep an env block, Codex an auth block beside the 
TOML
+       // its endpoint is in.
+       endpoint.readPairs(pairsOf(document["env"]))
+       endpoint.readPairs(pairsOf(document["auth"]))
+       endpoint.readObject(document)
+
+       switch config := document["config"].(type) {
+       case string:
+               endpoint.readConfigText(config)
+       case map[string]any:
+               endpoint.readPairs(pairsOf(config["env"]))
+               endpoint.readObject(config)
+       }
+       if providers, ok := document["provider"].(map[string]any); ok {
+               endpoint.readOpencodeProviders(providers)
+       }
+       return endpoint
+}
+
+// readPairs takes what an environment block sets.
+func (endpoint *ccSwitchEndpoint) readPairs(env map[string]string) {
+       for _, key := range ccSwitchBaseUrlKeys {
+               endpoint.setBaseUrl(env[key])
+       }
+       for _, key := range ccSwitchApiKeyKeys {
+               endpoint.setApiKey(env[key])
+       }
+       for _, key := range ccSwitchModelKeys {
+               endpoint.addModel(env[key])
+       }
+}
+
+// readObject takes the endpoint an app writes at the top of its own file, 
which
+// is how OpenClaw and the older shapes carry one.
+func (endpoint *ccSwitchEndpoint) readObject(document map[string]any) {
+       for _, key := range []string{"baseUrl", "baseURL", "base_url", "url"} {
+               endpoint.setBaseUrl(textOf(document[key]))
+       }
+       for _, key := range []string{"apiKey", "api_key", "token", "key"} {
+               endpoint.setApiKey(textOf(document[key]))
+       }
+       if models, ok := document["models"].([]any); ok {
+               for _, model := range models {
+                       switch named := model.(type) {
+                       case string:
+                               endpoint.addModel(named)
+                       case map[string]any:
+                               endpoint.addModel(textOf(named["id"]))
+                       }
+               }
+       }
+}
+
+// readConfigText reads a whole configuration file kept as text rather than as 
a
+// block. Codex's is TOML; the apps whose own file is JSON keep it as text too.
+func (endpoint *ccSwitchEndpoint) readConfigText(text string) {
+       nested := map[string]any{}
+       if json.Unmarshal([]byte(strings.TrimSpace(text)), &nested) == nil {
+               endpoint.readPairs(pairsOf(nested["env"]))
+               endpoint.readObject(nested)
+               return
+       }
+       endpoint.readCodexConfig(text)
+}
+
+// readCodexConfig reads the config.toml Codex is switched with. The endpoint 
is
+// in the table the file's model_provider names, which is the one CC Switch
+// wrote for this entry.
+func (endpoint *ccSwitchEndpoint) readCodexConfig(text string) {
+       document := map[string]any{}
+       if _, err := toml.Decode(text, &document); err != nil {
+               return
+       }
+
+       providers, _ := document["model_providers"].(map[string]any)
+       selected, _ := document["model_provider"].(string)
+       // A file naming no provider but describing exactly one means that one.
+       if selected == "" && len(providers) == 1 {
+               for name := range providers {
+                       selected = name
+               }
+       }
+       if provider, ok := providers[selected].(map[string]any); ok {
+               endpoint.setBaseUrl(textOf(provider["base_url"]))
+               endpoint.responses = endpoint.responses || 
textOf(provider["wire_api"]) == "responses"
+       }
+       endpoint.addModel(textOf(document["model"]))
+}
+
+// readOpencodeProviders reads opencode.json, where every provider is a block 
of
+// its own with the models it serves under it.
+func (endpoint *ccSwitchEndpoint) readOpencodeProviders(providers 
map[string]any) {
+       names := make([]string, 0, len(providers))
+       for name := range providers {
+               names = append(names, name)
+       }
+       sort.Strings(names)
+
+       for _, name := range names {
+               provider, _ := providers[name].(map[string]any)
+               if options, ok := provider["options"].(map[string]any); ok {
+                       endpoint.readObject(options)
+               }
+               models, _ := provider["models"].(map[string]any)
+               ids := make([]string, 0, len(models))
+               for id := range models {
+                       ids = append(ids, id)
+               }
+               sort.Strings(ids)
+               for _, id := range ids {
+                       endpoint.addModel(id)
+               }
+       }
+}
+
+func (endpoint *ccSwitchEndpoint) setBaseUrl(value string) {
+       if endpoint.baseUrl == "" {
+               endpoint.baseUrl = strings.TrimSpace(value)
+       }
+}
+
+func (endpoint *ccSwitchEndpoint) setApiKey(value string) {
+       if endpoint.apiKey == "" {
+               endpoint.apiKey = strings.TrimSpace(value)
+       }
+}
+
+func (endpoint *ccSwitchEndpoint) addModel(value string) {
+       name := strings.TrimSpace(value)
+       if name == "" || len(name) > maxProviderModelChars || 
len(endpoint.models) >= maxProviderModels {
+               return
+       }
+       for _, existing := range endpoint.models {
+               if existing == name {
+                       return
+               }
+       }
+       endpoint.models = append(endpoint.models, name)
+}
+
+// pairsOf keeps the settings of a block that are plain strings. The rest is
+// whatever else the agent's own file holds — Codex keeps a whole OAuth session
+// beside its API key — and none of it is a value Gateway stores.
+func pairsOf(value any) map[string]string {
+       block, ok := value.(map[string]any)
+       if !ok {
+               return nil
+       }
+
+       pairs := map[string]string{}
+       for key, held := range block {
+               if text, ok := held.(string); ok {
+                       pairs[key] = text
+               }
+       }
+       return pairs
+}
+
+func textOf(value any) string {
+       text, _ := value.(string)
+       return text
+}
diff --git a/routers/router.go b/routers/router.go
index 9dfbcd3..2ccd97b 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -127,6 +127,11 @@ func initAPI() {
        beego.Router("/api/open-import-link", &controllers.ApiController{}, 
"POST:OpenImportLink")
        beego.Router("/api/get-pending-import-link", 
&controllers.ApiController{}, "GET:GetPendingImportLink")
 
+       // Everything a CC Switch installation on this machine holds, brought 
over
+       // in one go by somebody moving to Gateway.
+       beego.Router("/api/get-ccswitch-import", &controllers.ApiController{}, 
"GET:GetCcSwitchImport")
+       beego.Router("/api/import-ccswitch", &controllers.ApiController{}, 
"POST:ImportCcSwitch")
+
        // 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/backend/CcSwitchBackend.ts 
b/web/src/backend/CcSwitchBackend.ts
new file mode 100644
index 0000000..1ca7ab5
--- /dev/null
+++ b/web/src/backend/CcSwitchBackend.ts
@@ -0,0 +1,30 @@
+// 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 {CcSwitchImport, CcSwitchResult, CcSwitchSelection} from "@/types";
+
+/** What CC Switch holds on this machine, with every key masked. */
+export function getCcSwitchImport(account = "") {
+  return 
request<CcSwitchImport>(`/api/get-ccswitch-import?account=${encodeURIComponent(account)}`);
+}
+
+/**
+ * Brings over the entries that were ticked. Only their keys are sent: the 
values
+ * are read from CC Switch's own store on the server, so no credential travels
+ * back through the browser to get here.
+ */
+export function importCcSwitch(selection: CcSwitchSelection) {
+  return request<CcSwitchResult>("/api/import-ccswitch", "POST", selection);
+}
diff --git a/web/src/components/CcSwitchBanner.tsx 
b/web/src/components/CcSwitchBanner.tsx
new file mode 100644
index 0000000..9dc2e72
--- /dev/null
+++ b/web/src/components/CcSwitchBanner.tsx
@@ -0,0 +1,129 @@
+// 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 {Link} from "react-router-dom";
+import {ArrowDownToLine} from "lucide-react";
+import i18next from "i18next";
+
+import * as CcSwitchBackend from "@/backend/CcSwitchBackend";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import type {CcSwitchImport} from "@/types";
+
+const dismissedStorageKey = "ccSwitchBannerDismissed";
+
+function readDismissed(): boolean {
+  try {
+    return localStorage.getItem(dismissedStorageKey) === "true";
+  } catch {
+    return false;
+  }
+}
+
+function writeDismissed() {
+  try {
+    localStorage.setItem(dismissedStorageKey, "true");
+  } catch {
+    // Private-mode storage failures must not take the page down.
+  }
+}
+
+/**
+ * What was found, counted, as "3 providers · 1 MCP server" and so on. Each
+ * count is a phrase of its own rather than a number glued to a noun: English
+ * needs the singular for one of them, and Chinese needs a measure word and no
+ * space around the digit.
+ */
+function summary(found: CcSwitchImport) {
+  const counts: [number, string, string][] = [
+    [found.providers.length, "link:{count} provider", "link:{count} 
providers"],
+    [found.mcps.length, "link:{count} MCP server", "link:{count} MCP servers"],
+    [found.prompts.length, "link:{count} set of instructions", "link:{count} 
sets of instructions"],
+    [found.skills.length, "link:{count} skill source", "link:{count} skill 
sources"],
+  ];
+
+  return counts
+    .filter(([count]) => count > 0)
+    .map(([count, one, many]) => i18next.t(count === 1 ? one : 
many).replace("{count}", `${count}`))
+    .join(" · ");
+}
+
+/**
+ * The one line somebody arriving from CC Switch needs on the home screen: it 
is
+ * installed on this machine, and everything in it can be brought over. It 
shows
+ * only while there is something to bring, so it disappears on its own once the
+ * import is done, and can be dismissed by anyone keeping both tools.
+ */
+export function CcSwitchBanner() {
+  const [found, setFound] = React.useState<CcSwitchImport | null>(null);
+  const [dismissed, setDismissed] = React.useState(readDismissed);
+
+  React.useEffect(() => {
+    if (dismissed) {
+      return;
+    }
+    // A machine without CC Switch is the common case and answers "not found",
+    // so a failure here is nothing to report: the banner simply stays away.
+    CcSwitchBackend.getCcSwitchImport()
+      .then(res => setFound(res.status === "ok" ? res.data : null))
+      .catch(() => setFound(null));
+  }, [dismissed]);
+
+  const total =
+    found === null
+      ? 0
+      : found.providers.length + found.mcps.length + found.prompts.length + 
found.skills.length;
+
+  if (dismissed || found === null || !found.found || total === 0) {
+    return null;
+  }
+
+  return (
+    <MessageAlert
+      variant="info"
+      title={i18next.t("link:CC Switch is installed here")}
+      // Two lines rather than one sentence: the counts read as a list, and
+      // joining them onto the hint would need a separator that is a space in
+      // English and nothing at all in Chinese.
+      description={
+        <>
+          <span className="block">{i18next.t("link:CC Switch is installed here 
hint")}</span>
+          <span className="block font-medium">{summary(found)}</span>
+        </>
+      }
+      action={
+        <div className="flex flex-wrap gap-2">
+          <Button asChild size="sm">
+            <Link to="/import">
+              <ArrowDownToLine />
+              {i18next.t("provider:Bring it all over")}
+            </Link>
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="ghost"
+            onClick={() => {
+              writeDismissed();
+              setDismissed(true);
+            }}
+          >
+            {i18next.t("general:Dismiss")}
+          </Button>
+        </div>
+      }
+    />
+  );
+}
diff --git a/web/src/components/CcSwitchImportCard.tsx 
b/web/src/components/CcSwitchImportCard.tsx
new file mode 100644
index 0000000..c4a6781
--- /dev/null
+++ b/web/src/components/CcSwitchImportCard.tsx
@@ -0,0 +1,418 @@
+// 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 {ArrowDownToLine, Check, ChevronDown, FolderSearch} from "lucide-react";
+import i18next from "i18next";
+
+import * as CcSwitchBackend from "@/backend/CcSwitchBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {ActionBadge} from "@/components/agent-config/action-badge";
+import {CodeText} from "@/components/shared/misc";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+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 {cn} from "@/lib/utils";
+import type {CcSwitchImport, CcSwitchOutcome, CcSwitchResult} from "@/types";
+
+/** The four kinds of entry, which are also the fields of a selection. */
+const kinds = ["providers", "mcps", "prompts", "skills"] as const;
+type Kind = (typeof kinds)[number];
+
+type Selection = Record<Kind, string[]>;
+
+const emptySelection = (): Selection => ({providers: [], mcps: [], prompts: 
[], skills: []});
+
+/**
+ * What is ticked when the list first appears. Everything is, except the
+ * instructions: those are a single file per agent, and importing one writes
+ * over whatever that agent reads today.
+ */
+function defaultSelection(found: CcSwitchImport): Selection {
+  return {
+    providers: found.providers.map(item => item.key),
+    mcps: found.mcps.filter(item => item.targets.length > 0).map(item => 
item.key),
+    prompts: [],
+    skills: found.skills.map(item => item.key),
+  };
+}
+
+function kindTitle(kind: Kind) {
+  return {
+    providers: i18next.t("link:Providers"),
+    mcps: i18next.t("link:MCP servers"),
+    prompts: i18next.t("link:Instructions"),
+    skills: i18next.t("link:Skill sources"),
+  }[kind];
+}
+
+/** One tickable entry: a row that reads as itself, and a tick that follows 
it. */
+function Row({
+  selected,
+  onToggle,
+  icon,
+  title,
+  detail,
+  badges,
+  disabled = false,
+}: {
+  selected: boolean;
+  onToggle: () => void;
+  icon: React.ReactNode;
+  title: React.ReactNode;
+  detail?: React.ReactNode;
+  badges?: React.ReactNode;
+  disabled?: boolean;
+}) {
+  return (
+    <button
+      type="button"
+      disabled={disabled}
+      onClick={onToggle}
+      className={cn(
+        "flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left 
transition-colors",
+        selected ? "border-primary bg-primary/10" : "hover:bg-accent",
+        disabled && "cursor-not-allowed opacity-50",
+      )}
+    >
+      <span
+        className={cn(
+          "flex size-4 shrink-0 items-center justify-center rounded-[4px] 
border",
+          selected ? "border-primary bg-primary text-primary-foreground" : 
"border-input",
+        )}
+      >
+        {selected ? <Check className="size-3" /> : null}
+      </span>
+      <span className="shrink-0">{icon}</span>
+      <span className="min-w-0 flex-1">
+        <span className="flex flex-wrap items-center gap-2 text-sm 
font-medium">
+          {title}
+          {badges}
+        </span>
+        {detail ? <span className="text-muted-foreground block truncate 
text-xs">{detail}</span> : null}
+      </span>
+    </button>
+  );
+}
+
+/** The agents an entry lands on, named by their own marks. */
+function Targets({targets}: {targets: string[]}) {
+  return (
+    <>
+      {targets.map(agentId => (
+        <span key={agentId} className="text-muted-foreground inline-flex 
items-center gap-1 text-xs font-normal">
+          <AgentIcon agent={agentId} size={12} />
+          {agentId}
+        </span>
+      ))}
+    </>
+  );
+}
+
+function Section({title, count, children}: {title: string; count: number; 
children: React.ReactNode}) {
+  if (count === 0) {
+    return null;
+  }
+  return (
+    <div className="grid gap-2">
+      <Label className="text-muted-foreground text-xs font-medium uppercase 
tracking-wide">
+        {title} · {count}
+      </Label>
+      <div className="grid gap-2">{children}</div>
+    </div>
+  );
+}
+
+/** What the import did, entry by entry, once it has run. */
+function Outcomes({title, items}: {title: string; items: CcSwitchOutcome[]}) {
+  if (items.length === 0) {
+    return null;
+  }
+  return (
+    <div className="grid gap-2">
+      <Label className="text-muted-foreground text-xs font-medium uppercase 
tracking-wide">{title}</Label>
+      {items.map((item, index) => (
+        <div key={`${item.key}-${item.agent ?? ""}-${index}`} className="flex 
flex-wrap items-center gap-2 text-sm">
+          <ActionBadge action={item.action} />
+          <span className="truncate">{item.name}</span>
+          {item.agent ? <span className="text-muted-foreground 
text-xs">{item.agent}</span> : null}
+          {item.reason ? <span className="text-muted-foreground truncate 
text-xs">{item.reason}</span> : null}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+/** The entries there was nothing to bring over from, folded away until asked 
for. */
+function Skipped({found}: {found: CcSwitchImport}) {
+  const [open, setOpen] = React.useState(false);
+
+  if (found.skipped.length === 0) {
+    return null;
+  }
+  return (
+    <div className="grid gap-2">
+      <button
+        type="button"
+        onClick={() => setOpen(!open)}
+        className="text-muted-foreground hover:text-foreground flex w-fit 
items-center gap-1 text-xs"
+      >
+        <ChevronDown className={cn("size-3.5 transition-transform", open && 
"rotate-180")} />
+        {i18next.t("link:Left out").replace("{count}", 
`${found.skipped.length}`)}
+      </button>
+      {open
+        ? found.skipped.map((item, index) => (
+          <div key={`${item.app}-${item.name}-${index}`} className="flex 
flex-wrap items-baseline gap-2 text-xs">
+            <span className="font-medium">{item.name}</span>
+            <Badge variant="muted">{item.app}</Badge>
+            <span className="text-muted-foreground">{item.reason}</span>
+          </div>
+        ))
+        : null}
+    </div>
+  );
+}
+
+/**
+ * Everything a CC Switch installation on this machine holds, brought over in 
one
+ * go. The list is a preview: the keys in it are masked, and the import reads 
the
+ * real values from CC Switch's own store, so no credential makes the round 
trip
+ * through this page.
+ *
+ * Instructions start unticked. Everything else is added beside what Gateway
+ * already has, but an agent reads one instruction file and importing one 
writes
+ * over it, so that choice is made rather than inherited.
+ */
+export function CcSwitchImportCard({onImported}: {onImported?: () => void}) {
+  const [found, setFound] = React.useState<CcSwitchImport | null>(null);
+  const [selection, setSelection] = React.useState<Selection>(emptySelection);
+  const [overwrite, setOverwrite] = React.useState(false);
+  const [loading, setLoading] = React.useState(true);
+  const [busy, setBusy] = React.useState(false);
+  const [result, setResult] = React.useState<CcSwitchResult | null>(null);
+  const [error, setError] = React.useState("");
+
+  const scan = React.useCallback(() => {
+    setLoading(true);
+    setError("");
+    setResult(null);
+    CcSwitchBackend.getCcSwitchImport()
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("link:CC Switch cannot be read"));
+          return;
+        }
+        setFound(res.data);
+        setSelection(defaultSelection(res.data));
+      })
+      .catch(err => setError(`${err}`))
+      .then(() => setLoading(false));
+  }, []);
+
+  React.useEffect(scan, [scan]);
+
+  const toggle = (kind: Kind, key: string) => {
+    setSelection(previous => ({
+      ...previous,
+      [kind]: previous[kind].includes(key) ? previous[kind].filter(item => 
item !== key) : [...previous[kind], key],
+    }));
+  };
+
+  const total = kinds.reduce((sum, kind) => sum + selection[kind].length, 0);
+  const everything = found === null ? 0 : kinds.reduce((sum, kind) => sum + 
found[kind].length, 0);
+
+  const selectAll = () => {
+    if (found === null) {
+      return;
+    }
+    setSelection(
+      total === everything
+        ? emptySelection()
+        : {
+          providers: found.providers.map(item => item.key),
+          mcps: found.mcps.map(item => item.key),
+          prompts: found.prompts.map(item => item.key),
+          skills: found.skills.map(item => item.key),
+        },
+    );
+  };
+
+  const runImport = () => {
+    setBusy(true);
+    setError("");
+    CcSwitchBackend.importCcSwitch({...selection, overwrite: overwrite})
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("link:This cannot be imported"));
+          return;
+        }
+        setResult(res.data);
+        const written = kinds.flatMap(kind => res.data[kind]);
+        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"),
+        );
+        onImported?.();
+      })
+      .catch(err => setError(`${err}`))
+      .then(() => setBusy(false));
+  };
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2 text-[15px]">
+          <ArrowDownToLine className="size-4" />
+          {i18next.t("link:Import from CC Switch")}
+        </CardTitle>
+        <CardDescription>{i18next.t("link:Import from CC Switch 
hint")}</CardDescription>
+      </CardHeader>
+      <CardContent className="flex flex-col gap-4">
+        {error === "" ? null : <MessageAlert title={error} />}
+
+        {loading ? (
+          <Loading />
+        ) : found === null || !found.found ? (
+          <MessageAlert
+            variant="info"
+            title={i18next.t("link:No CC Switch here")}
+            description={i18next.t("link:No CC Switch here 
hint").replace("{path}", found?.path ?? "")}
+            action={
+              <Button type="button" size="xs" variant="outline" onClick={scan}>
+                <FolderSearch />
+                {i18next.t("link:Look again")}
+              </Button>
+            }
+          />
+        ) : result !== null ? (
+          <>
+            <Outcomes title={kindTitle("providers")} items={result.providers} 
/>
+            <Outcomes title={kindTitle("mcps")} items={result.mcps} />
+            <Outcomes title={kindTitle("prompts")} items={result.prompts} />
+            <Outcomes title={kindTitle("skills")} items={result.skills} />
+            <div>
+              <Button type="button" variant="outline" onClick={scan}>
+                {i18next.t("link:Read it again")}
+              </Button>
+            </div>
+          </>
+        ) : everything === 0 ? (
+          <>
+            <MessageAlert variant="info" title={i18next.t("link:Nothing to 
import")} />
+            <Skipped found={found} />
+          </>
+        ) : (
+          <>
+            <p className="text-muted-foreground text-xs">
+              <CodeText>{found.path}</CodeText>
+            </p>
+
+            <Section title={kindTitle("providers")} 
count={found.providers.length}>
+              {found.providers.map(item => (
+                <Row
+                  key={item.key}
+                  selected={selection.providers.includes(item.key)}
+                  onToggle={() => toggle("providers", item.key)}
+                  icon={<ProviderIcon baseUrl={item.provider.icon || 
item.provider.baseUrl} size={16} />}
+                  title={item.provider.displayName}
+                  detail={item.provider.baseUrl || item.provider.type}
+                  badges={
+                    <>
+                      <Badge variant="muted">{item.app}</Badge>
+                      {item.current ? <Badge 
variant="info">{i18next.t("link:In use")}</Badge> : null}
+                      {item.taken ? <Badge 
variant="warning">{i18next.t("link:Name taken")}</Badge> : null}
+                    </>
+                  }
+                />
+              ))}
+            </Section>
+
+            <Section title={kindTitle("mcps")} count={found.mcps.length}>
+              {found.mcps.map(item => (
+                <Row
+                  key={item.key}
+                  disabled={item.targets.length === 0}
+                  selected={selection.mcps.includes(item.key)}
+                  onToggle={() => toggle("mcps", item.key)}
+                  icon={<AgentIcon agent={item.targets[0] ?? ""} size={16} />}
+                  title={item.name}
+                  detail={
+                    item.targets.length === 0 ? i18next.t("link:No agent here 
reads this") : undefined
+                  }
+                  badges={<Targets targets={item.targets} />}
+                />
+              ))}
+            </Section>
+
+            <Section title={kindTitle("prompts")} count={found.prompts.length}>
+              {found.prompts.map(item => (
+                <Row
+                  key={item.key}
+                  disabled={item.targets.length === 0}
+                  selected={selection.prompts.includes(item.key)}
+                  onToggle={() => toggle("prompts", item.key)}
+                  icon={<AgentIcon agent={item.targets[0] ?? item.app} 
size={16} />}
+                  title={item.name}
+                  detail={i18next.t("link:This replaces the instructions")}
+                  badges={<Targets targets={item.targets} />}
+                />
+              ))}
+            </Section>
+
+            <Section title={kindTitle("skills")} count={found.skills.length}>
+              {found.skills.map(item => (
+                <Row
+                  key={item.key}
+                  selected={selection.skills.includes(item.key)}
+                  onToggle={() => toggle("skills", item.key)}
+                  icon={<ProviderIcon baseUrl="github.com" size={16} />}
+                  title={item.repo}
+                  detail={item.ref}
+                />
+              ))}
+            </Section>
+
+            <Skipped found={found} />
+
+            {found.mcps.length === 0 ? null : (
+              <div className="flex items-center gap-2">
+                <Switch id="ccswitch-overwrite" checked={overwrite} 
onCheckedChange={setOverwrite} />
+                <Label htmlFor="ccswitch-overwrite" className="text-sm 
font-normal">
+                  {i18next.t("link:Replace what an agent already has")}
+                </Label>
+              </div>
+            )}
+
+            <div className="flex flex-wrap items-center gap-2">
+              <Button type="button" loading={busy} disabled={total === 0} 
onClick={runImport}>
+                <ArrowDownToLine />
+                {i18next.t("link:Import selected").replace("{count}", 
`${total}`)}
+              </Button>
+              <Button type="button" variant="ghost" onClick={selectAll}>
+                {total === everything ? i18next.t("link:Select none") : 
i18next.t("link:Select everything")}
+              </Button>
+            </div>
+          </>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/components/ProviderSourcePicker.tsx 
b/web/src/components/ProviderSourcePicker.tsx
index a7bbf77..2bf4d22 100644
--- a/web/src/components/ProviderSourcePicker.tsx
+++ b/web/src/components/ProviderSourcePicker.tsx
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
-import {KeyRound, Link2, LogIn, Search, Settings2} from "lucide-react";
+import {ArrowDownToLine, KeyRound, Link2, LogIn, Search, Settings2} from 
"lucide-react";
 import i18next from "i18next";
 
 import {ProviderIcon} from "@/components/ProviderIcon";
@@ -97,10 +97,13 @@ function SourceCard({source, onPick}: {source: 
ProviderSource; onPick: (source:
 export function ProviderSourcePicker({
   onPick,
   onLink,
+  onCcSwitch,
 }: {
   onPick: (source: ProviderSource) => void;
   /** A vendor's "add this provider" link, or New API connection info. */
   onLink?: (link: string) => Promise<void>;
+  /** Opens the import page, where a whole CC Switch install is brought over. 
*/
+  onCcSwitch?: () => void;
 }) {
   const [query, setQuery] = React.useState("");
   const [link, setLink] = React.useState("");
@@ -173,6 +176,23 @@ export function ProviderSourcePicker({
         </div>
       )}
 
+      {onCcSwitch === undefined ? null : (
+        <div className="grid gap-1.5 rounded-lg border border-dashed p-3">
+          <span className="flex items-center gap-2 text-sm font-medium">
+            <ArrowDownToLine className="size-4" />
+            {i18next.t("provider:Coming from CC Switch")}
+          </span>
+          <span className="text-muted-foreground text-xs">
+            {i18next.t("provider:Coming from CC Switch hint")}
+          </span>
+          <div>
+            <Button type="button" variant="outline" onClick={onCcSwitch}>
+              {i18next.t("provider:Bring it all over")}
+            </Button>
+          </div>
+        </div>
+      )}
+
       {leading.length === 0 ? null : (
         <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
           {leading.map(source => (
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 18666e2..e16092c 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -891,27 +891,58 @@
   "link": {
     "A place to install skills from": "A place to install skills from",
     "Branch": "Branch",
+    "CC Switch cannot be read": "CC Switch's settings cannot be read",
+    "CC Switch is installed here": "CC Switch is installed on this machine",
+    "CC Switch is installed here hint": "Everything set up there can be 
brought over in one go",
     "Discard": "Discard",
     "Done": "Done",
     "Folder": "Folder",
     "Import": "Import",
     "Import finished with failures": "Some of this could not be imported",
-    "Import from a link": "Import from a link",
-    "Import hint": "What a link carries is shown here first. Nothing is 
written until you say so, because a link like this comes from someone else.",
+    "Import from CC Switch": "Import from CC Switch",
+    "Import from CC Switch hint": "The providers, MCP servers, instructions 
and skill sources CC Switch keeps on this machine. CC Switch is only read, and 
stays as it is.",
+    "Import hint": "Everything CC Switch has on this machine, and whatever a 
vendor's “add this” link carries. Both are shown here first; nothing is written 
until you say so.",
+    "Import selected": "Import {count} selected",
+    "Import settings": "Import settings",
     "Imported": "Imported",
+    "In use": "In use there",
+    "Instructions": "Instructions",
     "Instructions hint": "The instructions this link carries, to be read by an 
agent before every session.",
+    "Left out": "{count} left out",
+    "Look again": "Look again",
+    "MCP servers": "MCP servers",
     "MCP servers from this link": "MCP servers from this link",
     "MCP servers hint": "A server runs as a program on this machine with the 
arguments below. Read them before adding it.",
+    "Name taken": "Name taken",
+    "No CC Switch here": "No CC Switch installation was found",
+    "No CC Switch here hint": "Nothing was read from {path}. Install CC Switch 
under this account, or import what you need from a link below.",
+    "No agent here reads this": "None of the apps it was switched on for is 
managed here",
+    "Nothing to import": "CC Switch is installed here, but holds nothing 
Gateway can take over",
     "Paste a link": "Paste a link",
     "Paste a link hint": "A vendor’s “add this” link, for a provider, an MCP 
server, a set of instructions or a place to install skills from.",
+    "Providers": "Providers",
+    "Read it again": "Read CC Switch again",
     "Read link": "Read link",
+    "Replace what an agent already has": "Replace an MCP server the agent 
already has under the same name",
     "Repository": "Repository",
+    "Select everything": "Select everything",
+    "Select none": "Select none",
     "Skill source hint": "Skills are downloaded from this repository when you 
install one from it. Nothing is installed by adding it.",
+    "Skill sources": "Skill sources",
     "Some apps are not managed here": "Part of this link is for other apps",
     "Some apps are not managed here hint": "The link also names {apps}, which 
Gateway does not manage. The rest of it can still be imported.",
+    "This cannot be imported": "This cannot be imported",
     "This link cannot be read": "This link cannot be read",
     "This replaces the instructions": "This replaces the instructions the 
agent has",
-    "This replaces the instructions hint": "Each agent reads one such file. 
Importing writes over whatever is in it now."
+    "This replaces the instructions hint": "Each agent reads one such file. 
Importing writes over whatever is in it now.",
+    "{count} MCP server": "{count} MCP server",
+    "{count} MCP servers": "{count} MCP servers",
+    "{count} provider": "{count} provider",
+    "{count} providers": "{count} providers",
+    "{count} set of instructions": "{count} set of instructions",
+    "{count} sets of instructions": "{count} sets of instructions",
+    "{count} skill source": "{count} skill source",
+    "{count} skill sources": "{count} skill sources"
   },
   "llm": {
     "All time": "All time",
@@ -1022,28 +1053,20 @@
     "Ask an endpoint of my own": "Ask an endpoint of my own",
     "Asked at": "asked at",
     "Authentication": "Authentication",
-    "Balance": "Balance",
     "Automatic": "Automatic",
+    "Balance": "Balance",
     "Balance URL": "Balance URL",
     "Balance URL hint": "A path, resolved against the provider's own base URL. 
An absolute URL works too, but it has to stay on that same host.",
     "Balance description": "What the vendor says is left on the account this 
provider bills. Gateway knows the endpoint of a few vendors; for the rest, and 
for aggregator sites, name one here.",
+    "Balance headers hint": "One NAME=VALUE per line. {{token}} stands for the 
balance token and {{apiKey}} for the provider's own key, so neither has to be 
written out twice.",
     "Balance source": "Balance source",
     "Balance source hint": "Automatic uses the endpoint Gateway has built in 
for the vendor. Custom endpoint names one yourself. Manual amount tracks a 
figure you enter against recorded spend, for a vendor with no balance API.",
-    "Counting since": "Counting spend since",
-    "Initial amount": "Initial amount",
-    "Manual amount": "Manual amount",
-    "Manual amount hint": "The balance starts at this figure and drops by what 
Gateway records spending through this provider. Spend is priced from the model 
pricing table, so keep that table in the same currency.",
-    "Manual currency hint": "The label the amounts carry. It converts nothing: 
the recorded spend is subtracted as it stands.",
-    "Reset counter": "Reset counter",
-    "Reset counter hint": "Saving moves the start point to now, so the balance 
counts only spend from here on.",
-    "Since the start": "set when you save",
-    "Balance headers hint": "One NAME=VALUE per line. {{token}} stands for the 
balance token and {{apiKey}} for the provider's own key, so neither has to be 
written out twice.",
     "Balance token": "Balance token",
     "Balance token hint": "Only for a vendor whose balance endpoint does not 
take the inference key, as an aggregator's system token does not. Stored like 
the API key and never sent back to the browser.",
     "Balance unavailable": "Balance unavailable",
     "Base URL": "Base URL",
     "Base URL hint": "Requests will be sent to",
-    "Custom base URL hint": "The endpoint of your relay or self-hosted 
service, e.g. https://your-relay.com/v1";,
+    "Bring it all over": "Bring it all over",
     "Caller's own login": "The caller's own login",
     "Change source": "Change",
     "Claude subscription": "Claude subscription",
@@ -1051,14 +1074,18 @@
     "Clear": "Clear",
     "Client auth hint": "Requests are forwarded with the credentials the 
client sent, so an agent already signed in with a subscription keeps its own 
login and no key is stored here. Codex is the exception: its ChatGPT sign-in 
speaks a different API.",
     "Client auth usage hint": "Only the base URL is set: a token here would 
replace the sign-in the client already has.",
+    "Coming from CC Switch": "Coming from CC Switch",
+    "Coming from CC Switch hint": "Bring the providers you set up there over 
in one go, along with its MCP servers, instructions and skill sources.",
     "Connection Failed": "Connection Failed",
     "Connection Successful": "Connection Successful",
     "Connectivity": "Connectivity",
     "Connectivity hint": "A real request to the upstream: the first model is 
asked for one short answer, so a key that does not work, or a model the 
upstream will not serve, is caught here. It costs a few tokens. It also runs 
once when the form is submitted.",
     "Continue draft": "Continue draft",
     "Cooling down": "Cooling down",
+    "Counting since": "Counting spend since",
     "Currency": "Currency",
     "Currency hint": "What the numbers are counted in, after the scale is 
applied.",
+    "Custom base URL hint": "The endpoint of your relay or self-hosted 
service, e.g. https://your-relay.com/v1";,
     "Custom endpoint": "Custom endpoint",
     "Custom endpoint hint": "Leave this off for a vendor Gateway already knows 
the balance endpoint of.",
     "Custom vendor": "Custom",
@@ -1087,6 +1114,10 @@
     "Icon": "Icon",
     "Icon hint": "The site the vendor's icon is taken from, or an image URL. 
Leave it empty to take it from the base URL.",
     "Inference platforms": "Inference platforms",
+    "Initial amount": "Initial amount",
+    "Manual amount": "Manual amount",
+    "Manual amount hint": "The balance starts at this figure and drops by what 
Gateway records spending through this provider. Spend is priced from the model 
pricing table, so keep that table in the same currency.",
+    "Manual currency hint": "The label the amounts carry. It converts nothing: 
the recorded spend is subtracted as it stands.",
     "Model routing hint": "Requests that arrive here are routed by the model 
name they carry, to the enabled provider with the smallest priority value that 
serves it. To send one agent's requests to this provider whatever model they 
name, bind the provider on the agent's page instead.",
     "Model vendors": "Model vendors",
     "Models": "Models",
@@ -1124,12 +1155,15 @@
     "Refresh balances": "Refresh balances",
     "Remaining": "Remaining",
     "Remaining path": "Remaining path",
+    "Reset counter": "Reset counter",
+    "Reset counter hint": "Saving moves the start point to now, so the balance 
counts only spend from here on.",
     "Retried at": "retried at",
     "Save anyway hint": "The upstream did not accept this provider. Fix it, or 
press {action} again to store it as it is.",
     "Scale": "Scale",
     "Scale hint": "Divides every number read, for a vendor that counts in an 
internal unit of its own. New API counts in 1/500000 of a dollar, so its scale 
is 500000.",
     "Search vendors": "Search vendors",
     "Select all": "Select all",
+    "Since the start": "set when you save",
     "Source": "Source",
     "Source hint": "Where this provider gets its credentials. Picking one 
fills in the type, base URL and models, so a subscription needs nothing typed 
and a vendor needs only its key.",
     "Status": "Status",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index d6937ac..1fe1b54 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -963,27 +963,58 @@
   "link": {
     "A place to install skills from": "技能来源",
     "Branch": "分支",
+    "CC Switch cannot be read": "无法读取CC Switch的配置",
+    "CC Switch is installed here": "本机装了CC Switch",
+    "CC Switch is installed here hint": "在那边配好的东西可以一次性搬过来",
     "Discard": "丢弃",
     "Done": "完成",
     "Folder": "子目录",
     "Import": "导入",
     "Import finished with failures": "部分内容未能导入",
-    "Import from a link": "从链接导入",
-    "Import hint": "这里先展示链接携带的内容。在你确认之前不会写入任何东西——这样的链接来自别人。",
+    "Import from CC Switch": "从CC Switch导入",
+    "Import from CC Switch hint": "CC Switch在本机保存的供应商、MCP服务器、指令和技能来源。只读取,CC 
Switch本身不会被改动。",
+    "Import hint": "本机CC 
Switch里的全部配置,以及厂商“一键添加”链接携带的内容。两者都先在这里展示,你确认前不会写入任何东西。",
+    "Import selected": "导入选中的{count}项",
+    "Import settings": "导入配置",
     "Imported": "已导入",
+    "In use": "那边正在用",
+    "Instructions": "指令",
     "Instructions hint": "该链接携带的指令,Agent会在每次会话前读取。",
+    "Left out": "有{count}项未纳入",
+    "Look again": "重新查找",
+    "MCP servers": "MCP服务器",
     "MCP servers from this link": "链接中的MCP服务器",
     "MCP servers hint": "MCP服务器会以下面的参数在本机上运行。添加前请先看清楚。",
+    "Name taken": "名称已占用",
+    "No CC Switch here": "没有找到CC Switch",
+    "No CC Switch here hint": "{path}里没有可读的内容。可在当前账户下安装CC Switch,或者用下方的链接导入。",
+    "No agent here reads this": "它开启的应用都不在Gateway的管理范围内",
+    "Nothing to import": "本机装了CC Switch,但里面没有Gateway可以接管的配置",
     "Paste a link": "粘贴链接",
     "Paste a link hint": "厂商的“一键添加”链接,可以是服务商、MCP服务器、指令,或技能来源。",
+    "Providers": "供应商",
+    "Read it again": "重新读取CC Switch",
     "Read link": "读取链接",
+    "Replace what an agent already has": "覆盖Agent中同名的MCP服务器",
     "Repository": "仓库",
+    "Select everything": "全选",
+    "Select none": "全不选",
     "Skill source hint": "从该来源安装技能时才会下载仓库内容。仅添加来源不会安装任何技能。",
+    "Skill sources": "技能来源",
     "Some apps are not managed here": "链接中有部分内容属于其他应用",
     "Some apps are not managed here hint": 
"链接还指定了{apps},Gateway不管理它们。其余部分仍可导入。",
+    "This cannot be imported": "无法导入",
     "This link cannot be read": "无法读取该链接",
     "This replaces the instructions": "这会覆盖该Agent现有的指令",
-    "This replaces the instructions hint": "每个Agent只读取一个指令文件。导入会覆盖其中现有的内容。"
+    "This replaces the instructions hint": "每个Agent只读取一个指令文件。导入会覆盖其中现有的内容。",
+    "{count} MCP server": "{count}个MCP服务器",
+    "{count} MCP servers": "{count}个MCP服务器",
+    "{count} provider": "{count}个供应商",
+    "{count} providers": "{count}个供应商",
+    "{count} set of instructions": "{count}份指令",
+    "{count} sets of instructions": "{count}份指令",
+    "{count} skill source": "{count}个技能来源",
+    "{count} skill sources": "{count}个技能来源"
   },
   "llm": {
     "All time": "全部时间",
@@ -1099,23 +1130,15 @@
     "Balance URL": "余额接口地址",
     "Balance URL hint": "相对于该供应商Base URL的路径。也可以填完整地址,但必须在同一个域名下。",
     "Balance description": "厂商侧这个账号还剩多少。Gateway内置了几家厂商的接口;其余厂商和聚合站需要在这里指定。",
+    "Balance headers hint": 
"每行一个NAME=VALUE。{{token}}代表余额令牌,{{apiKey}}代表该供应商自己的密钥,两者都不必再写一遍。",
     "Balance source": "余额来源",
     "Balance source hint": 
"「自动」使用Gateway内置的厂商接口;「自定义接口」由你自己指定;「手动填写」用你填的金额减去已记录的花费,适用于完全没有余额接口的厂商。",
-    "Counting since": "计费起点",
-    "Initial amount": "初始金额",
-    "Manual amount": "手动填写",
-    "Manual amount hint": 
"余额从这个金额开始,按Gateway记录的、经由该供应商的花费递减。花费依据模型价格表计算,请让该表保持同一币种。",
-    "Manual currency hint": "金额显示用的单位。它不做任何换算:记录的花费按原值扣减。",
-    "Reset counter": "重置计费起点",
-    "Reset counter hint": "保存后把起点移到当前时间,余额只统计此后的花费。",
-    "Since the start": "保存后确定",
-    "Balance headers hint": 
"每行一个NAME=VALUE。{{token}}代表余额令牌,{{apiKey}}代表该供应商自己的密钥,两者都不必再写一遍。",
     "Balance token": "余额令牌",
     "Balance token hint": "只有当厂商的余额接口不接受推理密钥时才需要填,比如聚合站的系统令牌。存储方式与API 
Key相同,不会返回给浏览器。",
     "Balance unavailable": "无法获取余额",
     "Base URL": "基础URL",
     "Base URL hint": "实际请求将发往",
-    "Custom base URL hint": "中转站或自建服务的接口地址,例如https://your-relay.com/v1";,
+    "Bring it all over": "全部导入",
     "Caller's own login": "调用方自己的登录",
     "Change source": "更换",
     "Claude subscription": "Claude订阅登录",
@@ -1123,14 +1146,18 @@
     "Clear": "清空",
     "Client auth hint": 
"请求会带着客户端自己的凭据转发上游,已经用订阅登录过的Agent可以继续用自己的登录,这里不保存任何Key。Codex除外:它的ChatGPT登录走的是另一套API。",
     "Client auth usage hint": "只设置base URL:这里再设一个token会覆盖客户端已有的登录。",
+    "Coming from CC Switch": "从CC Switch迁移过来",
+    "Coming from CC Switch hint": "把你在那边配好的供应商一次性导入,连同MCP服务器、指令和技能来源。",
     "Connection Failed": "连接失败",
     "Connection Successful": "连接成功",
     "Connectivity": "连通性",
     "Connectivity hint": 
"向上游发起一次真实请求:用第一个模型问一句话,因此密钥不可用、或上游不提供该模型时都能在这里发现。会消耗少量token。提交表单时也会自动探测一次。",
     "Continue draft": "继续草稿",
     "Cooling down": "冷却中",
+    "Counting since": "计费起点",
     "Currency": "币种",
     "Currency hint": "换算之后这些数字的单位。",
+    "Custom base URL hint": "中转站或自建服务的接口地址,例如https://your-relay.com/v1";,
     "Custom endpoint": "自定义接口",
     "Custom endpoint hint": "如果Gateway已内置该厂商的余额接口,这里保持关闭即可。",
     "Custom vendor": "自定义",
@@ -1159,6 +1186,10 @@
     "Icon": "图标",
     "Icon hint": "取图标的站点域名,或一个图片地址。留空则从Base URL推断。",
     "Inference platforms": "推理平台",
+    "Initial amount": "初始金额",
+    "Manual amount": "手动填写",
+    "Manual amount hint": 
"余额从这个金额开始,按Gateway记录的、经由该供应商的花费递减。花费依据模型价格表计算,请让该表保持同一币种。",
+    "Manual currency hint": "金额显示用的单位。它不做任何换算:记录的花费按原值扣减。",
     "Model routing hint": 
"到达这里的请求按自己带的模型名路由,转发到支持该模型、且priority值最小的已启用供应商。如果想让某个Agent的请求不管模型名一律走这个供应商,请到该Agent的页面上绑定。",
     "Model vendors": "模型厂商",
     "Models": "模型",
@@ -1196,12 +1227,15 @@
     "Refresh balances": "刷新余额",
     "Remaining": "余额",
     "Remaining path": "余额字段路径",
+    "Reset counter": "重置计费起点",
+    "Reset counter hint": "保存后把起点移到当前时间,余额只统计此后的花费。",
     "Retried at": "将于",
     "Save anyway hint": "上游没有接受这个供应商。修正后重试,或再次点击「{action}」按当前内容保存。",
     "Scale": "换算系数",
     "Scale hint": "读到的数字都除以它,用于按内部单位计数的厂商。New API以1/500000美元计数,系数就是500000。",
     "Search vendors": "搜索厂商",
     "Select all": "全选",
+    "Since the start": "保存后确定",
     "Source": "来源",
     "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base 
URL和模型:订阅登录什么都不用填,厂商只用填一个Key。",
     "Status": "状态",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index f49f03b..d2bab12 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -13,12 +13,12 @@
 // limitations under the License.
 
 import {
+  ArrowDownToLine,
   Blocks,
   Bot,
   ChartColumn,
   CircleDollarSign,
   FileSearch,
-  Link2,
   Logs,
   MessageSquare,
   PackageCheck,
@@ -75,9 +75,10 @@ export const navGroups: NavGroup[] = [
     adminOnly: 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},
+  // Under Providers, because bringing a CC Switch installation over is how
+  // somebody arriving with one gets their providers, and it is not a page they
+  // would think to look for behind the add-provider dialog.
+  {key: "/import", label: "link:Import settings", icon: ArrowDownToLine, path: 
"/import", adminOnly: true},
   {
     key: "/agent-configs",
     label: "agentConfig:Skills, MCP & Prompts",
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index d52c8b7..d39bcb3 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -1,323 +1,328 @@
-// 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 {Link} from "react-router-dom";
-import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
-import i18next from "i18next";
-
-import * as AgentBackend from "@/backend/AgentBackend";
-import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
-import * as ProviderBackend from "@/backend/ProviderBackend";
-import * as Setting from "@/Setting";
-import {AgentCatalog} from "@/components/AgentCatalog";
-import {AuthenticityOverview} from "@/components/AuthenticityOverview";
-import {AgentGridCard} from "@/components/AgentGridCard";
-import {OnboardingButton, OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
-import {EmptyState, ErrorState} from "@/components/shared/empty-state";
-import {Loading} from "@/components/shared/loading";
-import {UnauthorizedResult} from "@/components/shared/misc";
-import {PageContainer, PageHeader} from "@/components/shared/page-header";
-import {MessageAlert} from "@/components/ui/alert";
-import {Button} from "@/components/ui/button";
-import {Card} from "@/components/ui/card";
-import {
-  agentKey,
-  runtimeOf,
-  useAgentInstall,
-  useAgentInstances,
-  useAgents,
-  useAgentUpdates,
-  updateOf,
-  usageOf,
-} from "@/lib/agents";
-import type {
-  Account,
-  AgentUsage,
-  LlmAgentStat,
-  Provider,
-  ProviderHealth,
-  ProviderQuota,
-} from "@/types";
-
-/** How often the numbers on the cards are read again. */
-const refreshMs = 10000;
-
-/**
- * How often the transcripts are added up again. Reading them walks every
- * session file on the machine, so it is asked for far less often than the
- * counters the proxy keeps in memory.
- */
-const usageRefreshMs = 60000;
-
-/**
- * The home screen: one card per agent installed on this machine. Each card
- * carries what that agent is on, what it has spent there and whether it is
- * running, and the provider box switches it over in one click — the routing is
- * stored and, where Gateway writes the agent's configuration file, that file 
is
- * rewritten with it.
- */
-export default function HomePage({account}: {account: Account}) {
-  const isAdmin = Setting.isAdminUser(account);
-  const {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-    activateProvider,
-  } = useAgents(isAdmin);
-  // Which cards have a newer release waiting, looked up in the registries.
-  const updates = useAgentUpdates(isAdmin);
-  // Installs the agents this machine is missing, listed under the cards.
-  const installer = useAgentInstall(isAdmin, () => {
-    scan(true);
-    updates.reload(true);
-  });
-  // Every agent's extra copies in one listing, so a page of cards costs one
-  // request rather than one per card.
-  const instances = useAgentInstances("", isAdmin);
-  const [providers, setProviders] = React.useState<Provider[]>([]);
-  const [health, setHealth] = React.useState<ProviderHealth[]>([]);
-  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
-  const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
-  const [usage, setUsage] = React.useState<AgentUsage>();
-  // Nothing is recorded until it is asked for, and until it is a zero on a 
card
-  // would read as "this agent relayed nothing" rather than "nobody counted".
-  const [recording, setRecording] = React.useState(true);
-  // False until the first listing lands, so "no providers" never flashes.
-  const [loaded, setLoaded] = React.useState(false);
-  // A listing that failed must not read as "you have no providers": that says
-  // the accounts holding your API keys are gone when the request simply
-  // dropped.
-  const [providerError, setProviderError] = React.useState("");
-  const loadProviders = React.useCallback(() => {
-    if (!isAdmin) {
-      return;
-    }
-    ProviderBackend.getProviders(account.name)
-      .then(res => {
-        if (res.status === "ok") {
-          setProviders(res.data ?? []);
-          setProviderError("");
-        } else {
-          setProviderError(res.msg || i18next.t("provider:Failed to get 
providers"));
-        }
-      })
-      .catch(failure => setProviderError(failure.message || String(failure)))
-      .then(() => setLoaded(true));
-
-    // What is already known paints at once; the refresh then asks only the
-    // vendors whose answer has gone stale, so a page view costs at most one
-    // request per provider every ten minutes.
-    ProviderBackend.getProviderQuotas()
-      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined)
-      .then(() => ProviderBackend.refreshProviderQuotas("", false))
-      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
-      .catch(() => undefined);
-  }, [isAdmin, account.name]);
-
-  React.useEffect(() => {
-    loadProviders();
-  }, [loadProviders]);
-
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-    LlmRecordBackend.getLlmRecordStatus()
-      .then(res => setRecording(res.status !== "ok" || (res.data?.mode ?? 
"off") !== "off"))
-      .catch(() => undefined);
-  }, [isAdmin]);
-
-  // What the proxy has seen changes as requests are relayed, so the health and
-  // the per-agent totals are polled rather than read once.
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return undefined;
-    }
-
-    const load = () => {
-      ProviderBackend.getProviderHealth()
-        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setHealth([]));
-      LlmRecordBackend.getLlmAgentStats()
-        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setStats([]));
-    };
-
-    load();
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, [isAdmin]);
-
-  // What the agents wrote down themselves, which is the only account of the
-  // requests that never went through Gateway - and the only one there is at 
all
-  // before a provider is bound.
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return undefined;
-    }
-
-    const load = () => {
-      AgentBackend.getAgentUsage()
-        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
-        .catch(() => undefined);
-    };
-
-    load();
-    const interval = setInterval(load, usageRefreshMs);
-    return () => clearInterval(interval);
-  }, [isAdmin]);
-
-  // Held here rather than inside the card, so the header can offer the guide
-  // back to whoever closed it.
-  const onboarding = useOnboarding({providers: providers, agents: agents, 
stats: stats});
-
-  if (!isAdmin) {
-    return <UnauthorizedResult />;
-  }
-
-  const refresh = () => {
-    scan(true);
-    loadRuntime(true);
-    loadProviders();
-    instances.reload(true);
-  };
-
-  const rescan = (
-    <Button variant="outline" onClick={refresh} loading={loading}>
-      <RefreshCw />
-      {i18next.t("agent:Scan")}
-    </Button>
-  );
-
-  return (
-    <PageContainer>
-      <PageHeader
-        title={i18next.t("agent:Agents on this machine")}
-        description={account.hostname}
-        actions={
-          <>
-            {loaded && !onboarding.open ? <OnboardingButton 
onboarding={onboarding} /> : null}
-            <Button asChild variant="ghost">
-              <Link to="/agents">
-                <Table2 />
-                {i18next.t("agent:Advanced view")}
-              </Link>
-            </Button>
-            {rescan}
-          </>
-        }
-      />
-
-      {loaded && onboarding.open ? <OnboardingChecklist 
onboarding={onboarding} /> : null}
-
-      {/* With no agents to show, the failure is the whole page and is rendered
-          below instead; here it sits above the cards that are still on 
screen. */}
-      {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : 
null}
-
-      {providerError !== "" ? (
-        <MessageAlert
-          title={i18next.t("provider:Failed to get providers")}
-          description={providerError}
-          action={
-            <Button variant="outline" size="sm" onClick={loadProviders}>
-              <RefreshCw />
-              {i18next.t("general:Retry")}
-            </Button>
-          }
-        />
-      ) : loaded && providers.length === 0 ? (
-        <MessageAlert
-          variant="info"
-          title={i18next.t("provider:No providers yet")}
-          description={i18next.t("provider:No providers yet detail")}
-          action={
-            <Button asChild size="sm">
-              <Link to="/providers">
-                <Plus />
-                {i18next.t("provider:New Provider")}
-              </Link>
-            </Button>
-          }
-        />
-      ) : null}
-
-      {/* Whether the keys behind these agents reach what they are sold as. It
-          is measured without being asked for, so it belongs above the fold
-          rather than behind a button on another page. */}
-      {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : 
null}
-
-      {!scanned ? (
-        <Loading tip={i18next.t("agent:Scan")} />
-      ) : error !== "" && agents.length === 0 ? (
-        // A scan that failed is not a machine without agents, and telling
-        // someone to install one they already have is the wrong instruction.
-        <Card className="py-0">
-          <ErrorState title={i18next.t("agent:Failed to scan agents")} 
error={error} onRetry={refresh} />
-        </Card>
-      ) : agents.length === 0 ? (
-        <Card className="py-0">
-          <EmptyState
-            icon={inContainer ? Container : Bot}
-            title={i18next.t(inContainer ? "agent:Running in a container" : 
"agent:No supported agents found")}
-            description={i18next.t(
-              inContainer
-                ? "agent:Running in a container detail"
-                : "agent:Install an AI agent on this machine, then scan again",
-            )}
-            action={rescan}
-          />
-        </Card>
-      ) : (
-        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4">
-          {agents.map(agent => (
-            <AgentGridCard
-              key={agentKey(agent)}
-              agent={agent}
-              agents={agents}
-              providers={providers}
-              health={health}
-              quota={quotas.find(item => item.provider === agent.provider)}
-              stats={stats.find(item => item.agent === agent.agentId)}
-              usage={usageOf(usage, agent)}
-              status={runtimeOf(runtime, agent)}
-              update={updateOf(updates.updates, agent)}
-              instances={instances}
-              recording={recording}
-              busy={busyKey === agentKey(agent)}
-              runBusy={runBusyKey === agentKey(agent)}
-              onEnable={providerId => activateProvider(agent, providerId)}
-              onLocated={() => scan(true)}
-              onToggleRunning={toggleRunning}
-              onTogglePatch={() => togglePatch(agent)}
-            />
-          ))}
-        </div>
-      )}
-
-      {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog agents={agents} enabled={scanned} installer={installer} 
onLocated={() => scan(true)} />
-    </PageContainer>
-  );
-}
+// 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 {Link} from "react-router-dom";
+import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import * as Setting from "@/Setting";
+import {AgentCatalog} from "@/components/AgentCatalog";
+import {CcSwitchBanner} from "@/components/CcSwitchBanner";
+import {AuthenticityOverview} from "@/components/AuthenticityOverview";
+import {AgentGridCard} from "@/components/AgentGridCard";
+import {OnboardingButton, OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
+import {EmptyState, ErrorState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Card} from "@/components/ui/card";
+import {
+  agentKey,
+  runtimeOf,
+  useAgentInstall,
+  useAgentInstances,
+  useAgents,
+  useAgentUpdates,
+  updateOf,
+  usageOf,
+} from "@/lib/agents";
+import type {
+  Account,
+  AgentUsage,
+  LlmAgentStat,
+  Provider,
+  ProviderHealth,
+  ProviderQuota,
+} from "@/types";
+
+/** How often the numbers on the cards are read again. */
+const refreshMs = 10000;
+
+/**
+ * How often the transcripts are added up again. Reading them walks every
+ * session file on the machine, so it is asked for far less often than the
+ * counters the proxy keeps in memory.
+ */
+const usageRefreshMs = 60000;
+
+/**
+ * The home screen: one card per agent installed on this machine. Each card
+ * carries what that agent is on, what it has spent there and whether it is
+ * running, and the provider box switches it over in one click — the routing is
+ * stored and, where Gateway writes the agent's configuration file, that file 
is
+ * rewritten with it.
+ */
+export default function HomePage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    activateProvider,
+  } = useAgents(isAdmin);
+  // Which cards have a newer release waiting, looked up in the registries.
+  const updates = useAgentUpdates(isAdmin);
+  // Installs the agents this machine is missing, listed under the cards.
+  const installer = useAgentInstall(isAdmin, () => {
+    scan(true);
+    updates.reload(true);
+  });
+  // Every agent's extra copies in one listing, so a page of cards costs one
+  // request rather than one per card.
+  const instances = useAgentInstances("", isAdmin);
+  const [providers, setProviders] = React.useState<Provider[]>([]);
+  const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
+  const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
+  const [usage, setUsage] = React.useState<AgentUsage>();
+  // Nothing is recorded until it is asked for, and until it is a zero on a 
card
+  // would read as "this agent relayed nothing" rather than "nobody counted".
+  const [recording, setRecording] = React.useState(true);
+  // False until the first listing lands, so "no providers" never flashes.
+  const [loaded, setLoaded] = React.useState(false);
+  // A listing that failed must not read as "you have no providers": that says
+  // the accounts holding your API keys are gone when the request simply
+  // dropped.
+  const [providerError, setProviderError] = React.useState("");
+  const loadProviders = React.useCallback(() => {
+    if (!isAdmin) {
+      return;
+    }
+    ProviderBackend.getProviders(account.name)
+      .then(res => {
+        if (res.status === "ok") {
+          setProviders(res.data ?? []);
+          setProviderError("");
+        } else {
+          setProviderError(res.msg || i18next.t("provider:Failed to get 
providers"));
+        }
+      })
+      .catch(failure => setProviderError(failure.message || String(failure)))
+      .then(() => setLoaded(true));
+
+    // What is already known paints at once; the refresh then asks only the
+    // vendors whose answer has gone stale, so a page view costs at most one
+    // request per provider every ten minutes.
+    ProviderBackend.getProviderQuotas()
+      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined)
+      .then(() => ProviderBackend.refreshProviderQuotas("", false))
+      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, [isAdmin, account.name]);
+
+  React.useEffect(() => {
+    loadProviders();
+  }, [loadProviders]);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+    LlmRecordBackend.getLlmRecordStatus()
+      .then(res => setRecording(res.status !== "ok" || (res.data?.mode ?? 
"off") !== "off"))
+      .catch(() => undefined);
+  }, [isAdmin]);
+
+  // What the proxy has seen changes as requests are relayed, so the health and
+  // the per-agent totals are polled rather than read once.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      ProviderBackend.getProviderHealth()
+        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setHealth([]));
+      LlmRecordBackend.getLlmAgentStats()
+        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setStats([]));
+    };
+
+    load();
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
+  // What the agents wrote down themselves, which is the only account of the
+  // requests that never went through Gateway - and the only one there is at 
all
+  // before a provider is bound.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      AgentBackend.getAgentUsage()
+        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
+        .catch(() => undefined);
+    };
+
+    load();
+    const interval = setInterval(load, usageRefreshMs);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
+  // Held here rather than inside the card, so the header can offer the guide
+  // back to whoever closed it.
+  const onboarding = useOnboarding({providers: providers, agents: agents, 
stats: stats});
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const refresh = () => {
+    scan(true);
+    loadRuntime(true);
+    loadProviders();
+    instances.reload(true);
+  };
+
+  const rescan = (
+    <Button variant="outline" onClick={refresh} loading={loading}>
+      <RefreshCw />
+      {i18next.t("agent:Scan")}
+    </Button>
+  );
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("agent:Agents on this machine")}
+        description={account.hostname}
+        actions={
+          <>
+            {loaded && !onboarding.open ? <OnboardingButton 
onboarding={onboarding} /> : null}
+            <Button asChild variant="ghost">
+              <Link to="/agents">
+                <Table2 />
+                {i18next.t("agent:Advanced view")}
+              </Link>
+            </Button>
+            {rescan}
+          </>
+        }
+      />
+
+      {loaded && onboarding.open ? <OnboardingChecklist 
onboarding={onboarding} /> : null}
+
+      {/* Somebody arriving from CC Switch has all of this set up already, and
+          the import page is the first thing they want rather than a form. */}
+      <CcSwitchBanner />
+
+      {/* With no agents to show, the failure is the whole page and is rendered
+          below instead; here it sits above the cards that are still on 
screen. */}
+      {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : 
null}
+
+      {providerError !== "" ? (
+        <MessageAlert
+          title={i18next.t("provider:Failed to get providers")}
+          description={providerError}
+          action={
+            <Button variant="outline" size="sm" onClick={loadProviders}>
+              <RefreshCw />
+              {i18next.t("general:Retry")}
+            </Button>
+          }
+        />
+      ) : loaded && providers.length === 0 ? (
+        <MessageAlert
+          variant="info"
+          title={i18next.t("provider:No providers yet")}
+          description={i18next.t("provider:No providers yet detail")}
+          action={
+            <Button asChild size="sm">
+              <Link to="/providers">
+                <Plus />
+                {i18next.t("provider:New Provider")}
+              </Link>
+            </Button>
+          }
+        />
+      ) : null}
+
+      {/* Whether the keys behind these agents reach what they are sold as. It
+          is measured without being asked for, so it belongs above the fold
+          rather than behind a button on another page. */}
+      {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : 
null}
+
+      {!scanned ? (
+        <Loading tip={i18next.t("agent:Scan")} />
+      ) : error !== "" && agents.length === 0 ? (
+        // A scan that failed is not a machine without agents, and telling
+        // someone to install one they already have is the wrong instruction.
+        <Card className="py-0">
+          <ErrorState title={i18next.t("agent:Failed to scan agents")} 
error={error} onRetry={refresh} />
+        </Card>
+      ) : agents.length === 0 ? (
+        <Card className="py-0">
+          <EmptyState
+            icon={inContainer ? Container : Bot}
+            title={i18next.t(inContainer ? "agent:Running in a container" : 
"agent:No supported agents found")}
+            description={i18next.t(
+              inContainer
+                ? "agent:Running in a container detail"
+                : "agent:Install an AI agent on this machine, then scan again",
+            )}
+            action={rescan}
+          />
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 
xl:grid-cols-4">
+          {agents.map(agent => (
+            <AgentGridCard
+              key={agentKey(agent)}
+              agent={agent}
+              agents={agents}
+              providers={providers}
+              health={health}
+              quota={quotas.find(item => item.provider === agent.provider)}
+              stats={stats.find(item => item.agent === agent.agentId)}
+              usage={usageOf(usage, agent)}
+              status={runtimeOf(runtime, agent)}
+              update={updateOf(updates.updates, agent)}
+              instances={instances}
+              recording={recording}
+              busy={busyKey === agentKey(agent)}
+              runBusy={runBusyKey === agentKey(agent)}
+              onEnable={providerId => activateProvider(agent, providerId)}
+              onLocated={() => scan(true)}
+              onToggleRunning={toggleRunning}
+              onTogglePatch={() => togglePatch(agent)}
+            />
+          ))}
+        </div>
+      )}
+
+      {/* What is not here yet, so an empty machine has somewhere to go. */}
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} 
onLocated={() => scan(true)} />
+    </PageContainer>
+  );
+}
diff --git a/web/src/pages/ImportLinkPage.tsx b/web/src/pages/ImportLinkPage.tsx
index df4637c..c1a4524 100644
--- a/web/src/pages/ImportLinkPage.tsx
+++ b/web/src/pages/ImportLinkPage.tsx
@@ -20,6 +20,7 @@ import i18next from "i18next";
 import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
 import * as ImportLinkBackend from "@/backend/ImportLinkBackend";
 import * as Setting from "@/Setting";
+import {CcSwitchImportCard} from "@/components/CcSwitchImportCard";
 import {ActionBadge} from "@/components/agent-config/action-badge";
 import {TargetPicker} from "@/components/agent-config/target-picker";
 import {CodeBlock, CodeText, DescriptionList} from "@/components/shared/misc";
@@ -42,14 +43,16 @@ import type {
 } 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.
+ * The two ways settings arrive from somewhere else: a whole CC Switch
+ * installation on this machine, brought over in one go, and a vendor's "add
+ * this to Gateway" link. Both are shown before any of them is kept — a 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.
+ * A provider from a link 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();
@@ -117,36 +120,39 @@ export default function ImportLinkPage() {
 
   return (
     <PageContainer>
-      <PageHeader title={i18next.t("link:Import from a link")} 
description={i18next.t("link:Import hint")} />
+      <PageHeader title={i18next.t("link:Import settings")} 
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>
+        <>
+          <CcSwitchImportCard />
+          <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 ? (
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index f6b2584..2d6440f 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -443,7 +443,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
             <CardDescription>{i18next.t("provider:No providers yet 
detail")}</CardDescription>
           </CardHeader>
           <CardContent>
-            <ProviderSourcePicker onPick={openAddDialog} onLink={importLink} />
+            <ProviderSourcePicker onPick={openAddDialog} onLink={importLink} 
onCcSwitch={() => navigate("/import")} />
           </CardContent>
         </Card>
       ) : (
@@ -562,7 +562,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                 }
               />
             )}
-            <ProviderSourcePicker onPick={pickSource} onLink={importLink} />
+            <ProviderSourcePicker onPick={pickSource} onLink={importLink} 
onCcSwitch={() => navigate("/import")} />
           </>
         ) : (
           <>
diff --git a/web/src/types.ts b/web/src/types.ts
index 054611b..ee199c0 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1397,3 +1397,91 @@ export interface AutostartState {
   enabled: boolean;
   launcher: string;
 }
+
+/**
+ * A CC Switch installation on this machine, read and ready to be brought over.
+ * It is a preview: every key in it is masked, and the import reads the real
+ * values from CC Switch's own store rather than from this page.
+ */
+export interface CcSwitchImport {
+  found: boolean;
+  /** The folder it was read from, whether or not anything is there. */
+  path: string;
+  /** True for a store CC Switch still keeps in config.json. */
+  legacy: boolean;
+  providers: CcSwitchProvider[];
+  mcps: CcSwitchMcp[];
+  prompts: CcSwitchPrompt[];
+  skills: CcSwitchSkill[];
+  /** The entries there is nothing to bring over from, and why. */
+  skipped: CcSwitchSkipped[];
+}
+
+export interface CcSwitchProvider {
+  key: string;
+  /** The CC Switch app it was set up for: claude, codex, gemini and so on. */
+  app: string;
+  /** The one CC Switch has applied for its app right now. */
+  current: boolean;
+  /** A provider is already here under this name; importing adds one beside 
it. */
+  taken: boolean;
+  provider: Provider;
+}
+
+export interface CcSwitchMcp {
+  key: string;
+  name: string;
+  config: string;
+  /** The agents its apps resolve to here. */
+  targets: string[];
+  unknown: string[];
+}
+
+export interface CcSwitchPrompt {
+  key: string;
+  name: string;
+  app: string;
+  content: string;
+  targets: string[];
+  unknown: string[];
+}
+
+export interface CcSwitchSkill {
+  key: string;
+  repo: string;
+  ref: string;
+}
+
+export interface CcSwitchSkipped {
+  name: string;
+  app: string;
+  reason: string;
+}
+
+/** What an import was asked to bring over, by the keys the scan gave out. */
+export interface CcSwitchSelection {
+  account?: string;
+  providers: string[];
+  mcps: string[];
+  prompts: string[];
+  skills: string[];
+  /** Replace an MCP server the agent already has, rather than leaving it 
alone. */
+  overwrite: boolean;
+}
+
+/** What the import did with one entry. */
+export interface CcSwitchOutcome {
+  key: string;
+  name: string;
+  action: AgentConfigAction;
+  reason?: string;
+  /** The agent it was written into, for the entries that go into one. */
+  agent?: string;
+}
+
+export interface CcSwitchResult {
+  providers: CcSwitchOutcome[];
+  mcps: CcSwitchOutcome[];
+  prompts: CcSwitchOutcome[];
+  skills: CcSwitchOutcome[];
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to