This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new bde6658 feat(agent): manage each agent's skills and MCP servers
bde6658 is described below
commit bde6658fe4d59f2d5540cefb1c400bd42ce82495
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 13:17:35 2026 +0800
feat(agent): manage each agent's skills and MCP servers
---
README.md | 10 +
agent/agent.go | 11 +
agentconfig/agentconfig.go | 387 +++++++++++++++++++++
agentconfig/agentconfig_test.go | 313 +++++++++++++++++
agentconfig/file.go | 89 +++++
agentconfig/frontmatter.go | 121 +++++++
agentconfig/layout.go | 192 +++++++++++
agentconfig/mcp_item.go | 103 ++++++
agentconfig/mcp_json.go | 175 ++++++++++
agentconfig/mcp_toml.go | 291 ++++++++++++++++
agentconfig/mcp_toml_test.go | 207 +++++++++++
agentconfig/skill.go | 290 ++++++++++++++++
{agentpatch => agenthome}/home.go | 17 +-
agentpatch/claude_code.go | 3 +-
agentpatch/claude_desktop.go | 16 +-
agentpatch/openclaw.go | 3 +-
agentpatch/state.go | 6 +-
controllers/agent_config.go | 197 +++++++++++
routers/router.go | 5 +
web/src/App.tsx | 5 +
web/src/backend/AgentConfigBackend.ts | 58 ++++
web/src/components/agent-config/copy-dialog.tsx | 295 ++++++++++++++++
web/src/components/agent-config/detail-dialog.tsx | 128 +++++++
web/src/lib/agent-configs.ts | 127 +++++++
web/src/locales/en/data.json | 61 +++-
web/src/locales/zh/data.json | 61 +++-
web/src/nav.ts | 8 +
web/src/pages/AgentConfigsPage.tsx | 399 ++++++++++++++++++++++
web/src/types.ts | 60 ++++
29 files changed, 3614 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index e3adca5..07c50c2 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,16 @@ Gateway also watches the AI coding agents installed on the
machine it runs on: i
Discovery reads that machine's user accounts, home directories and package
install paths, so it only ever sees the host Gateway itself runs on. Inside a
container — `docker compose up`, `docker run`, or Podman — Gateway scans the
container's own filesystem, finds nothing, and the **Agents** page stays empty
even though agents are installed on the host. Run Gateway from source or as a
[single binary](#single-binary) on the host to use these features; every other
part of Gateway, the WAF p [...]
+### Skills and MCP servers
+
+Every agent keeps its skills and its MCP servers in its own file, in its own
format: `~/.claude/skills` and `~/.claude.json` for Claude Code,
`~/.codex/skills` and the `[mcp_servers]` tables of `~/.codex/config.toml` for
Codex, `~/.cursor/skills-cursor` and `~/.cursor/mcp.json` for Cursor, and so
on. The **Skills & MCP** page reads all of them and puts them in one table,
with a column per agent showing which of them already has each item.
+
+From there an item can be opened — the whole `SKILL.md`, or the server's entry
with its credentials masked — deleted from the agent that holds it, or copied
into the other agents on the same account. A copy shows what it would do at
every target first, item by item, and only replaces something that is already
there when told to.
+
+Gateway reads and writes these files in place. It stages a replacement and
renames it over the original rather than truncating it, keeps every setting it
does not own, and preserves the comments and formatting of `config.toml` by
editing its text instead of re-encoding it. The MCP server Gateway registers
for its own monitoring is listed like any other, but is never copied or deleted
from here; turning monitoring off on the **Agents** page is what removes it.
+
+Configuration is found whether or not the agent itself was discovered, so an
agent installed some way Gateway does not recognize still shows the skills it
has on disk. Like agent monitoring, this only ever sees the host Gateway runs
on.
+
### Quick start
From nothing to a request flowing through the gateway, in four steps. No
database server is needed: Gateway creates `./data/casbin-gateway.db` on first
start.
diff --git a/agent/agent.go b/agent/agent.go
index 5bb83fb..bca4022 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -25,6 +25,17 @@ type Installation struct {
Owner string `json:"owner"`
}
+// DisplayNameOf is the human-readable name of a known agent id, empty for an
id
+// no fingerprint declares.
+func DisplayNameOf(id string) string {
+ for i := range fingerprints {
+ if fingerprints[i].ID == id {
+ return fingerprints[i].DisplayName
+ }
+ }
+ return ""
+}
+
// IsKnownAgentId reads the fingerprints rather than a host scan, so an agent
// stays configurable while it is not installed.
func IsKnownAgentId(id string) bool {
diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
new file mode 100644
index 0000000..74d05e7
--- /dev/null
+++ b/agentconfig/agentconfig.go
@@ -0,0 +1,387 @@
+// 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 reads and edits the skills and MCP servers that AI
coding
+// agents keep in their own configuration files, so the agents installed on one
+// host can be compared and their configuration moved between them.
+//
+// Every agent stores the same two things in a different place and a different
+// format. layout.go holds that per-agent knowledge, and nothing else does.
+package agentconfig
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// The two kinds of configuration Gateway manages.
+const (
+ KindSkill = "skill"
+ KindMcp = "mcp"
+)
+
+// ErrUnsupported reports a kind an agent has no known location for, which is
+// different from an agent that has one and it is empty.
+var ErrUnsupported = errors.New("Gateway does not know where this agent keeps
that configuration")
+
+// Item is one skill or MCP server as it exists in an agent's own
configuration.
+type Item struct {
+ AgentId string `json:"agentId"`
+ Owner string `json:"owner"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+
+ Description string `json:"description,omitempty"`
+
+ // Path is what an edit would change: the skill's own directory, or the
+ // config file the MCP server is one entry of.
+ Path string `json:"path"`
+
+ // Transport, Command and Url summarize an MCP server in a list.
+ Transport string `json:"transport,omitempty"`
+ Command string `json:"command,omitempty"`
+ Url string `json:"url,omitempty"`
+
+ // Files and Bytes summarize a skill in a list.
+ Files int `json:"files,omitempty"`
+ Bytes int64 `json:"bytes,omitempty"`
+
+ // Managed marks an entry Gateway wrote itself, which is not the
operator's
+ // to migrate and is removed by turning monitoring off instead.
+ Managed bool `json:"managed,omitempty"`
+}
+
+// Inventory is everything Gateway can read for one installation. A location it
+// cannot read leaves the corresponding list empty and adds to Errors, so one
+// unreadable file never hides the rest of the host.
+type Inventory struct {
+ AgentId string `json:"agentId"`
+ Owner string `json:"owner"`
+ // Home is the account directory the locations below were resolved
under.
+ // Two agents can be compared and copied between exactly when they
share it.
+ Home string `json:"home,omitempty"`
+
+ SkillsDir string `json:"skillsDir,omitempty"`
+ McpFile string `json:"mcpFile,omitempty"`
+
+ SkillsSupported bool `json:"skillsSupported"`
+ McpSupported bool `json:"mcpSupported"`
+ // McpWritable is false for a config Gateway can read but must not
rewrite,
+ // because writing it back would lose something it cannot represent.
+ McpWritable bool `json:"mcpWritable"`
+ McpReadOnly string `json:"mcpReadOnly,omitempty"`
+
+ Skills []*Item `json:"skills"`
+ McpServers []*Item `json:"mcpServers"`
+ Errors []string `json:"errors,omitempty"`
+}
+
+// Detail is one item's full definition, for the viewer.
+type Detail struct {
+ Item *Item `json:"item"`
+ // Content is the SKILL.md of a skill, or the JSON of one MCP server
entry.
+ Content string `json:"content"`
+ // Files lists the other files a skill directory carries.
+ Files []string `json:"files,omitempty"`
+}
+
+// source is one item of the migration source, read once per Copy rather than
+// once per target.
+type source struct {
+ item *Item
+ entry map[string]any
+}
+
+// Read returns one installation's inventory. It never fails: an unreadable
+// location is reported in Errors, because a page listing every agent on the
+// host must stay useful when one of them has a broken config file.
+func Read(agentId string, owner string) *Inventory {
+ inventory := &Inventory{
+ AgentId: agentId,
+ Owner: owner,
+ Skills: []*Item{},
+ McpServers: []*Item{},
+ }
+
+ found, ok := layoutOf(agentId)
+ if !ok {
+ return inventory
+ }
+ inventory.SkillsSupported = found.skills != nil
+ inventory.McpSupported = found.mcp != nil
+ if found.mcp != nil {
+ inventory.McpWritable = found.mcp.readOnly == ""
+ inventory.McpReadOnly = found.mcp.readOnly
+ }
+
+ home, err := homeOf(owner)
+ if err != nil {
+ inventory.Errors = append(inventory.Errors, err.Error())
+ return inventory
+ }
+ inventory.Home = home
+
+ if found.skills != nil {
+ inventory.SkillsDir = found.skills.dir(home)
+ skills, err := readSkills(agentId, owner, inventory.SkillsDir)
+ if err != nil {
+ inventory.Errors = append(inventory.Errors, err.Error())
+ }
+ inventory.Skills = skills
+ }
+
+ if found.mcp != nil {
+ inventory.McpFile = found.mcp.path(home)
+ entries, err := found.mcp.store.read(inventory.McpFile)
+ if err != nil {
+ inventory.Errors = append(inventory.Errors, err.Error())
+ }
+ inventory.McpServers = mcpItems(agentId, owner,
inventory.McpFile, entries)
+ }
+ return inventory
+}
+
+// ReadDetail returns one item's full definition.
+func ReadDetail(agentId string, owner string, kind string, name string)
(*Detail, error) {
+ found, home, err := resolve(agentId, owner, kind)
+ if err != nil {
+ return nil, err
+ }
+ if kind == KindSkill {
+ return skillDetail(agentId, owner, found.skills.dir(home), name)
+ }
+
+ file := found.mcp.path(home)
+ entries, err := found.mcp.store.read(file)
+ if err != nil {
+ return nil, err
+ }
+ entry, ok := entries[name]
+ if !ok {
+ return nil, fmt.Errorf("%s: no MCP server named %q in %s",
agentId, name, file)
+ }
+ return mcpDetail(agentId, owner, file, name, entry)
+}
+
+// Delete removes one item from the agent's own configuration.
+func Delete(agentId string, owner string, kind string, name string) error {
+ found, home, err := resolve(agentId, owner, kind)
+ if err != nil {
+ return err
+ }
+ if kind == KindSkill {
+ return deleteSkill(found.skills.dir(home), name)
+ }
+ if found.mcp.readOnly != "" {
+ return fmt.Errorf("%s: %s", agentId, found.mcp.readOnly)
+ }
+ return found.mcp.store.remove(found.mcp.path(home), name)
+}
+
+// CopyRequest is one migration: some of a source agent's items, into one or
+// more other agents belonging to the same account.
+type CopyRequest struct {
+ Owner string `json:"owner"`
+ From string `json:"from"`
+ To []string `json:"to"`
+ Kind string `json:"kind"`
+ Names []string `json:"names"`
+ Overwrite bool `json:"overwrite"`
+}
+
+// What one planned copy would do to the target.
+const (
+ ActionCreate = "create"
+ ActionOverwrite = "overwrite"
+ ActionSkip = "skip"
+ ActionFailed = "failed"
+)
+
+// PlanItem is one item's fate at one target, decided before anything is
written
+// and reported again afterwards with what actually happened.
+type PlanItem struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Action string `json:"action"`
+ Reason string `json:"reason,omitempty"`
+ Path string `json:"path,omitempty"`
+}
+
+// Plan reports what Copy would do, without touching anything.
+func Plan(request CopyRequest) ([]*PlanItem, error) {
+ sources, err := readSources(request)
+ if err != nil {
+ return nil, err
+ }
+ return plan(request, sources), nil
+}
+
+func plan(request CopyRequest, sources map[string]*source) []*PlanItem {
+ planned := []*PlanItem{}
+ for _, agentId := range request.To {
+ existing, err := targetNames(agentId, request.Owner,
request.Kind)
+ for _, name := range request.Names {
+ item := &PlanItem{AgentId: agentId, Name: name}
+ switch {
+ case err != nil:
+ item.Action, item.Reason = ActionSkip,
err.Error()
+ case sources[name] == nil:
+ item.Action, item.Reason = ActionSkip, "not
found in the source agent"
+ case sources[name].item.Managed:
+ item.Action, item.Reason = ActionSkip, "written
by Gateway, not migrated"
+ case !existing[name]:
+ item.Action = ActionCreate
+ case request.Overwrite:
+ item.Action = ActionOverwrite
+ default:
+ item.Action, item.Reason = ActionSkip, "already
exists"
+ }
+ planned = append(planned, item)
+ }
+ }
+ return planned
+}
+
+// Copy writes the selected items into every target agent. One item's failure
is
+// recorded against that item and the rest still run, so a partly finished
+// migration is reported item by item instead of hidden behind the first error.
+func Copy(request CopyRequest) ([]*PlanItem, error) {
+ sources, err := readSources(request)
+ if err != nil {
+ return nil, err
+ }
+
+ planned := plan(request, sources)
+ for _, item := range planned {
+ if item.Action != ActionCreate && item.Action !=
ActionOverwrite {
+ continue
+ }
+ path, err := writeItem(request, sources[item.Name],
item.AgentId)
+ if err != nil {
+ item.Action, item.Reason = ActionFailed, err.Error()
+ continue
+ }
+ item.Path = path
+ }
+ return planned, nil
+}
+
+// writeItem puts one already-read source item into one target agent.
+func writeItem(request CopyRequest, from *source, agentId string) (string,
error) {
+ found, home, err := resolve(agentId, request.Owner, request.Kind)
+ if err != nil {
+ return "", err
+ }
+
+ if request.Kind == KindSkill {
+ return copySkill(from.item.Path, found.skills.dir(home),
from.item.Name)
+ }
+ if found.mcp.readOnly != "" {
+ return "", errors.New(found.mcp.readOnly)
+ }
+ file := found.mcp.path(home)
+ return file, found.mcp.store.write(file, from.item.Name, from.entry)
+}
+
+// readSources validates the request and loads the source agent's items once.
+func readSources(request CopyRequest) (map[string]*source, error) {
+ switch {
+ case request.From == "":
+ return nil, errors.New("the source agent is empty")
+ case len(request.To) == 0:
+ return nil, errors.New("no target agent was selected")
+ case len(request.Names) == 0:
+ return nil, errors.New("nothing was selected to copy")
+ case request.Kind != KindSkill && request.Kind != KindMcp:
+ return nil, fmt.Errorf("unknown configuration kind: %s",
request.Kind)
+ }
+ for _, agentId := range request.To {
+ if agentId == request.From {
+ return nil, errors.New("the source agent cannot also be
a target")
+ }
+ }
+
+ found, home, err := resolve(request.From, request.Owner, request.Kind)
+ if err != nil {
+ return nil, err
+ }
+
+ inventory := Read(request.From, request.Owner)
+ if len(inventory.Errors) > 0 {
+ return nil, errors.New(strings.Join(inventory.Errors, "; "))
+ }
+
+ entries := map[string]map[string]any{}
+ if request.Kind == KindMcp {
+ entries, err = found.mcp.store.read(found.mcp.path(home))
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ sources := map[string]*source{}
+ for _, item := range itemsOf(inventory, request.Kind) {
+ sources[item.Name] = &source{item: item, entry:
entries[item.Name]}
+ }
+ return sources, nil
+}
+
+// targetNames is what the target already has, so planning can tell a new name
+// from one that would be replaced.
+func targetNames(agentId string, owner string, kind string) (map[string]bool,
error) {
+ if _, _, err := resolve(agentId, owner, kind); err != nil {
+ return nil, err
+ }
+
+ inventory := Read(agentId, owner)
+ if len(inventory.Errors) > 0 {
+ return nil, errors.New(strings.Join(inventory.Errors, "; "))
+ }
+
+ names := map[string]bool{}
+ for _, item := range itemsOf(inventory, kind) {
+ names[item.Name] = true
+ }
+ return names, nil
+}
+
+func itemsOf(inventory *Inventory, kind string) []*Item {
+ if kind == KindSkill {
+ return inventory.Skills
+ }
+ return inventory.McpServers
+}
+
+// resolve is the check every read and write shares: the agent is known, it
+// keeps that kind of configuration somewhere, and the owner's home resolves.
+func resolve(agentId string, owner string, kind string) (layout, string,
error) {
+ found, ok := layoutOf(agentId)
+ if !ok {
+ return layout{}, "", fmt.Errorf("unknown agent: %s", agentId)
+ }
+ if (kind == KindSkill && found.skills == nil) || (kind == KindMcp &&
found.mcp == nil) {
+ return layout{}, "", fmt.Errorf("%s: %w", agentId,
ErrUnsupported)
+ }
+ home, err := homeOf(owner)
+ if err != nil {
+ return layout{}, "", err
+ }
+ return found, home, nil
+}
+
+func sortItems(items []*Item) {
+ sort.Slice(items, func(i, j int) bool { return items[i].Name <
items[j].Name })
+}
diff --git a/agentconfig/agentconfig_test.go b/agentconfig/agentconfig_test.go
new file mode 100644
index 0000000..15d0d50
--- /dev/null
+++ b/agentconfig/agentconfig_test.go
@@ -0,0 +1,313 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// fakeHome points the whole package at a temporary directory, so a test reads
+// and writes agent configuration without touching the account running it.
+func fakeHome(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+ return home
+}
+
+func writeFileAt(t *testing.T, path string, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func writeSkill(t *testing.T, dir string, name string, description string) {
+ t.Helper()
+ writeFileAt(t, filepath.Join(dir, name, skillFile),
+ "---\nname: "+name+"\ndescription: "+description+"\n---\n#
"+name+"\n")
+}
+
+func TestReadListsSkillsAndServers(t *testing.T) {
+ home := fakeHome(t)
+ writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work
with PDFs")
+ writeFileAt(t, filepath.Join(home, ".claude", "skills", "pdf",
"references", "spec.md"), "spec")
+ writeFileAt(t, filepath.Join(home, ".claude.json"),
+
`{"projects":{"a":1},"mcpServers":{"files":{"command":"npx","args":["-y","server"]}}}`)
+
+ inventory := Read("claude-code", "")
+ if len(inventory.Errors) > 0 {
+ t.Fatalf("read reported errors: %v", inventory.Errors)
+ }
+ if len(inventory.Skills) != 1 || inventory.Skills[0].Name != "pdf" {
+ t.Fatalf("skills = %+v", inventory.Skills)
+ }
+ if inventory.Skills[0].Description != "Work with PDFs" ||
inventory.Skills[0].Files != 2 {
+ t.Fatalf("skill = %+v", inventory.Skills[0])
+ }
+ if len(inventory.McpServers) != 1 {
+ t.Fatalf("servers = %+v", inventory.McpServers)
+ }
+ server := inventory.McpServers[0]
+ if server.Name != "files" || server.Transport != "stdio" ||
server.Command != "npx -y server" {
+ t.Fatalf("server = %+v", server)
+ }
+}
+
+func TestReadSkipsDirectoriesWithoutAManifest(t *testing.T) {
+ home := fakeHome(t)
+ skills := filepath.Join(home, ".claude", "skills")
+ writeSkill(t, skills, "real", "kept")
+ writeFileAt(t, filepath.Join(skills, "notes", "README.md"), "not a
skill")
+ writeFileAt(t, filepath.Join(skills, ".managed", skillFile),
"---\nname: hidden\n---\n")
+
+ inventory := Read("claude-code", "")
+ if len(inventory.Skills) != 1 || inventory.Skills[0].Name != "real" {
+ t.Fatalf("skills = %+v", inventory.Skills)
+ }
+}
+
+func TestReadReportsAnUnparsableFileWithoutLosingSkills(t *testing.T) {
+ home := fakeHome(t)
+ writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work
with PDFs")
+ writeFileAt(t, filepath.Join(home, ".claude.json"), "{ not json")
+
+ inventory := Read("claude-code", "")
+ if len(inventory.Errors) != 1 {
+ t.Fatalf("errors = %v", inventory.Errors)
+ }
+ if len(inventory.Skills) != 1 {
+ t.Fatalf("a broken MCP file hid the skills: %+v",
inventory.Skills)
+ }
+}
+
+func TestOpenClawServersAreReadUnderEitherSpelling(t *testing.T) {
+ home := fakeHome(t)
+ writeFileAt(t, filepath.Join(home, ".openclaw", "openclaw.json"),
+
`{"mcp":{"servers":{"a":{"command":"a"}}},"mcpServers":{"b":{"url":"https://b"}}}`)
+
+ inventory := Read("openclaw", "")
+ if len(inventory.McpServers) != 2 {
+ t.Fatalf("servers = %+v", inventory.McpServers)
+ }
+ if inventory.McpServers[1].Transport != "http" {
+ t.Fatalf("server = %+v", inventory.McpServers[1])
+ }
+}
+
+func TestDeleteRemovesOneServerAndKeepsTheRestOfTheFile(t *testing.T) {
+ home := fakeHome(t)
+ path := filepath.Join(home, ".cursor", "mcp.json")
+ writeFileAt(t, path,
`{"other":{"keep":true},"mcpServers":{"a":{"command":"a"},"b":{"command":"b"}}}`)
+
+ if err := Delete("cursor", "", KindMcp, "a"); err != nil {
+ t.Fatal(err)
+ }
+
+ content := readBack(t, path)
+ if strings.Contains(content, `"a"`) || !strings.Contains(content,
`"b"`) {
+ t.Fatalf("file after delete: %s", content)
+ }
+ if !strings.Contains(content, `"keep"`) {
+ t.Fatalf("delete dropped an unrelated key: %s", content)
+ }
+}
+
+func TestDeleteSkillRefusesADirectoryThatIsNotASkill(t *testing.T) {
+ home := fakeHome(t)
+ writeFileAt(t, filepath.Join(home, ".claude", "skills", "notes",
"README.md"), "not a skill")
+
+ if err := Delete("claude-code", "", KindSkill, "notes"); err == nil {
+ t.Fatal("a directory without a manifest was deleted")
+ }
+ if _, err := os.Stat(filepath.Join(home, ".claude", "skills",
"notes")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeleteRejectsATraversingName(t *testing.T) {
+ fakeHome(t)
+ for _, name := range []string{"..", "../escape", `..\escape`, "a/b",
""} {
+ if err := Delete("claude-code", "", KindSkill, name); err ==
nil {
+ t.Fatalf("name %q was accepted", name)
+ }
+ }
+}
+
+func TestCopySkillsBetweenAgents(t *testing.T) {
+ home := fakeHome(t)
+ source := filepath.Join(home, ".claude", "skills")
+ writeSkill(t, source, "pdf", "Work with PDFs")
+ writeFileAt(t, filepath.Join(source, "pdf", "references", "spec.md"),
"spec")
+ writeSkill(t, source, "xlsx", "Work with spreadsheets")
+ writeSkill(t, filepath.Join(home, ".codex", "skills"), "pdf", "the
older copy")
+
+ request := CopyRequest{
+ From: "claude-code",
+ To: []string{"codex-cli"},
+ Kind: KindSkill,
+ Names: []string{"pdf", "xlsx"},
+ }
+
+ planned, err := Plan(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if planned[0].Action != ActionSkip || planned[1].Action != ActionCreate
{
+ t.Fatalf("plan = %+v %+v", planned[0], planned[1])
+ }
+
+ request.Overwrite = true
+ result, err := Copy(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result[0].Action != ActionOverwrite || result[1].Action !=
ActionCreate {
+ t.Fatalf("result = %+v %+v", result[0], result[1])
+ }
+
+ target := filepath.Join(home, ".codex", "skills")
+ if content := readBack(t, filepath.Join(target, "pdf", skillFile));
!strings.Contains(content, "Work with PDFs") {
+ t.Fatalf("the existing skill was not replaced: %s", content)
+ }
+ if content := readBack(t, filepath.Join(target, "pdf", "references",
"spec.md")); content != "spec" {
+ t.Fatalf("reference file = %q", content)
+ }
+ if _, err := os.Stat(filepath.Join(target, "xlsx", skillFile)); err !=
nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCopyLeavesTheSourceInPlace(t *testing.T) {
+ home := fakeHome(t)
+ writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work
with PDFs")
+
+ _, err := Copy(CopyRequest{From: "claude-code", To: []string{"cursor"},
Kind: KindSkill, Names: []string{"pdf"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".claude", "skills", "pdf",
skillFile)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCopyReportsAFailureItemByItem(t *testing.T) {
+ home := fakeHome(t)
+ writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work
with PDFs")
+
+ result, err := Copy(CopyRequest{
+ From: "claude-code",
+ To: []string{"claude-desktop", "cursor"},
+ Kind: KindSkill,
+ Names: []string{"pdf"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result[0].Action != ActionSkip || !strings.Contains(result[1].Path,
"skills-cursor") {
+ t.Fatalf("result = %+v %+v", result[0], result[1])
+ }
+}
+
+func TestCopyRejectsTheSourceAsItsOwnTarget(t *testing.T) {
+ fakeHome(t)
+ _, err := Copy(CopyRequest{From: "cursor", To: []string{"cursor"},
Kind: KindSkill, Names: []string{"pdf"}})
+ if err == nil {
+ t.Fatal("an agent was allowed to copy onto itself")
+ }
+}
+
+func TestCopyServersIntoAJsonConfig(t *testing.T) {
+ home := fakeHome(t)
+ writeFileAt(t, filepath.Join(home, ".claude.json"),
+
`{"mcpServers":{"files":{"command":"npx","args":["-y","server"],"env":{"API_KEY":"secret"}}}}`)
+ writeFileAt(t, filepath.Join(home, ".cursor", "mcp.json"),
`{"mcpServers":{}}`)
+
+ result, err := Copy(CopyRequest{From: "claude-code", To:
[]string{"cursor"}, Kind: KindMcp, Names: []string{"files"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result[0].Action != ActionCreate {
+ t.Fatalf("result = %+v", result[0])
+ }
+
+ content := readBack(t, filepath.Join(home, ".cursor", "mcp.json"))
+ if !strings.Contains(content, `"API_KEY": "secret"`) {
+ t.Fatalf("the copy lost the environment: %s", content)
+ }
+}
+
+func TestDetailMasksCredentialsButCopyDoesNot(t *testing.T) {
+ home := fakeHome(t)
+ writeFileAt(t, filepath.Join(home, ".cursor", "mcp.json"),
+
`{"mcpServers":{"files":{"command":"npx","env":{"API_KEY":"secret"}}}}`)
+
+ detail, err := ReadDetail("cursor", "", KindMcp, "files")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(detail.Content, "secret") {
+ t.Fatalf("the detail view leaked a credential: %s",
detail.Content)
+ }
+
+ if _, err := Copy(CopyRequest{From: "cursor", To:
[]string{"claude-code"}, Kind: KindMcp, Names: []string{"files"}}); err != nil {
+ t.Fatal(err)
+ }
+ if content := readBack(t, filepath.Join(home, ".claude.json"));
!strings.Contains(content, "secret") {
+ t.Fatalf("the copy wrote the masked value: %s", content)
+ }
+}
+
+func TestManagedEntriesAreNotMigrated(t *testing.T) {
+ home := fakeHome(t)
+ writeFileAt(t, filepath.Join(home, ".claude.json"),
+ `{"mcpServers":{"`+ManagedEntryName+`":{"command":"gateway"}}}`)
+
+ planned, err := Plan(CopyRequest{
+ From: "claude-code", To: []string{"cursor"}, Kind: KindMcp,
Names: []string{ManagedEntryName},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if planned[0].Action != ActionSkip {
+ t.Fatalf("plan = %+v", planned[0])
+ }
+}
+
+func TestUnsupportedKindIsAnError(t *testing.T) {
+ fakeHome(t)
+ if _, err := ReadDetail("windsurf", "", KindSkill, "pdf"); err == nil {
+ t.Fatal("an agent with no skills directory answered a skill
lookup")
+ }
+ if inventory := Read("windsurf", ""); inventory.SkillsSupported {
+ t.Fatal("windsurf reported skill support")
+ }
+}
+
+func readBack(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
diff --git a/agentconfig/file.go b/agentconfig/file.go
new file mode 100644
index 0000000..2b1d3a1
--- /dev/null
+++ b/agentconfig/file.go
@@ -0,0 +1,89 @@
+// 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 (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// defaultConfigMode is what a config file Gateway creates is born with. These
+// files carry API keys and tokens, so a new one is not world-readable.
+const defaultConfigMode = os.FileMode(0o600)
+
+// readFile returns empty content for a file that does not exist yet, which is
+// what an agent with no configuration of that kind looks like.
+func readFile(path string) ([]byte, os.FileMode, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return nil, defaultConfigMode, nil
+ }
+ if err != nil {
+ return nil, 0, err
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, 0, err
+ }
+ return data, info.Mode().Perm(), nil
+}
+
+// writeFile replaces path in one step. An agent may be reading its own config
+// while Gateway rewrites it, so the new content is staged in the same
directory
+// and renamed over the old file rather than truncating it in place.
+func writeFile(path string, data []byte, mode os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ staged, err := os.CreateTemp(dir, "."+filepath.Base(path)+".gateway-*")
+ if err != nil {
+ return err
+ }
+ name := staged.Name()
+ defer os.Remove(name)
+
+ if _, err := staged.Write(data); err != nil {
+ staged.Close()
+ return err
+ }
+ if err := staged.Sync(); err != nil {
+ staged.Close()
+ return err
+ }
+ if err := staged.Close(); err != nil {
+ return err
+ }
+ if err := os.Chmod(name, mode); err != nil {
+ return err
+ }
+ return os.Rename(name, path)
+}
+
+// checkName rejects a name that would escape the directory it is looked up in.
+// Names come from an API request, and a skill is a directory that gets deleted
+// or copied whole.
+func checkName(name string) error {
+ if name == "" {
+ return fmt.Errorf("the name is empty")
+ }
+ if name != filepath.Base(name) || name == "." || name == ".." ||
+ filepath.IsAbs(name) || filepath.VolumeName(name) != "" {
+ return fmt.Errorf("invalid name: %q", name)
+ }
+ return nil
+}
diff --git a/agentconfig/frontmatter.go b/agentconfig/frontmatter.go
new file mode 100644
index 0000000..8b1d390
--- /dev/null
+++ b/agentconfig/frontmatter.go
@@ -0,0 +1,121 @@
+// 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 "strings"
+
+const maxDescriptionRunes = 400
+
+// parseFrontMatter reads the name and description out of a SKILL.md header.
+//
+// The header is YAML, but a skill only ever declares flat string fields in it,
+// and two of them are all a list needs. Reading those two directly keeps this
a
+// leaf package and keeps a header Gateway does not fully understand from
+// costing the operator the whole listing.
+func parseFrontMatter(raw string) (string, string) {
+ lines, ok := frontMatterLines(raw)
+ if !ok {
+ return "", ""
+ }
+
+ name, description := "", ""
+ for index := 0; index < len(lines); index++ {
+ key, value := splitField(lines[index])
+ if key != "name" && key != "description" {
+ continue
+ }
+ if folded := strings.TrimSpace(value); folded == ">" || folded
== ">-" || folded == "|" || folded == "|-" {
+ var consumed int
+ value, consumed = readBlock(lines[index+1:],
strings.HasPrefix(folded, "|"))
+ index += consumed
+ }
+
+ if key == "name" {
+ name = unquote(value)
+ } else {
+ description = unquote(value)
+ }
+ }
+ return name, truncate(description)
+}
+
+// frontMatterLines returns the lines between the opening and closing --- of a
+// document that starts with one.
+func frontMatterLines(raw string) ([]string, bool) {
+ raw = strings.TrimLeft(raw, "\uFEFF \t\r\n")
+ if !strings.HasPrefix(raw, "---") {
+ return nil, false
+ }
+
+ lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
+ for index := 1; index < len(lines); index++ {
+ if strings.TrimRight(lines[index], " \t") == "---" {
+ return lines[1:index], true
+ }
+ }
+ return nil, false
+}
+
+// splitField returns a top-level "key: value" pair. An indented line belongs
to
+// the field above it and is not one itself.
+func splitField(line string) (string, string) {
+ if line == "" || line[0] == ' ' || line[0] == '\t' ||
strings.HasPrefix(strings.TrimSpace(line), "#") {
+ return "", ""
+ }
+ key, value, found := strings.Cut(line, ":")
+ if !found {
+ return "", ""
+ }
+ return strings.TrimSpace(key), strings.TrimSpace(value)
+}
+
+// readBlock collects the indented lines of a folded (>) or literal (|) scalar,
+// and reports how many lines it consumed.
+func readBlock(lines []string, literal bool) (string, int) {
+ collected := []string{}
+ consumed := 0
+ for _, line := range lines {
+ if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, "
") && !strings.HasPrefix(line, "\t") {
+ break
+ }
+ consumed++
+ collected = append(collected, strings.TrimSpace(line))
+ }
+
+ separator := " "
+ if literal {
+ separator = "\n"
+ }
+ return strings.TrimSpace(strings.Join(collected, separator)), consumed
+}
+
+func unquote(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) < 2 {
+ return value
+ }
+ if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\''
&& value[len(value)-1] == '\'') {
+ return strings.TrimSpace(value[1 : len(value)-1])
+ }
+ return value
+}
+
+func truncate(value string) string {
+ runes := []rune(value)
+ if len(runes) <= maxDescriptionRunes {
+ return value
+ }
+ return string(runes[:maxDescriptionRunes]) + "..."
+}
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
new file mode 100644
index 0000000..3ae1091
--- /dev/null
+++ b/agentconfig/layout.go
@@ -0,0 +1,192 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+// ManagedEntryName is the MCP server Gateway registers for its own monitoring.
+// It is listed like any other so an operator can see it, but it is never
+// migrated or deleted from here: turning monitoring off is what removes it.
+const ManagedEntryName = "casbin-gateway-agent-monitor"
+
+// layout is where one agent keeps its skills and MCP servers. A nil half means
+// the agent has no such location that Gateway knows about, which the UI shows
+// as unsupported rather than as empty.
+type layout struct {
+ skills *skillLayout
+ mcp *mcpLayout
+}
+
+// skillLayout is a directory of skill folders, each with a SKILL.md. Every
+// agent Gateway supports uses that same layout, which is what makes copying a
+// skill between two of them a plain directory copy.
+type skillLayout struct {
+ segments []string
+}
+
+func (l *skillLayout) dir(home string) string {
+ return filepath.Join(append([]string{home}, l.segments...)...)
+}
+
+// mcpLayout is one config file holding named MCP server entries.
+type mcpLayout struct {
+ file func(home string) string
+ store mcpStore
+ // readOnly explains, in the operator's words, why Gateway will not
write
+ // this file. Empty for the files it may write.
+ readOnly string
+}
+
+func (l *mcpLayout) path(home string) string {
+ return l.file(home)
+}
+
+// mcpStore is the file format half: JSON objects for most agents, TOML tables
+// for Codex. Entries are keyed by server name and hold that server's own
+// fields, in whatever spelling the file used.
+type mcpStore interface {
+ read(file string) (map[string]map[string]any, error)
+ write(file string, name string, entry map[string]any) error
+ remove(file string, name string) error
+}
+
+// layouts is the whole of Gateway's per-agent knowledge. The Codex CLI, the
+// Codex VS Code extension and the ChatGPT desktop app share one CODEX_HOME,
and
+// Cursor and its CLI share one ~/.cursor, so those ids share a layout.
+var layouts = map[string]layout{
+ "claude-code": {
+ skills: &skillLayout{segments: []string{".claude", "skills"}},
+ mcp: &mcpLayout{file: under(".claude.json"), store:
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+ },
+ "claude-desktop": {
+ mcp: &mcpLayout{file: claudeDesktopConfig, store:
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+ },
+ "codex": codexLayout,
+ "codex-cli": codexLayout,
+ "codex-vscode": codexLayout,
+ "codex_vscode": codexLayout,
+ "cursor": cursorLayout,
+ "cursor-agent": cursorLayout,
+ "windsurf": {
+ mcp: &mcpLayout{
+ file: under(".codeium", "windsurf", "mcp_config.json"),
+ store: &jsonStore{paths: [][]string{{"mcpServers"}}},
+ },
+ },
+ "openclaw": {
+ skills: &skillLayout{segments: []string{".openclaw", "skills"}},
+ mcp: &mcpLayout{
+ file: under(".openclaw", "openclaw.json"),
+ // OpenClaw has spelled this three ways across
versions, and reads
+ // all three; the first is where a new entry goes.
+ store: &jsonStore{paths: [][]string{{"mcp", "servers"},
{"mcp", "mcpServers"}, {"mcpServers"}}},
+ },
+ },
+}
+
+var codexLayout = layout{
+ skills: &skillLayout{segments: []string{".codex", "skills"}},
+ mcp: &mcpLayout{file: under(".codex", "config.toml"), store:
&tomlStore{table: "mcp_servers"}},
+}
+
+var cursorLayout = layout{
+ skills: &skillLayout{segments: []string{".cursor", "skills-cursor"}},
+ mcp: &mcpLayout{file: under(".cursor", "mcp.json"), store:
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+}
+
+func layoutOf(agentId string) (layout, bool) {
+ found, ok := layouts[agentId]
+ return found, ok
+}
+
+// Supports reports whether Gateway knows where agentId keeps either kind of
+// configuration, so a caller can skip an installation before resolving a home.
+func Supports(agentId string) bool {
+ _, ok := layouts[agentId]
+ return ok
+}
+
+// McpConfigPath is where agentId keeps its MCP servers, for the account whose
+// home directory is home. Callers outside this package use it to edit one
entry
+// of a file this package otherwise owns.
+func McpConfigPath(agentId string, home string) (string, bool) {
+ found, ok := layouts[agentId]
+ if !ok || found.mcp == nil {
+ return "", false
+ }
+ return found.mcp.path(home), true
+}
+
+func under(segments ...string) func(string) string {
+ return func(home string) string {
+ return filepath.Join(append([]string{home}, segments...)...)
+ }
+}
+
+func claudeDesktopConfig(home string) string {
+ switch runtime.GOOS {
+ case "windows":
+ return filepath.Join(home, "AppData", "Roaming", "Claude",
"claude_desktop_config.json")
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support",
"Claude", "claude_desktop_config.json")
+ default:
+ return filepath.Join(home, ".config", "Claude",
"claude_desktop_config.json")
+ }
+}
+
+func homeOf(owner string) (string, error) {
+ return agenthome.Resolve(owner)
+}
+
+// KnownAgents lists the agent ids Gateway has a layout for, in a stable order.
+func KnownAgents() []string {
+ ids := make([]string, 0, len(layouts))
+ for id := range layouts {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return ids
+}
+
+// Configured reports whether agentId has either of its locations on disk for
+// owner. An agent can be set up on an account without Gateway recognizing an
+// installation of it, and those skills are just as real as any other.
+func Configured(agentId string, owner string) bool {
+ found, ok := layouts[agentId]
+ if !ok {
+ return false
+ }
+ home, err := homeOf(owner)
+ if err != nil {
+ return false
+ }
+
+ if found.skills != nil && exists(found.skills.dir(home)) {
+ return true
+ }
+ return found.mcp != nil && exists(found.mcp.path(home))
+}
+
+func exists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/agentconfig/mcp_item.go b/agentconfig/mcp_item.go
new file mode 100644
index 0000000..99e4a51
--- /dev/null
+++ b/agentconfig/mcp_item.go
@@ -0,0 +1,103 @@
+// 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"
+ "strings"
+
+ "github.com/apache/casbin-gateway/auditutil"
+)
+
+// mcpItems summarizes one file's entries for a list. The fields below are the
+// ones every MCP host spells the same way; anything else an agent keeps in an
+// entry is preserved on copy and shown in the detail view.
+func mcpItems(agentId string, owner string, file string, entries
map[string]map[string]any) []*Item {
+ items := make([]*Item, 0, len(entries))
+ for name, entry := range entries {
+ item := &Item{
+ AgentId: agentId,
+ Owner: owner,
+ Kind: KindMcp,
+ Name: name,
+ Path: file,
+ Command: commandOf(entry),
+ Url: stringField(entry, "url", "serverUrl",
"endpoint"),
+ Managed: name == ManagedEntryName,
+ Transport: transportOf(entry),
+ }
+ items = append(items, item)
+ }
+ sortItems(items)
+ return items
+}
+
+// mcpDetail shows one entry as it is written in the file, with credentials
+// masked. Copying reads the file again, so what is migrated is the real entry
+// rather than this redacted view of it.
+func mcpDetail(agentId string, owner string, file string, name string, entry
map[string]any) (*Detail, error) {
+ redacted := auditutil.SanitizeValue("", entry)
+ content, err := json.MarshalIndent(redacted, "", " ")
+ if err != nil {
+ return nil, err
+ }
+
+ items := mcpItems(agentId, owner, file, map[string]map[string]any{name:
entry})
+ return &Detail{Item: items[0], Content: string(content)}, nil
+}
+
+// transportOf reports how the agent reaches the server. An entry that names
its
+// own type is believed; otherwise a command means the server is spawned
locally
+// and a URL means it is reached over HTTP.
+func transportOf(entry map[string]any) string {
+ if declared := strings.ToLower(stringField(entry, "type",
"transport")); declared != "" {
+ return declared
+ }
+ if commandOf(entry) != "" {
+ return "stdio"
+ }
+ if stringField(entry, "url", "serverUrl", "endpoint") != "" {
+ return "http"
+ }
+ return ""
+}
+
+// commandOf renders the spawn command the way a shell would show it, so a list
+// row says which binary runs rather than only that the server is local.
+func commandOf(entry map[string]any) string {
+ command := stringField(entry, "command")
+ if command == "" {
+ return ""
+ }
+
+ arguments, _ := entry["args"].([]any)
+ parts := make([]string, 0, len(arguments)+1)
+ parts = append(parts, command)
+ for _, argument := range arguments {
+ if text, ok := argument.(string); ok {
+ parts = append(parts, text)
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+func stringField(entry map[string]any, keys ...string) string {
+ for _, key := range keys {
+ if text, ok := entry[key].(string); ok && text != "" {
+ return text
+ }
+ }
+ return ""
+}
diff --git a/agentconfig/mcp_json.go b/agentconfig/mcp_json.go
new file mode 100644
index 0000000..e001845
--- /dev/null
+++ b/agentconfig/mcp_json.go
@@ -0,0 +1,175 @@
+// 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"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// jsonStore holds MCP servers in a JSON object somewhere in a JSON config
file.
+// paths are the places that object has been spelled; all of them are read, and
+// the first is where a new entry goes when the file has none of them yet.
+type jsonStore struct {
+ paths [][]string
+}
+
+func (store *jsonStore) read(file string) (map[string]map[string]any, error) {
+ config, _, _, err := store.load(file)
+ if err != nil {
+ return nil, err
+ }
+
+ entries := map[string]map[string]any{}
+ for _, path := range store.paths {
+ servers, ok := objectAt(config, path)
+ if !ok {
+ continue
+ }
+ for name, value := range servers {
+ entry, ok := value.(map[string]any)
+ if !ok || entries[name] != nil {
+ continue
+ }
+ entries[name] = entry
+ }
+ }
+ return entries, nil
+}
+
+func (store *jsonStore) write(file string, name string, entry map[string]any)
error {
+ if err := checkName(name); err != nil {
+ return err
+ }
+ if entry == nil {
+ return fmt.Errorf("the definition of MCP server %q is empty",
name)
+ }
+
+ config, mode, _, err := store.load(file)
+ if err != nil {
+ return err
+ }
+
+ servers, err := ensureObject(config, store.writePath(config))
+ if err != nil {
+ return err
+ }
+ servers[name] = entry
+ return store.save(file, config, mode)
+}
+
+func (store *jsonStore) remove(file string, name string) error {
+ config, mode, exists, err := store.load(file)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return fmt.Errorf("%s does not exist", file)
+ }
+
+ removed := false
+ for _, path := range store.paths {
+ servers, ok := objectAt(config, path)
+ if !ok {
+ continue
+ }
+ if _, found := servers[name]; found {
+ delete(servers, name)
+ removed = true
+ }
+ }
+ if !removed {
+ return fmt.Errorf("no MCP server named %q in %s", name, file)
+ }
+ return store.save(file, config, mode)
+}
+
+// writePath is the path a new entry goes to: the one the file already uses, so
+// an edit does not add a second spelling the agent may not merge.
+func (store *jsonStore) writePath(config map[string]any) []string {
+ for _, path := range store.paths {
+ if _, ok := objectAt(config, path); ok {
+ return path
+ }
+ }
+ return store.paths[0]
+}
+
+func (store *jsonStore) load(file string) (map[string]any, os.FileMode, bool,
error) {
+ data, mode, err := readFile(file)
+ if err != nil {
+ return nil, 0, false, err
+ }
+ if len(strings.TrimSpace(string(data))) == 0 {
+ return map[string]any{}, mode, len(data) > 0, nil
+ }
+
+ config := map[string]any{}
+ if err := json.Unmarshal(data, &config); err != nil {
+ return nil, 0, false, fmt.Errorf("parse %s: %w", file, err)
+ }
+ return config, mode, true, nil
+}
+
+func (store *jsonStore) save(file string, config map[string]any, mode
os.FileMode) error {
+ data, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return err
+ }
+ return writeFile(file, append(data, '\n'), mode)
+}
+
+func objectAt(config map[string]any, path []string) (map[string]any, bool) {
+ current := config
+ for index, key := range path {
+ value, ok := current[key]
+ if !ok {
+ return nil, false
+ }
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ if index == len(path)-1 {
+ return object, true
+ }
+ current = object
+ }
+ return nil, false
+}
+
+// ensureObject walks path, creating the objects that are missing. A key
already
+// holding something that is not an object is an error rather than a value to
+// overwrite: that is somebody else's setting.
+func ensureObject(config map[string]any, path []string) (map[string]any,
error) {
+ current := config
+ for _, key := range path {
+ value, ok := current[key]
+ if !ok {
+ created := map[string]any{}
+ current[key] = created
+ current = created
+ continue
+ }
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("%q is not a JSON object",
strings.Join(path, "."))
+ }
+ current = object
+ }
+ return current, nil
+}
diff --git a/agentconfig/mcp_toml.go b/agentconfig/mcp_toml.go
new file mode 100644
index 0000000..c68c746
--- /dev/null
+++ b/agentconfig/mcp_toml.go
@@ -0,0 +1,291 @@
+// 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 (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+// tomlStore holds MCP servers as sub-tables of one TOML table, which is how
+// Codex keeps them in config.toml.
+//
+// Reading decodes the file, but writing edits its text: config.toml is a
+// hand-written file with comments and formatting in it, and re-encoding a
+// decoded document would hand it back stripped of both.
+type tomlStore struct {
+ table string
+}
+
+func (store *tomlStore) read(file string) (map[string]map[string]any, error) {
+ data, _, err := readFile(file)
+ if err != nil {
+ return nil, err
+ }
+
+ document := map[string]any{}
+ if len(strings.TrimSpace(string(data))) > 0 {
+ if err := toml.Unmarshal(data, &document); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", file, err)
+ }
+ }
+
+ entries := map[string]map[string]any{}
+ servers, ok := document[store.table].(map[string]any)
+ if !ok {
+ return entries, nil
+ }
+ for name, value := range servers {
+ if entry, ok := value.(map[string]any); ok {
+ entries[name] = entry
+ }
+ }
+ return entries, nil
+}
+
+func (store *tomlStore) write(file string, name string, entry map[string]any)
error {
+ if err := checkName(name); err != nil {
+ return err
+ }
+ if entry == nil {
+ return fmt.Errorf("the definition of MCP server %q is empty",
name)
+ }
+
+ block, err := store.render(name, entry)
+ if err != nil {
+ return err
+ }
+
+ data, mode, err := readFile(file)
+ if err != nil {
+ return err
+ }
+ text, _ := store.cut(string(data), name)
+
+ text = strings.TrimRight(text, "\n")
+ if text != "" {
+ text += "\n\n"
+ }
+ return writeFile(file, []byte(text+block), mode)
+}
+
+func (store *tomlStore) remove(file string, name string) error {
+ data, mode, err := readFile(file)
+ if err != nil {
+ return err
+ }
+
+ text, found := store.cut(string(data), name)
+ if !found {
+ return fmt.Errorf("no MCP server named %q in %s", name, file)
+ }
+ return writeFile(file, []byte(text), mode)
+}
+
+// cut removes one server's table and its sub-tables from the document text,
+// leaving every other line, comment and blank line exactly as it was.
+func (store *tomlStore) cut(text string, name string) (string, bool) {
+ lines := strings.Split(text, "\n")
+ kept := make([]string, 0, len(lines))
+ dropping := false
+ found := false
+
+ for _, line := range lines {
+ header, isHeader := tableHeader(line)
+ if isHeader {
+ dropping = store.owns(header, name)
+ found = found || dropping
+ }
+ if !dropping {
+ kept = append(kept, line)
+ }
+ }
+
+ result := strings.Join(kept, "\n")
+ if found {
+ result = strings.TrimRight(result, "\n") + "\n"
+ }
+ return result, found
+}
+
+// owns reports whether a table header belongs to one server:
[mcp_servers.name]
+// itself, or any sub-table of it such as [mcp_servers.name.env].
+func (store *tomlStore) owns(header []string, name string) bool {
+ return len(header) >= 2 && header[0] == store.table && header[1] == name
+}
+
+// render writes one server as TOML text. Scalars come first and sub-tables
+// after, because everything following a sub-table header belongs to it.
+func (store *tomlStore) render(name string, entry map[string]any) (string,
error) {
+ scalars := &strings.Builder{}
+ tables := &strings.Builder{}
+
+ fmt.Fprintf(scalars, "[%s.%s]\n", tomlKey(store.table), tomlKey(name))
+ for _, key := range sortedKeys(entry) {
+ value := entry[key]
+ if nested, ok := value.(map[string]any); ok {
+ fmt.Fprintf(tables, "\n[%s.%s.%s]\n",
tomlKey(store.table), tomlKey(name), tomlKey(key))
+ for _, nestedKey := range sortedKeys(nested) {
+ text, err := tomlValue(nested[nestedKey])
+ if err != nil {
+ return "", fmt.Errorf("%s.%s: %w", key,
nestedKey, err)
+ }
+ fmt.Fprintf(tables, "%s = %s\n",
tomlKey(nestedKey), text)
+ }
+ continue
+ }
+
+ text, err := tomlValue(value)
+ if err != nil {
+ return "", fmt.Errorf("%s: %w", key, err)
+ }
+ fmt.Fprintf(scalars, "%s = %s\n", tomlKey(key), text)
+ }
+ return scalars.String() + tables.String(), nil
+}
+
+// tableHeader parses a [a.b.c] line into its dotted parts. Anything else, an
+// array-of-tables header included, is not a header this package edits.
+func tableHeader(line string) ([]string, bool) {
+ trimmed := strings.TrimSpace(line)
+ if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[")
{
+ return nil, false
+ }
+ end := strings.LastIndex(trimmed, "]")
+ if end <= 0 {
+ return nil, false
+ }
+ // A comment may follow the header, but nothing else may.
+ if rest := strings.TrimSpace(trimmed[end+1:]); rest != "" &&
!strings.HasPrefix(rest, "#") {
+ return nil, false
+ }
+ return splitTomlKey(trimmed[1:end]), true
+}
+
+// splitTomlKey splits a dotted key, honouring quoted parts and unquoting them,
+// so ["my-server"] and [my-server] name the same table.
+func splitTomlKey(key string) []string {
+ parts := []string{}
+ current := &strings.Builder{}
+ quote := rune(0)
+
+ for _, char := range key {
+ switch {
+ case quote != 0:
+ if char == quote {
+ quote = 0
+ continue
+ }
+ current.WriteRune(char)
+ case char == '"' || char == '\'':
+ quote = char
+ case char == '.':
+ parts = append(parts,
strings.TrimSpace(current.String()))
+ current.Reset()
+ default:
+ current.WriteRune(char)
+ }
+ }
+ return append(parts, strings.TrimSpace(current.String()))
+}
+
+// tomlKey quotes a key that is not a bare TOML key.
+func tomlKey(key string) string {
+ for _, char := range key {
+ bare := char == '-' || char == '_' ||
+ (char >= 'a' && char <= 'z') || (char >= 'A' && char <=
'Z') || (char >= '0' && char <= '9')
+ if !bare {
+ return quoteToml(key)
+ }
+ }
+ if key == "" {
+ return `""`
+ }
+ return key
+}
+
+func tomlValue(value any) (string, error) {
+ switch typed := value.(type) {
+ case string:
+ return quoteToml(typed), nil
+ case bool:
+ return strconv.FormatBool(typed), nil
+ case int:
+ return strconv.Itoa(typed), nil
+ case int64:
+ return strconv.FormatInt(typed, 10), nil
+ case float64:
+ // JSON has no integers, so a whole number arriving from a JSON
config
+ // has to go back out as one: Codex rejects 120.0 for a timeout.
+ if typed == float64(int64(typed)) {
+ return strconv.FormatInt(int64(typed), 10), nil
+ }
+ return strconv.FormatFloat(typed, 'f', -1, 64), nil
+ case []any:
+ items := make([]string, 0, len(typed))
+ for _, item := range typed {
+ text, err := tomlValue(item)
+ if err != nil {
+ return "", err
+ }
+ items = append(items, text)
+ }
+ return "[" + strings.Join(items, ", ") + "]", nil
+ case nil:
+ return "", fmt.Errorf("a null value has no TOML equivalent")
+ default:
+ return "", fmt.Errorf("a %T value has no TOML equivalent",
value)
+ }
+}
+
+func quoteToml(value string) string {
+ escaped := &strings.Builder{}
+ escaped.WriteByte('"')
+ for _, char := range value {
+ switch char {
+ case '"':
+ escaped.WriteString(`\"`)
+ case '\\':
+ escaped.WriteString(`\\`)
+ case '\n':
+ escaped.WriteString(`\n`)
+ case '\r':
+ escaped.WriteString(`\r`)
+ case '\t':
+ escaped.WriteString(`\t`)
+ default:
+ if char < 0x20 {
+ fmt.Fprintf(escaped, `\u%04X`, char)
+ continue
+ }
+ escaped.WriteRune(char)
+ }
+ }
+ escaped.WriteByte('"')
+ return escaped.String()
+}
+
+func sortedKeys(entry map[string]any) []string {
+ keys := make([]string, 0, len(entry))
+ for key := range entry {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/agentconfig/mcp_toml_test.go b/agentconfig/mcp_toml_test.go
new file mode 100644
index 0000000..d71c21b
--- /dev/null
+++ b/agentconfig/mcp_toml_test.go
@@ -0,0 +1,207 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const codexConfig = `# the operator's own notes
+model = "gpt-5"
+
+[features]
+js_repl = false
+
+[mcp_servers.node_repl]
+command = 'C:\bin\node_repl.exe'
+args = []
+startup_timeout_sec = 120
+
+[mcp_servers.node_repl.env]
+NODE_REPL_NODE_PATH = 'C:\bin\node.exe'
+
+[desktop]
+followUpQueueMode = "queue"
+`
+
+func codexFile(t *testing.T, content string) (*tomlStore, string) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.toml")
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return &tomlStore{table: "mcp_servers"}, path
+}
+
+func TestTomlReadsServersAndTheirEnvironment(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ entries, err := store.read(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entry, ok := entries["node_repl"]
+ if !ok {
+ t.Fatalf("entries = %v", entries)
+ }
+ if entry["command"] != `C:\bin\node_repl.exe` {
+ t.Fatalf("command = %v", entry["command"])
+ }
+ environment, ok := entry["env"].(map[string]any)
+ if !ok || environment["NODE_REPL_NODE_PATH"] != `C:\bin\node.exe` {
+ t.Fatalf("env = %v", entry["env"])
+ }
+}
+
+func TestTomlRemoveKeepsCommentsAndOtherTables(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ if err := store.remove(path, "node_repl"); err != nil {
+ t.Fatal(err)
+ }
+
+ content := readBack(t, path)
+ if strings.Contains(content, "mcp_servers") {
+ t.Fatalf("the table survived: %s", content)
+ }
+ for _, kept := range []string{"# the operator's own notes", `model =
"gpt-5"`, "[features]", "[desktop]"} {
+ if !strings.Contains(content, kept) {
+ t.Fatalf("remove dropped %q: %s", kept, content)
+ }
+ }
+}
+
+func TestTomlRemoveReportsAMissingServer(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ if err := store.remove(path, "absent"); err == nil {
+ t.Fatal("removing a server that is not there succeeded")
+ }
+ if readBack(t, path) != codexConfig {
+ t.Fatal("a failed remove rewrote the file")
+ }
+}
+
+func TestTomlWriteReplacesOneServerInPlace(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ entry := map[string]any{
+ "command": "npx",
+ "args": []any{"-y",
"@modelcontextprotocol/server-files"},
+ "startup_timeout_sec": float64(30),
+ "env": map[string]any{"API_KEY": "secret"},
+ }
+ if err := store.write(path, "node_repl", entry); err != nil {
+ t.Fatal(err)
+ }
+
+ content := readBack(t, path)
+ if strings.Contains(content, "node_repl.exe") {
+ t.Fatalf("the old definition survived: %s", content)
+ }
+ if !strings.Contains(content, "startup_timeout_sec = 30") {
+ t.Fatalf("a whole number was written as a float: %s", content)
+ }
+ if !strings.Contains(content, "[desktop]") {
+ t.Fatalf("write dropped a later table: %s", content)
+ }
+
+ entries, err := store.read(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ written := entries["node_repl"]
+ if written["command"] != "npx" {
+ t.Fatalf("command = %v", written["command"])
+ }
+ arguments, _ := written["args"].([]any)
+ if len(arguments) != 2 || arguments[0] != "-y" {
+ t.Fatalf("args = %v", written["args"])
+ }
+ environment, _ := written["env"].(map[string]any)
+ if environment["API_KEY"] != "secret" {
+ t.Fatalf("env = %v", written["env"])
+ }
+}
+
+func TestTomlWriteCreatesAMissingFile(t *testing.T) {
+ store := &tomlStore{table: "mcp_servers"}
+ path := filepath.Join(t.TempDir(), "nested", "config.toml")
+
+ if err := store.write(path, "files", map[string]any{"command": "npx"});
err != nil {
+ t.Fatal(err)
+ }
+ entries, err := store.read(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entries["files"]["command"] != "npx" {
+ t.Fatalf("entries = %v", entries)
+ }
+}
+
+func TestTomlQuotesANameThatIsNotABareKey(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ if err := store.write(path, "my server", map[string]any{"command":
"npx"}); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(readBack(t, path), `[mcp_servers."my server"]`) {
+ t.Fatalf("content = %s", readBack(t, path))
+ }
+
+ if err := store.remove(path, "my server"); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(readBack(t, path), "my server") {
+ t.Fatalf("a quoted name was not matched on remove: %s",
readBack(t, path))
+ }
+}
+
+func TestTomlRejectsAValueItCannotWrite(t *testing.T) {
+ store, path := codexFile(t, codexConfig)
+
+ err := store.write(path, "files", map[string]any{"command": "npx",
"deep": map[string]any{
+ "nested": map[string]any{"too": "far"},
+ }})
+ if err == nil {
+ t.Fatal("a value with no TOML equivalent was accepted")
+ }
+ if readBack(t, path) != codexConfig {
+ t.Fatal("a rejected write still changed the file")
+ }
+}
+
+func TestFrontMatterReadsFoldedAndQuotedFields(t *testing.T) {
+ name, description := parseFrontMatter("---\nname: babysit\ndescription:
>-\n Keep a PR merge-ready by\n fixing CI in a loop.\n---\n# body\n")
+ if name != "babysit" {
+ t.Fatalf("name = %q", name)
+ }
+ if description != "Keep a PR merge-ready by fixing CI in a loop." {
+ t.Fatalf("description = %q", description)
+ }
+
+ _, quoted := parseFrontMatter("---\nname: a\ndescription: 'Work with
PDFs'\n---\n")
+ if quoted != "Work with PDFs" {
+ t.Fatalf("description = %q", quoted)
+ }
+
+ if _, none := parseFrontMatter("# no front matter\ndescription: not a
field\n"); none != "" {
+ t.Fatalf("description = %q", none)
+ }
+}
diff --git a/agentconfig/skill.go b/agentconfig/skill.go
new file mode 100644
index 0000000..cb7d228
--- /dev/null
+++ b/agentconfig/skill.go
@@ -0,0 +1,290 @@
+// 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 (
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const (
+ // skillFile is the manifest that makes a directory a skill rather than
+ // whatever else the operator keeps next to their skills.
+ skillFile = "SKILL.md"
+ // maxSkillFileBytes bounds what the detail view loads: a skill is
meant to
+ // be read by a model, but nothing stops one from carrying a large file.
+ maxSkillFileBytes = 256 * 1024
+ // maxSkillFiles bounds the file list shown beside a skill's manifest.
+ maxSkillFiles = 200
+)
+
+// readSkills lists the skill folders in dir. A directory that does not exist
is
+// an agent with no skills yet, not an error.
+func readSkills(agentId string, owner string, dir string) ([]*Item, error) {
+ entries, err := os.ReadDir(dir)
+ if os.IsNotExist(err) {
+ return []*Item{}, nil
+ }
+ if err != nil {
+ return []*Item{}, err
+ }
+
+ items := []*Item{}
+ for _, entry := range entries {
+ // A dot directory is the agent's own bookkeeping, such as the
manifest
+ // Cursor writes beside the skills it manages.
+ if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
+ continue
+ }
+ path := filepath.Join(dir, entry.Name())
+ manifest, ok := manifestPath(path)
+ if !ok {
+ continue
+ }
+
+ item := &Item{
+ AgentId: agentId,
+ Owner: owner,
+ Kind: KindSkill,
+ Name: entry.Name(),
+ Path: path,
+ }
+ if raw, err := os.ReadFile(manifest); err == nil {
+ _, item.Description = parseFrontMatter(string(raw))
+ }
+ item.Files, item.Bytes = measure(path)
+ items = append(items, item)
+ }
+ sortItems(items)
+ return items, nil
+}
+
+// skillDetail loads one skill's manifest and the names of the files shipped
+// with it.
+func skillDetail(agentId string, owner string, dir string, name string)
(*Detail, error) {
+ if err := checkName(name); err != nil {
+ return nil, err
+ }
+
+ path := filepath.Join(dir, name)
+ manifest, ok := manifestPath(path)
+ if !ok {
+ return nil, fmt.Errorf("no %s in %s", skillFile, path)
+ }
+
+ raw, err := os.ReadFile(manifest)
+ if err != nil {
+ return nil, err
+ }
+ content := string(raw)
+ if len(raw) > maxSkillFileBytes {
+ content = string(raw[:maxSkillFileBytes]) + "\n\n... truncated
by Gateway ..."
+ }
+
+ _, description := parseFrontMatter(content)
+ files, bytes := measure(path)
+ item := &Item{
+ AgentId: agentId,
+ Owner: owner,
+ Kind: KindSkill,
+ Name: name,
+ Description: description,
+ Path: path,
+ Files: files,
+ Bytes: bytes,
+ }
+ return &Detail{Item: item, Content: content, Files: listFiles(path)},
nil
+}
+
+// deleteSkill removes one skill folder. It refuses a directory without a
+// manifest, so a mistyped name cannot delete something that is not a skill.
+func deleteSkill(dir string, name string) error {
+ if err := checkName(name); err != nil {
+ return err
+ }
+
+ path := filepath.Join(dir, name)
+ if _, ok := manifestPath(path); !ok {
+ return fmt.Errorf("%s is not a skill folder", path)
+ }
+ return os.RemoveAll(path)
+}
+
+// copySkill copies one skill folder into another agent's skills directory,
+// replacing what is there. The two agents Gateway supports keep skills in the
+// same format, so the copy is the folder itself and nothing is converted.
+func copySkill(from string, dir string, name string) (string, error) {
+ if err := checkName(name); err != nil {
+ return "", err
+ }
+ if _, ok := manifestPath(from); !ok {
+ return "", fmt.Errorf("%s is not a skill folder", from)
+ }
+
+ to := filepath.Join(dir, name)
+ same, err := samePath(from, to)
+ if err != nil {
+ return "", err
+ }
+ if same {
+ return "", fmt.Errorf("%s is already the source of this copy",
to)
+ }
+
+ staged := to + ".gateway-copy"
+ if err := os.RemoveAll(staged); err != nil {
+ return "", err
+ }
+ if err := copyTree(from, staged); err != nil {
+ os.RemoveAll(staged)
+ return "", err
+ }
+ if err := os.RemoveAll(to); err != nil {
+ os.RemoveAll(staged)
+ return "", err
+ }
+ if err := os.Rename(staged, to); err != nil {
+ os.RemoveAll(staged)
+ return "", err
+ }
+ return to, nil
+}
+
+// manifestPath finds the skill manifest inside a folder. The name is matched
+// case-insensitively because the agents that write it do not agree on the case
+// on the file systems that care.
+func manifestPath(dir string) (string, bool) {
+ path := filepath.Join(dir, skillFile)
+ if _, err := os.Stat(path); err == nil {
+ return path, true
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", false
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.EqualFold(entry.Name(), skillFile)
{
+ return filepath.Join(dir, entry.Name()), true
+ }
+ }
+ return "", false
+}
+
+func measure(dir string) (int, int64) {
+ files := 0
+ var bytes int64
+ filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error)
error {
+ if err != nil || entry.IsDir() {
+ return nil
+ }
+ files++
+ if info, err := entry.Info(); err == nil {
+ bytes += info.Size()
+ }
+ return nil
+ })
+ return files, bytes
+}
+
+// listFiles names what a skill ships besides its manifest, in slash form so
the
+// list reads the same on every platform.
+func listFiles(dir string) []string {
+ files := []string{}
+ filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error)
error {
+ if err != nil || entry.IsDir() || len(files) >= maxSkillFiles {
+ return nil
+ }
+ relative, err := filepath.Rel(dir, path)
+ if err != nil || strings.EqualFold(relative, skillFile) {
+ return nil
+ }
+ files = append(files, filepath.ToSlash(relative))
+ return nil
+ })
+ sort.Strings(files)
+ return files
+}
+
+func copyTree(from string, to string) error {
+ return filepath.WalkDir(from, func(path string, entry fs.DirEntry, err
error) error {
+ if err != nil {
+ return err
+ }
+ relative, err := filepath.Rel(from, path)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(to, relative)
+
+ if entry.IsDir() {
+ return os.MkdirAll(target, 0o755)
+ }
+ // A symbolic link in a skill folder would point at the source
agent's
+ // directory, so it is left behind rather than copied as a
broken link.
+ if !entry.Type().IsRegular() {
+ return nil
+ }
+ return copyOneFile(path, target, entry)
+ })
+}
+
+func copyOneFile(path string, target string, entry fs.DirEntry) error {
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+
+ source, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ destination, err := os.OpenFile(target,
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(destination, source); err != nil {
+ destination.Close()
+ return err
+ }
+ return destination.Close()
+}
+
+// samePath compares two paths by what they resolve to, so a copy cannot be
+// asked to overwrite its own source through a link or a differently spelled
+// path to the same directory.
+func samePath(left string, right string) (bool, error) {
+ leftInfo, err := os.Stat(left)
+ if err != nil {
+ return false, err
+ }
+ rightInfo, err := os.Stat(right)
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return os.SameFile(leftInfo, rightInfo), nil
+}
diff --git a/agentpatch/home.go b/agenthome/home.go
similarity index 81%
rename from agentpatch/home.go
rename to agenthome/home.go
index 24659ca..d2323c3 100644
--- a/agentpatch/home.go
+++ b/agenthome/home.go
@@ -12,7 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package agentpatch
+// Package agenthome resolves the home directory of an agent installation's
+// owner. Both the monitoring patches and the skill/MCP configuration reader
+// write into that home, and neither should own the account lookup.
+package agenthome
import (
"fmt"
@@ -22,12 +25,12 @@ import (
"strings"
)
-// homeOf resolves the home directory of the installation owner. Patching
writes
-// into that home, so guessing here would silently modify the wrong account's
-// configuration: an unresolvable owner is an error rather than a fallback to
-// whichever account happens to run Gateway.
-func homeOf(target Target) (string, error) {
- owner := strings.TrimSpace(target.Owner)
+// Resolve returns the home directory of owner. Callers write into that home,
so
+// guessing here would silently modify the wrong account's configuration: an
+// unresolvable owner is an error rather than a fallback to whichever account
+// happens to run Gateway.
+func Resolve(owner string) (string, error) {
+ owner = strings.TrimSpace(owner)
if owner == "" {
home, err := os.UserHomeDir()
if err != nil {
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 297c9b8..9e20585 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -19,6 +19,7 @@ import (
"os"
"path/filepath"
+ "github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agenthook"
)
@@ -120,7 +121,7 @@ func (claudeCodePatcher) Status(target Target) (Status,
error) {
}
func claudeCodeConfigPath(target Target) (string, error) {
- home, err := homeOf(target)
+ home, err := agenthome.Resolve(target.Owner)
if err != nil {
return "", err
}
diff --git a/agentpatch/claude_desktop.go b/agentpatch/claude_desktop.go
index 40f396d..c758900 100644
--- a/agentpatch/claude_desktop.go
+++ b/agentpatch/claude_desktop.go
@@ -19,10 +19,11 @@ import (
"errors"
"fmt"
"os"
- "path/filepath"
"runtime"
"strings"
+ "github.com/apache/casbin-gateway/agentconfig"
+ "github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/mcpserver"
)
@@ -160,18 +161,15 @@ func (p claudeDesktopPatcher) PatchNotice(patched bool)
(string, string) {
}
func (p claudeDesktopPatcher) configPath(target Target) (string, error) {
- home, err := homeOf(target)
+ home, err := agenthome.Resolve(target.Owner)
if err != nil {
return "", err
}
- switch runtime.GOOS {
- case "windows":
- return filepath.Join(home, "AppData", "Roaming", "Claude",
"claude_desktop_config.json"), nil
- case "darwin":
- return filepath.Join(home, "Library", "Application Support",
"Claude", "claude_desktop_config.json"), nil
- default:
- return filepath.Join(home, ".config", "Claude",
"claude_desktop_config.json"), nil
+ path, ok := agentconfig.McpConfigPath(p.AgentId(), home)
+ if !ok {
+ return "", errors.New("Gateway does not know where Claude
Desktop keeps its MCP servers")
}
+ return path, nil
}
func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any,
error) {
diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go
index 33ce6ce..85fa2bd 100644
--- a/agentpatch/openclaw.go
+++ b/agentpatch/openclaw.go
@@ -22,6 +22,7 @@ import (
"path/filepath"
"strings"
+ "github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agentmonitor"
)
@@ -118,7 +119,7 @@ type openclawLayout struct {
}
func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) {
- home, err := homeOf(target)
+ home, err := agenthome.Resolve(target.Owner)
if err != nil {
return openclawLayout{}, err
}
diff --git a/agentpatch/state.go b/agentpatch/state.go
index 1ffabab..6b38e19 100644
--- a/agentpatch/state.go
+++ b/agentpatch/state.go
@@ -26,12 +26,14 @@ import (
"sync"
"time"
+ "github.com/apache/casbin-gateway/agentconfig"
"github.com/apache/casbin-gateway/conf"
)
// gatewayEntryName is the identifier Gateway writes into every agent
-// configuration it touches, so operators can find and remove it by hand.
-const gatewayEntryName = "casbin-gateway-agent-monitor"
+// configuration it touches, so operators can find and remove it by hand. The
+// skill and MCP listings recognize it by the same name.
+const gatewayEntryName = agentconfig.ManagedEntryName
type changeKind string
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
new file mode 100644
index 0000000..58041c3
--- /dev/null
+++ b/controllers/agent_config.go
@@ -0,0 +1,197 @@
+// 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"
+ "slices"
+ "sort"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentconfig"
+)
+
+// agentConfigView is one installation's configuration, named the way the agent
+// list names it.
+type agentConfigView struct {
+ *agentconfig.Inventory
+
+ Name string `json:"name"`
+ Path string `json:"path,omitempty"`
+ // Installed is false for configuration found without a matching
installation.
+ Installed bool `json:"installed"`
+ // SharedWith names the other agents reading the same files. Cursor and
its
+ // CLI share one ~/.cursor, and the Codex front ends one CODEX_HOME, so
those
+ // appear once with the others listed here rather than as separate
copies of
+ // the same configuration.
+ SharedWith []string `json:"sharedWith,omitempty"`
+}
+
+// GetAgentConfigs lists the skills and MCP servers of every agent on this host
+// whose configuration layout Gateway knows.
+func (c *ApiController) GetAgentConfigs() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ installations, err := agent.Scan(c.GetString("refresh") == "true")
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ collected := &agentConfigs{seen: map[string]*agentConfigView{}}
+ for _, installation := range installations {
+ collected.add(installation.AgentId, installation.Owner,
installation.Name, installation.Path, true)
+ }
+
+ // An agent can be configured on the account Gateway runs as without its
+ // installation being recognized — installed through a package manager
+ // Gateway does not scan, or already uninstalled. Its skills are on disk
+ // either way, so the page would be wrong to leave them out.
+ for _, agentId := range agentconfig.KnownAgents() {
+ name := agent.DisplayNameOf(agentId)
+ if name == "" || !agentconfig.Configured(agentId, "") {
+ continue
+ }
+ collected.add(agentId, "", name, "", false)
+ }
+
+ sort.Slice(collected.views, func(i, j int) bool { return
collected.views[i].Name < collected.views[j].Name })
+ c.ResponseOk(collected.views)
+}
+
+// agentConfigs collects one view per configuration location. Several agent ids
+// can read the same files, and the page lists what is on disk rather than who
+// reads it, so the second id to arrive is recorded as a reader of the first.
+type agentConfigs struct {
+ views []*agentConfigView
+ seen map[string]*agentConfigView
+}
+
+func (collected *agentConfigs) add(agentId string, owner string, name string,
path string, installed bool) {
+ if !agentconfig.Supports(agentId) {
+ return
+ }
+
+ inventory := agentconfig.Read(agentId, owner)
+ key := inventory.SkillsDir + "\x00" + inventory.McpFile
+ if first, ok := collected.seen[key]; ok {
+ if first.AgentId != agentId &&
!slices.Contains(first.SharedWith, name) {
+ first.SharedWith = append(first.SharedWith, name)
+ }
+ return
+ }
+
+ view := &agentConfigView{
+ Inventory: inventory,
+ Name: name,
+ Path: path,
+ Installed: installed,
+ }
+ collected.seen[key] = view
+ collected.views = append(collected.views, view)
+}
+
+// GetAgentConfigItem returns one skill's manifest or one MCP server's entry.
+func (c *ApiController) GetAgentConfigItem() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ detail, err := agentconfig.ReadDetail(
+ c.GetString("agentId"), c.GetString("owner"),
c.GetString("kind"), c.GetString("name"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(detail)
+}
+
+// DeleteAgentConfigItem removes one skill or MCP server from the agent's own
+// configuration.
+func (c *ApiController) DeleteAgentConfigItem() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ AgentId string `json:"agentId"`
+ Owner string `json:"owner"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if form.Name == agentconfig.ManagedEntryName {
+ c.ResponseError("this entry belongs to Gateway's agent
monitoring; turn monitoring off to remove it")
+ return
+ }
+
+ if err := agentconfig.Delete(form.AgentId, form.Owner, form.Kind,
form.Name); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(form.Name)
+}
+
+// PlanAgentConfigCopy reports what CopyAgentConfig would do, so the operator
+// sees which targets already have an item before anything is written.
+func (c *ApiController) PlanAgentConfigCopy() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ request, ok := c.readCopyRequest()
+ if !ok {
+ return
+ }
+ planned, err := agentconfig.Plan(request)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(planned)
+}
+
+// CopyAgentConfig migrates the selected skills or MCP servers into the target
+// agents' own configuration files.
+func (c *ApiController) CopyAgentConfig() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ request, ok := c.readCopyRequest()
+ if !ok {
+ return
+ }
+ result, err := agentconfig.Copy(request)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(result)
+}
+
+func (c *ApiController) readCopyRequest() (agentconfig.CopyRequest, bool) {
+ var request agentconfig.CopyRequest
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return request, false
+ }
+ return request, true
+}
diff --git a/routers/router.go b/routers/router.go
index 589968c..4932a4e 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -70,6 +70,11 @@ func initAPI() {
beego.Router("/api/get-agent-records", &controllers.ApiController{},
"GET:GetAgentRecords")
beego.Router("/api/get-agent-sessions", &controllers.ApiController{},
"GET:GetAgentSessions")
beego.Router("/api/add-agent-record", &controllers.ApiController{},
"POST:AddAgentRecord")
+ beego.Router("/api/get-agent-configs", &controllers.ApiController{},
"GET:GetAgentConfigs")
+ beego.Router("/api/get-agent-config-item",
&controllers.ApiController{}, "GET:GetAgentConfigItem")
+ beego.Router("/api/delete-agent-config-item",
&controllers.ApiController{}, "POST:DeleteAgentConfigItem")
+ beego.Router("/api/plan-agent-config-copy",
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
+ beego.Router("/api/copy-agent-config", &controllers.ApiController{},
"POST:CopyAgentConfig")
beego.Router("/api/get-records", &controllers.ApiController{},
"GET:GetRecords")
beego.Router("/api/get-record", &controllers.ApiController{},
"GET:GetRecord")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8ecade2..94268b5 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,6 +27,7 @@ import {Loading} from "@/components/shared/loading";
import {TooltipProvider} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import AccountPage from "@/pages/AccountPage";
+import AgentConfigsPage from "@/pages/AgentConfigsPage";
import AgentDashboardPage from "@/pages/AgentDashboardPage";
import AgentDetailPage from "@/pages/AgentDetailPage";
import AgentRecordsPage from "@/pages/AgentRecordsPage";
@@ -255,6 +256,10 @@ export default function App() {
path="/agents/:agentId"
element={requireSignin(user => <AgentDetailPage
account={user} />)}
/>
+ <Route
+ path="/agent-configs"
+ element={requireSignin(user => <AgentConfigsPage
account={user} />)}
+ />
<Route
path="/agent-records"
element={requireSignin(user => <AgentRecordsPage
account={user} />)}
diff --git a/web/src/backend/AgentConfigBackend.ts
b/web/src/backend/AgentConfigBackend.ts
new file mode 100644
index 0000000..770cbee
--- /dev/null
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -0,0 +1,58 @@
+// 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 {query, request} from "@/backend/request";
+import type {
+ AgentConfigDetail,
+ AgentConfigInventory,
+ AgentConfigKind,
+ AgentConfigPlanItem,
+} from "@/types";
+
+export interface CopyRequest {
+ owner: string;
+ from: string;
+ to: string[];
+ kind: AgentConfigKind;
+ names: string[];
+ overwrite: boolean;
+}
+
+export function getAgentConfigs(forceRefresh = false) {
+ return request<AgentConfigInventory[]>(`/api/get-agent-configs${forceRefresh
? "?refresh=true" : ""}`);
+}
+
+export function getAgentConfigItem(agentId: string, owner: string, kind:
AgentConfigKind, name: string) {
+ return request<AgentConfigDetail>(
+ `/api/get-agent-config-item${query({agentId: agentId, owner: owner, kind:
kind, name: name})}`,
+ );
+}
+
+export function deleteAgentConfigItem(agentId: string, owner: string, kind:
AgentConfigKind, name: string) {
+ return request("/api/delete-agent-config-item", "POST", {
+ agentId: agentId,
+ owner: owner,
+ kind: kind,
+ name: name,
+ });
+}
+
+/** What a copy would do, asked for before anything is written. */
+export function planAgentConfigCopy(body: CopyRequest) {
+ return request<AgentConfigPlanItem[]>("/api/plan-agent-config-copy", "POST",
body);
+}
+
+export function copyAgentConfig(body: CopyRequest) {
+ return request<AgentConfigPlanItem[]>("/api/copy-agent-config", "POST",
body);
+}
diff --git a/web/src/components/agent-config/copy-dialog.tsx
b/web/src/components/agent-config/copy-dialog.tsx
new file mode 100644
index 0000000..8b52db7
--- /dev/null
+++ b/web/src/components/agent-config/copy-dialog.tsx
@@ -0,0 +1,295 @@
+// 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 {ArrowRight} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {blockedReason, counted, inventoryKey} from "@/lib/agent-configs";
+import type {
+ AgentConfigAction,
+ AgentConfigInventory,
+ AgentConfigKind,
+ AgentConfigPlanItem,
+} from "@/types";
+
+const actionStyles: Record<AgentConfigAction, {variant: BadgeVariant; label:
string}> = {
+ create: {variant: "success", label: "agentConfig:New"},
+ overwrite: {variant: "warning", label: "agentConfig:Replaced"},
+ skip: {variant: "muted", label: "agentConfig:Skipped"},
+ failed: {variant: "danger", label: "agentConfig:Failed"},
+};
+
+function ActionBadge({action}: {action: AgentConfigAction}) {
+ const style = actionStyles[action] ?? actionStyles.skip;
+ return <Badge variant={style.variant}>{i18next.t(style.label)}</Badge>;
+}
+
+/**
+ * The migration step: pick the agents to copy into, see what would change at
+ * each of them, then apply. The preview is what makes an overwrite a decision
+ * rather than a surprise, so it is loaded before the button does anything.
+ */
+export function CopyDialog({
+ open,
+ onOpenChange,
+ kind,
+ source,
+ inventories,
+ names,
+ onDone,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ kind: AgentConfigKind;
+ source: AgentConfigInventory;
+ inventories: AgentConfigInventory[];
+ names: string[];
+ onDone: () => void;
+}) {
+ const [selected, setSelected] = React.useState<string[]>([]);
+ const [overwrite, setOverwrite] = React.useState(false);
+ const [plan, setPlan] = React.useState<AgentConfigPlanItem[] | null>(null);
+ const [planning, setPlanning] = React.useState(false);
+ const [applied, setApplied] = React.useState(false);
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState("");
+
+ // Copying moves files inside one account's home directory, so an agent
+ // configured under another home is not a target this page can offer.
+ const candidates = inventories.filter(
+ inventory => inventory.home === source.home && inventory.agentId !==
source.agentId,
+ );
+
+ React.useEffect(() => {
+ if (open) {
+ setSelected([]);
+ setOverwrite(false);
+ setPlan(null);
+ setApplied(false);
+ setError("");
+ }
+ }, [open, kind, source.agentId]);
+
+ React.useEffect(() => {
+ if (!open || applied || selected.length === 0) {
+ setPlan(null);
+ return;
+ }
+
+ let current = true;
+ setPlanning(true);
+ setError("");
+ AgentConfigBackend.planAgentConfigCopy({
+ owner: source.owner,
+ from: source.agentId,
+ to: selected,
+ kind: kind,
+ names: names,
+ overwrite: overwrite,
+ })
+ .then(res => {
+ if (!current) {
+ return;
+ }
+ if (res.status === "ok") {
+ setPlan(res.data ?? []);
+ } else {
+ setError(res.msg || i18next.t("agentConfig:Failed to plan this
copy"));
+ }
+ })
+ .catch(err => current && setError(err.message || String(err)))
+ .then(() => current && setPlanning(false));
+
+ return () => {
+ current = false;
+ };
+ }, [open, applied, selected, overwrite, kind, names, source.owner,
source.agentId]);
+
+ const toggleTarget = (agentId: string) => {
+ setSelected(previous =>
+ previous.includes(agentId) ? previous.filter(item => item !== agentId) :
[...previous, agentId],
+ );
+ };
+
+ const changes = (plan ?? []).filter(item => item.action === "create" ||
item.action === "overwrite");
+
+ const apply = () => {
+ setBusy(true);
+ setError("");
+ AgentConfigBackend.copyAgentConfig({
+ owner: source.owner,
+ from: source.agentId,
+ to: selected,
+ kind: kind,
+ names: names,
+ overwrite: overwrite,
+ })
+ .then(res => {
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("agentConfig:Failed to copy"));
+ return;
+ }
+
+ const result = res.data ?? [];
+ const written = result.filter(item => item.action === "create" ||
item.action === "overwrite").length;
+ const failed = result.filter(item => item.action === "failed").length;
+ setPlan(result);
+ setApplied(true);
+ Setting.showMessage(
+ failed > 0 ? "error" : "success",
+ failed > 0
+ ? i18next
+ .t("agentConfig:Copied {done}, {failed} failed")
+ .replace("{done}", String(written))
+ .replace("{failed}", String(failed))
+ : counted(written, "agentConfig:Copied 1 item",
"agentConfig:Copied {done} items", "{done}"),
+ );
+ onDone();
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setBusy(false));
+ };
+
+ const byTarget = candidates
+ .filter(inventory => selected.includes(inventory.agentId))
+ .map(inventory => ({
+ inventory: inventory,
+ items: (plan ?? []).filter(item => item.agentId === inventory.agentId),
+ }));
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>
+ {kind === "skill"
+ ? counted(names.length, "agentConfig:Copy 1 skill",
"agentConfig:Copy {count} skills")
+ : counted(names.length, "agentConfig:Copy 1 MCP server",
"agentConfig:Copy {count} MCP servers")}
+ </DialogTitle>
+ <DialogDescription className="flex flex-wrap items-center gap-1.5">
+ {i18next.t("agentConfig:From")}
+ <Badge variant="muted">{source.name}</Badge>
+ <ArrowRight className="size-3.5" />
+ {i18next.t("agentConfig:Pick the agents to copy into")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex flex-col gap-4 overflow-y-auto">
+ {candidates.length === 0 ? (
+ <MessageAlert
+ variant="info"
+ description={i18next.t("agentConfig:No other agent on this
machine can receive these")}
+ />
+ ) : null}
+
+ <div className="flex flex-wrap gap-2">
+ {candidates.map(inventory => {
+ const blocked = blockedReason(inventory, kind);
+ const active = selected.includes(inventory.agentId);
+ const button = (
+ <button
+ key={inventoryKey(inventory)}
+ type="button"
+ disabled={Boolean(blocked) || applied}
+ onClick={() => toggleTarget(inventory.agentId)}
+ className={cn(
+ "flex items-center gap-2 rounded-md border px-3 py-2
text-sm transition-colors",
+ active ? "border-primary bg-primary/10" :
"hover:bg-accent",
+ (blocked || applied) && "cursor-not-allowed opacity-50",
+ )}
+ >
+ <AgentIcon agent={inventory.name} size={16} />
+ <span>{inventory.name}</span>
+ </button>
+ );
+ return blocked ? (
+ <SimpleTooltip key={inventoryKey(inventory)} title={blocked}>
+ <span className="inline-flex">{button}</span>
+ </SimpleTooltip>
+ ) : (
+ button
+ );
+ })}
+ </div>
+
+ {!applied && candidates.length > 0 ? (
+ <label className="flex items-center gap-2 text-sm">
+ <Switch checked={overwrite} onCheckedChange={setOverwrite} />
+ <span>{i18next.t("agentConfig:Replace items that already
exist")}</span>
+ </label>
+ ) : null}
+
+ {error ? <MessageAlert description={error} /> : null}
+ {planning ? <Loading /> : null}
+
+ {byTarget.map(({inventory, items}) => (
+ <div key={inventoryKey(inventory)} className="rounded-md border">
+ <div className="bg-muted/50 flex items-center justify-between
gap-2 border-b px-3 py-2 text-sm font-medium">
+ <span>{inventory.name}</span>
+ <span className="text-muted-foreground text-xs font-normal">
+ {items.filter(item => item.action !== "skip" && item.action
!== "failed").length}
+ {" / "}
+ {items.length}
+ </span>
+ </div>
+ <ul className="divide-y">
+ {items.map(item => (
+ <li key={item.name} className="flex items-center
justify-between gap-3 px-3 py-1.5 text-sm">
+ <span className="truncate font-mono
text-xs">{item.name}</span>
+ <span className="flex shrink-0 items-center gap-2">
+ {item.reason ? (
+ <span className="text-muted-foreground
text-xs">{item.reason}</span>
+ ) : null}
+ <ActionBadge action={item.action} />
+ </span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ {applied ? i18next.t("general:Close") :
i18next.t("general:Cancel")}
+ </Button>
+ {applied ? null : (
+ <Button onClick={apply} disabled={busy || planning ||
changes.length === 0}>
+ {counted(changes.length, "agentConfig:Copy 1 item",
"agentConfig:Copy {count} items")}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/web/src/components/agent-config/detail-dialog.tsx
b/web/src/components/agent-config/detail-dialog.tsx
new file mode 100644
index 0000000..1ca8423
--- /dev/null
+++ b/web/src/components/agent-config/detail-dialog.tsx
@@ -0,0 +1,128 @@
+// 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 i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import {Loading} from "@/components/shared/loading";
+import {CodeText} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {endpointOf} from "@/lib/agent-configs";
+import type {AgentConfigDetail, AgentConfigItem} from "@/types";
+
+/** The item whose definition is on screen, and the agent it was read from. */
+export interface DetailTarget {
+ item: AgentConfigItem;
+ agentName: string;
+}
+
+/**
+ * Shows one skill's SKILL.md or one MCP server's entry, exactly as the agent
+ * has it on disk. Credentials are masked by the server before they get here.
+ */
+export function DetailDialog({
+ target,
+ onOpenChange,
+}: {
+ target: DetailTarget | null;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [detail, setDetail] = React.useState<AgentConfigDetail | null>(null);
+ const [error, setError] = React.useState("");
+ const [loading, setLoading] = React.useState(false);
+
+ const item = target?.item;
+
+ React.useEffect(() => {
+ if (!item) {
+ return;
+ }
+
+ let current = true;
+ setDetail(null);
+ setError("");
+ setLoading(true);
+ AgentConfigBackend.getAgentConfigItem(item.agentId, item.owner, item.kind,
item.name)
+ .then(res => {
+ if (!current) {
+ return;
+ }
+ if (res.status === "ok") {
+ setDetail(res.data ?? null);
+ } else {
+ setError(res.msg || i18next.t("agentConfig:Failed to read this
item"));
+ }
+ })
+ .catch(err => current && setError(err.message || String(err)))
+ .then(() => current && setLoading(false));
+
+ return () => {
+ current = false;
+ };
+ }, [item]);
+
+ return (
+ <Dialog open={Boolean(target)} onOpenChange={onOpenChange}>
+ <DialogContent className="max-h-[85vh] gap-3 sm:max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span className="truncate">{item?.name}</span>
+ <Badge variant="muted">{target?.agentName}</Badge>
+ </DialogTitle>
+ <DialogDescription>
+ {item?.description || detail?.item.description || (item ?
endpointOf(item) : "")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex flex-col gap-3 overflow-y-auto">
+ <div className="flex flex-wrap items-center gap-2 text-xs">
+ <span
className="text-muted-foreground">{i18next.t("general:Path")}</span>
+ <CodeText copyable>{item?.path}</CodeText>
+ </div>
+
+ {detail?.files?.length ? (
+ <div className="flex flex-wrap items-center gap-1.5 text-xs">
+ <span
className="text-muted-foreground">{i18next.t("agentConfig:Bundled
files")}</span>
+ {detail.files.map(file => (
+ <Badge key={file} variant="outline" className="font-mono
font-normal">
+ {file}
+ </Badge>
+ ))}
+ </div>
+ ) : null}
+
+ {error ? <MessageAlert description={error} /> : null}
+ {loading ? <Loading /> : null}
+ {detail ? (
+ <pre className="bg-muted max-h-[50vh] overflow-auto rounded-md p-3
text-xs whitespace-pre-wrap">
+ {detail.content}
+ </pre>
+ ) : null}
+ {detail && item?.kind === "mcp" ? (
+ <p className="text-muted-foreground
text-xs">{i18next.t("agentConfig:Credentials are masked here")}</p>
+ ) : null}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
new file mode 100644
index 0000000..2ae59b9
--- /dev/null
+++ b/web/src/lib/agent-configs.ts
@@ -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.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import type {AgentConfigInventory, AgentConfigItem, AgentConfigKind} from
"@/types";
+
+/** Skills and MCP servers belong to an account, so an id alone is not unique.
*/
+export function inventoryKey(inventory: Pick<AgentConfigInventory, "agentId" |
"owner">) {
+ return `${inventory.owner}:${inventory.agentId}`;
+}
+
+export function itemsOf(inventory: AgentConfigInventory, kind:
AgentConfigKind) {
+ return kind === "skill" ? inventory.skills : inventory.mcpServers;
+}
+
+export function supports(inventory: AgentConfigInventory, kind:
AgentConfigKind) {
+ return kind === "skill" ? inventory.skillsSupported : inventory.mcpSupported;
+}
+
+/** Where the items of one kind live, shown under the agent it belongs to. */
+export function locationOf(inventory: AgentConfigInventory, kind:
AgentConfigKind) {
+ return (kind === "skill" ? inventory.skillsDir : inventory.mcpFile) ?? "";
+}
+
+/**
+ * Why an agent cannot be a copy target, in the reader's words. Empty when it
+ * can be one.
+ */
+export function blockedReason(inventory: AgentConfigInventory, kind:
AgentConfigKind) {
+ if (!supports(inventory, kind)) {
+ return i18next.t("agentConfig:Gateway does not know where this agent keeps
these");
+ }
+ if (kind === "mcp" && !inventory.mcpWritable) {
+ return inventory.mcpReadOnly || i18next.t("agentConfig:This file is
read-only for Gateway");
+ }
+ return "";
+}
+
+/** Which agents already have an item of each name, keyed by name. */
+export function presenceOf(inventories: AgentConfigInventory[], kind:
AgentConfigKind) {
+ const presence = new Map<string, Set<string>>();
+ inventories.forEach(inventory => {
+ itemsOf(inventory, kind).forEach(item => {
+ const holders = presence.get(item.name) ?? new Set<string>();
+ holders.add(inventoryKey(inventory));
+ presence.set(item.name, holders);
+ });
+ });
+ return presence;
+}
+
+/**
+ * Picks the singular or the plural wording. English needs a different noun for
+ * one item; the other locales map both keys to the same string.
+ */
+export function counted(count: number, oneKey: string, manyKey: string, token
= "{count}") {
+ return count === 1 ? i18next.t(oneKey) : i18next.t(manyKey).replace(token,
String(count));
+}
+
+export function formatBytes(bytes: number | undefined) {
+ if (!bytes) {
+ return "";
+ }
+ const units = ["B", "KB", "MB", "GB"];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)}
${units[unit]}`;
+}
+
+/** The one-line summary of an MCP server: what it runs, or what it connects
to. */
+export function endpointOf(item: AgentConfigItem) {
+ return item.command || item.url || "";
+}
+
+/**
+ * useAgentConfigs owns the host scan behind the Skills & MCP page. Deleting
and
+ * copying both change files this page has already read, so every one of them
+ * ends by calling refresh().
+ */
+export function useAgentConfigs() {
+ const [inventories, setInventories] =
React.useState<AgentConfigInventory[]>([]);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState("");
+ const [scanned, setScanned] = React.useState(false);
+
+ const refresh = React.useCallback((forceRefresh = false) => {
+ setLoading(true);
+ setError("");
+ AgentConfigBackend.getAgentConfigs(forceRefresh)
+ .then(res => {
+ if (res.status === "ok") {
+ setInventories(res.data ?? []);
+ } else {
+ setError(res.msg || i18next.t("agentConfig:Failed to read agent
configuration"));
+ }
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => {
+ setLoading(false);
+ setScanned(true);
+ });
+ }, []);
+
+ React.useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return {inventories, loading, error, scanned, refresh};
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 2346871..076651b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -92,6 +92,62 @@
"View all": "View all",
"{count} sessions": "{count} sessions"
},
+ "agentConfig": {
+ "1 file": "1 file",
+ "Bundled files": "Bundled files",
+ "Contents": "Contents",
+ "Copied 1 item": "Copied 1 item",
+ "Copied {done} items": "Copied {done} items",
+ "Copied {done}, {failed} failed": "Copied {done} items, {failed} failed",
+ "Copy 1 MCP server": "Copy 1 MCP server",
+ "Copy 1 item": "Copy 1 item",
+ "Copy 1 skill": "Copy 1 skill",
+ "Copy to other agents": "Copy to other agents",
+ "Copy {count} MCP servers": "Copy {count} MCP servers",
+ "Copy {count} items": "Copy {count} items",
+ "Copy {count} skills": "Copy {count} skills",
+ "Copy {count} to other agents": "Copy {count} to other agents",
+ "Credentials are masked here": "Credentials are masked in this view. A
copy carries the real values.",
+ "Delete description": "This removes it from the agent's own configuration,
here:",
+ "Delete this MCP server?": "Delete this MCP server?",
+ "Delete this skill?": "Delete this skill?",
+ "Deleted": "Deleted",
+ "Endpoint": "Endpoint",
+ "Failed": "Failed",
+ "Failed to copy": "Failed to copy",
+ "Failed to delete": "Failed to delete",
+ "Failed to plan this copy": "Failed to work out what this copy would do",
+ "Failed to read agent configuration": "Failed to read the agents'
configuration",
+ "Failed to read this item": "Failed to read this item",
+ "From": "From",
+ "Gateway does not know where this agent keeps these": "Gateway does not
know where this agent keeps these",
+ "MCP": "MCP",
+ "MCP servers": "MCP servers",
+ "Managed by Gateway": "Gateway",
+ "Managed by Gateway detail": "Registered by Gateway's agent monitoring.
Turn monitoring off on the Agents page to remove it.",
+ "New": "New",
+ "No MCP servers yet": "This agent has no MCP servers yet",
+ "No agents found": "No agents found on this machine",
+ "No agents found detail": "Gateway reads the configuration of the agents
installed where it runs. Inside a container it sees the container's own
filesystem, not the host's.",
+ "No other agent on this machine can receive these": "No other agent on
this machine can receive these.",
+ "No skills yet": "This agent has no skills yet",
+ "Not installed": "config only",
+ "Not installed detail": "Gateway found this configuration on disk but did
not detect an installation of the agent.",
+ "Page description": "The skills and MCP servers each agent on this machine
keeps in its own configuration files. Compare them side by side, and copy them
from one agent to another.",
+ "Pick the agents to copy into": "pick the agents to copy into",
+ "Read from": "Read from",
+ "Replace items that already exist": "Replace items that already exist",
+ "Replaced": "Replaced",
+ "Search by name": "Search by name",
+ "Select all": "Select all",
+ "Shared with": "The same files are read by {agents}.",
+ "Skills": "Skills",
+ "Skills & MCP": "Skills & MCP",
+ "Skipped": "Skipped",
+ "This file is read-only for Gateway": "Gateway does not write this agent's
configuration file",
+ "View": "View",
+ "{files} files": "{files} files"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
@@ -158,6 +214,7 @@
"Certs": "Certs",
"Client IP": "Client IP",
"Client ip": "Client ip",
+ "Close": "Close",
"Copied": "Copied",
"Copied to clipboard": "Copied to clipboard",
"Copy": "Copy",
@@ -184,8 +241,8 @@
"Home": "Home",
"Host": "Host",
"ID": "ID",
- "Input IP Addresses": "Input IP Addresses",
"IP Address": "IP Address",
+ "Input IP Addresses": "Input IP Addresses",
"Light": "Light",
"Logged in successfully": "Logged in successfully",
"Login Error": "Login Error",
@@ -193,8 +250,8 @@
"Name": "Name",
"Name cannot be empty": "Name cannot be empty",
"New Record": "New Record",
- "No data": "No data",
"No Upgrade": "No Upgrade",
+ "No data": "No data",
"Nodes": "Nodes",
"OK": "OK",
"Other": "Other",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 57ea6a1..1a8d651 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -92,6 +92,62 @@
"View all": "查看全部",
"{count} sessions": "{count} 个会话"
},
+ "agentConfig": {
+ "1 file": "1 个文件",
+ "Bundled files": "附带文件",
+ "Contents": "内容",
+ "Copied 1 item": "已复制 1 项",
+ "Copied {done} items": "已复制 {done} 项",
+ "Copied {done}, {failed} failed": "已复制 {done} 项,{failed} 项失败",
+ "Copy 1 MCP server": "复制 1 个 MCP 服务器",
+ "Copy 1 item": "复制 1 项",
+ "Copy 1 skill": "复制 1 个技能",
+ "Copy to other agents": "复制到其他 Agent",
+ "Copy {count} MCP servers": "复制 {count} 个 MCP 服务器",
+ "Copy {count} items": "复制 {count} 项",
+ "Copy {count} skills": "复制 {count} 个技能",
+ "Copy {count} to other agents": "把 {count} 项复制到其他 Agent",
+ "Credentials are masked here": "此视图中的凭据已被遮蔽。复制时携带的是真实值。",
+ "Delete description": "这会把它从该 Agent 自己的配置中删除,位置:",
+ "Delete this MCP server?": "删除这个 MCP 服务器?",
+ "Delete this skill?": "删除这个技能?",
+ "Deleted": "已删除",
+ "Endpoint": "端点",
+ "Failed": "失败",
+ "Failed to copy": "复制失败",
+ "Failed to delete": "删除失败",
+ "Failed to plan this copy": "无法预览这次复制的结果",
+ "Failed to read agent configuration": "读取 Agent 配置失败",
+ "Failed to read this item": "读取该项失败",
+ "From": "从",
+ "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent
把这些配置放在哪里",
+ "MCP": "MCP",
+ "MCP servers": "MCP 服务器",
+ "Managed by Gateway": "Gateway",
+ "Managed by Gateway detail": "由 Gateway 的 Agent 监控注册。要移除它,请在 Agents
页面关闭监控。",
+ "New": "新增",
+ "No MCP servers yet": "该 Agent 还没有 MCP 服务器",
+ "No agents found": "本机没有找到 Agent",
+ "No agents found detail": "Gateway 只读取它自己所在机器上安装的 Agent
的配置。在容器里运行时,它看到的是容器自己的文件系统,而不是宿主机的。",
+ "No other agent on this machine can receive these": "本机没有其他 Agent
可以接收这些内容。",
+ "No skills yet": "该 Agent 还没有技能",
+ "Not installed": "仅配置",
+ "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
+ "Page description": "本机每个 Agent 自己配置文件里的技能和 MCP 服务器。可以并排比较,并从一个 Agent
复制到另一个。",
+ "Pick the agents to copy into": "选择要复制到的 Agent",
+ "Read from": "读取自",
+ "Replace items that already exist": "覆盖已存在的同名项",
+ "Replaced": "覆盖",
+ "Search by name": "按名称搜索",
+ "Select all": "全选",
+ "Shared with": "{agents} 读取的是同一批文件。",
+ "Skills": "技能",
+ "Skills & MCP": "技能与 MCP",
+ "Skipped": "跳过",
+ "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
+ "View": "查看",
+ "{files} files": "{files} 个文件"
+ },
"cert": {
"Access key": "访问密钥",
"Access secret": "访问密钥 Secret",
@@ -158,6 +214,7 @@
"Certs": "证书",
"Client IP": "客户端 IP",
"Client ip": "客户端IP",
+ "Close": "关闭",
"Copied": "已复制",
"Copied to clipboard": "已复制到剪贴板",
"Copy": "复制",
@@ -184,8 +241,8 @@
"Home": "首页",
"Host": "主机",
"ID": "ID",
- "Input IP Addresses": "请输入 IP 地址",
"IP Address": "IP地址",
+ "Input IP Addresses": "请输入 IP 地址",
"Light": "浅色",
"Logged in successfully": "登录成功",
"Login Error": "登录失败",
@@ -193,8 +250,8 @@
"Name": "名称",
"Name cannot be empty": "名称不能为空",
"New Record": "新建记录",
- "No data": "暂无数据",
"No Upgrade": "不升级",
+ "No data": "暂无数据",
"Nodes": "节点",
"OK": "确定",
"Other": "其他",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 77c23e5..cf7e07a 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -13,6 +13,7 @@
// limitations under the License.
import {
+ Blocks,
Bot,
ChartColumn,
FileSearch,
@@ -59,6 +60,13 @@ export interface NavGroup {
export const navGroups: NavGroup[] = [
{key: "/", label: "general:Dashboard", icon: LayoutDashboard, path: "/"},
{key: "/agents", label: "agent:Agents", icon: Bot, path: "/agents",
adminOnly: true},
+ {
+ key: "/agent-configs",
+ label: "agentConfig:Skills & MCP",
+ icon: Blocks,
+ path: "/agent-configs",
+ adminOnly: true,
+ },
{
key: "/agent-sessions",
label: "agent:Agent Sessions",
diff --git a/web/src/pages/AgentConfigsPage.tsx
b/web/src/pages/AgentConfigsPage.tsx
new file mode 100644
index 0000000..9b656f1
--- /dev/null
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -0,0 +1,399 @@
+// 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 {Check, Eye, Minus, Package, RefreshCw, Send, Trash2} from
"lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {CopyDialog} from "@/components/agent-config/copy-dialog";
+import {DetailDialog, type DetailTarget} from
"@/components/agent-config/detail-dialog";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {EmptyState} from "@/components/shared/empty-state";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {
+ blockedReason,
+ counted,
+ endpointOf,
+ formatBytes,
+ inventoryKey,
+ itemsOf,
+ locationOf,
+ presenceOf,
+ supports,
+ useAgentConfigs,
+} from "@/lib/agent-configs";
+import type {Account, AgentConfigInventory, AgentConfigItem, AgentConfigKind}
from "@/types";
+
+/** The agent picker doubles as the overview: every row carries its own
counts. */
+function SourcePicker({
+ inventories,
+ selectedKey,
+ onSelect,
+}: {
+ inventories: AgentConfigInventory[];
+ selectedKey: string;
+ onSelect: (inventory: AgentConfigInventory) => void;
+}) {
+ return (
+ <div className="flex flex-wrap gap-2">
+ {inventories.map(inventory => {
+ const active = inventoryKey(inventory) === selectedKey;
+ return (
+ <button
+ key={inventoryKey(inventory)}
+ type="button"
+ onClick={() => onSelect(inventory)}
+ className={cn(
+ "flex items-center gap-2.5 rounded-md border px-3 py-2 text-left
transition-colors",
+ active ? "border-primary bg-primary/10" : "hover:bg-accent",
+ )}
+ >
+ <AgentIcon agent={inventory.name} size={20} fallback={<Package
className="size-5" />} />
+ <span className="flex flex-col">
+ <span className="flex items-center gap-1.5 text-sm leading-tight
font-medium">
+ {inventory.name}
+ {inventory.installed ? null : (
+ <SimpleTooltip title={i18next.t("agentConfig:Not installed
detail")}>
+ <Badge variant="muted">{i18next.t("agentConfig:Not
installed")}</Badge>
+ </SimpleTooltip>
+ )}
+ </span>
+ <span className="text-muted-foreground text-xs leading-tight">
+ {i18next.t("agentConfig:Skills")} {inventory.skills.length}
+ {" · "}
+ {i18next.t("agentConfig:MCP")} {inventory.mcpServers.length}
+ </span>
+ </span>
+ </button>
+ );
+ })}
+ </div>
+ );
+}
+
+export default function AgentConfigsPage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const {inventories, loading, error, scanned, refresh} = useAgentConfigs();
+
+ const [kind, setKind] = React.useState<AgentConfigKind>("skill");
+ const [sourceKey, setSourceKey] = React.useState("");
+ const [selected, setSelected] = React.useState<string[]>([]);
+ const [detail, setDetail] = React.useState<DetailTarget | null>(null);
+ const [copyOpen, setCopyOpen] = React.useState(false);
+ const [deleting, setDeleting] = React.useState("");
+
+ // The scan replaces every inventory, so the chosen source is re-resolved by
+ // key rather than held as an object that would go stale on every refresh.
+ const source = inventories.find(inventory => inventoryKey(inventory) ===
sourceKey) ?? inventories[0];
+
+ // The first agent with something in it is a better landing page than the
+ // first one alphabetically, but it is chosen once and then committed: left
+ // derived, a copy that fills an empty agent would move the page off the
+ // source the reader is working from.
+ React.useEffect(() => {
+ if (sourceKey === "" && inventories.length > 0) {
+ const landing = inventories.find(inventory => itemsOf(inventory,
kind).length > 0) ?? inventories[0];
+ setSourceKey(inventoryKey(landing));
+ }
+ }, [inventories, kind, sourceKey]);
+
+ React.useEffect(() => {
+ setSelected([]);
+ }, [sourceKey, kind]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const peers = source ? inventories.filter(inventory =>
inventoryKey(inventory) !== inventoryKey(source)) : [];
+ const items = source ? itemsOf(source, kind) : [];
+ const presence = presenceOf(inventories, kind);
+ const selectable = items.filter(item => !item.managed);
+ const allSelected = selectable.length > 0 && selected.length ===
selectable.length;
+
+ const toggleAll = () => {
+ setSelected(allSelected ? [] : selectable.map(item => item.name));
+ };
+
+ const toggleOne = (name: string) => {
+ setSelected(previous =>
+ previous.includes(name) ? previous.filter(item => item !== name) :
[...previous, name],
+ );
+ };
+
+ const deleteItem = (item: AgentConfigItem) => {
+ setDeleting(item.name);
+ return AgentConfigBackend.deleteAgentConfigItem(item.agentId, item.owner,
item.kind, item.name)
+ .then(res => {
+ if (res.status === "ok") {
+ Setting.showMessage("success", `${i18next.t("agentConfig:Deleted")}:
${item.name}`);
+ refresh();
+ } else {
+ Setting.showMessage("error", res.msg ||
i18next.t("agentConfig:Failed to delete"));
+ }
+ })
+ .catch(err => Setting.showMessage("error", err.message || String(err)))
+ .then(() => setDeleting(""));
+ };
+
+ const columns: Column<AgentConfigItem>[] = [
+ {
+ key: "selected",
+ width: "40px",
+ title: (
+ <input
+ type="checkbox"
+ className="accent-primary size-4 align-middle"
+ aria-label={i18next.t("agentConfig:Select all")}
+ checked={allSelected}
+ disabled={selectable.length === 0}
+ onChange={toggleAll}
+ />
+ ),
+ render: (_value, record) =>
+ record.managed ? null : (
+ <input
+ type="checkbox"
+ className="accent-primary size-4 align-middle"
+ aria-label={record.name}
+ checked={selected.includes(record.name)}
+ onChange={() => toggleOne(record.name)}
+ />
+ ),
+ },
+ {
+ key: "name",
+ dataIndex: "name",
+ title: i18next.t("general:Name"),
+ sorter: (a, b) => a.name.localeCompare(b.name),
+ render: (_value, record) => (
+ <div className="min-w-0">
+ <div className="flex items-center gap-1.5">
+ <span className="truncate font-medium">{record.name}</span>
+ {record.managed ? (
+ <SimpleTooltip title={i18next.t("agentConfig:Managed by Gateway
detail")}>
+ <Badge variant="info">{i18next.t("agentConfig:Managed by
Gateway")}</Badge>
+ </SimpleTooltip>
+ ) : null}
+ </div>
+ {record.description ? (
+ <p className="text-muted-foreground line-clamp-2
text-xs">{record.description}</p>
+ ) : null}
+ </div>
+ ),
+ },
+ {
+ key: "summary",
+ width: "220px",
+ title: kind === "skill" ? i18next.t("agentConfig:Contents") :
i18next.t("agentConfig:Endpoint"),
+ render: (_value, record) =>
+ kind === "skill" ? (
+ <span className="text-muted-foreground text-xs">
+ {counted(record.files ?? 0, "agentConfig:1 file",
"agentConfig:{files} files", "{files}")}
+ {record.bytes ? ` · ${formatBytes(record.bytes)}` : ""}
+ </span>
+ ) : (
+ <div className="flex min-w-0 items-center gap-1.5">
+ {record.transport ? <Badge
variant="muted">{record.transport}</Badge> : null}
+ <span className="text-muted-foreground truncate font-mono text-xs"
title={endpointOf(record)}>
+ {endpointOf(record)}
+ </span>
+ </div>
+ ),
+ },
+ ...peers.map<Column<AgentConfigItem>>(peer => ({
+ key: `peer-${inventoryKey(peer)}`,
+ width: "110px",
+ align: "center",
+ title: (
+ <SimpleTooltip title={peer.name}>
+ <span className="inline-flex items-center gap-1.5">
+ <AgentIcon agent={peer.name} size={14} />
+ <span className="truncate">{peer.name}</span>
+ </span>
+ </SimpleTooltip>
+ ),
+ render: (_value, record) =>
+ !supports(peer, kind) ? (
+ <span className="text-muted-foreground/50 text-xs">—</span>
+ ) : presence.get(record.name)?.has(inventoryKey(peer)) ? (
+ <Check className="text-success mx-auto size-4" />
+ ) : (
+ <Minus className="text-muted-foreground/40 mx-auto size-4" />
+ ),
+ })),
+ {
+ key: "actions",
+ width: "110px",
+ align: "right",
+ title: i18next.t("general:Action"),
+ render: (_value, record) => (
+ <div className="flex justify-end gap-1">
+ <SimpleTooltip title={i18next.t("agentConfig:View")}>
+ <Button
+ variant="ghost"
+ size="icon-sm"
+ aria-label={i18next.t("agentConfig:View")}
+ onClick={() => setDetail({item: record, agentName: source?.name
?? record.agentId})}
+ >
+ <Eye className="size-4" />
+ </Button>
+ </SimpleTooltip>
+ <ConfirmDialog
+ title={i18next.t(
+ kind === "skill" ? "agentConfig:Delete this skill?" :
"agentConfig:Delete this MCP server?",
+ )}
+ description={
+ <span className="flex flex-col gap-1">
+ <span>{i18next.t("agentConfig:Delete description")}</span>
+ <code className="text-foreground font-mono text-xs
break-all">{record.path}</code>
+ </span>
+ }
+ onConfirm={() => deleteItem(record)}
+ disabled={record.managed || deleting === record.name}
+ >
+ <Button
+ variant="ghost"
+ size="icon-sm"
+ className="text-destructive"
+ aria-label={i18next.t("general:Delete")}
+ disabled={record.managed || deleting === record.name}
+ >
+ <Trash2 className="size-4" />
+ </Button>
+ </ConfirmDialog>
+ </div>
+ ),
+ },
+ ];
+
+ const location = source ? locationOf(source, kind) : "";
+ const blocked = source ? blockedReason(source, kind) : "";
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("agentConfig:Skills & MCP")}
+ description={i18next.t("agentConfig:Page description")}
+ actions={
+ <Button variant="outline" onClick={() => refresh(true)}
disabled={loading}>
+ <RefreshCw className={cn("size-4", loading && "animate-spin")} />
+ {i18next.t("general:Refresh")}
+ </Button>
+ }
+ />
+
+ {error ? <MessageAlert description={error} /> : null}
+
+ {scanned && inventories.length === 0 ? (
+ <EmptyState
+ icon={Package}
+ title={i18next.t("agentConfig:No agents found")}
+ description={i18next.t("agentConfig:No agents found detail")}
+ />
+ ) : null}
+
+ {source ? (
+ <>
+ <SourcePicker
+ inventories={inventories}
+ selectedKey={inventoryKey(source)}
+ onSelect={inventory => setSourceKey(inventoryKey(inventory))}
+ />
+
+ {source.errors?.length ? <MessageAlert
description={source.errors.join("; ")} /> : null}
+
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <Tabs value={kind} onValueChange={value => setKind(value as
AgentConfigKind)}>
+ <TabsList>
+ <TabsTrigger value="skill">
+ {i18next.t("agentConfig:Skills")}
+ <Badge variant="muted">{source.skills.length}</Badge>
+ </TabsTrigger>
+ <TabsTrigger value="mcp">
+ {i18next.t("agentConfig:MCP servers")}
+ <Badge variant="muted">{source.mcpServers.length}</Badge>
+ </TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {location ? (
+ <span className="flex items-center gap-2 text-xs">
+ <span
className="text-muted-foreground">{i18next.t("agentConfig:Read from")}</span>
+ <CodeText copyable>{location}</CodeText>
+ </span>
+ ) : null}
+ </div>
+
+ {source.sharedWith?.length ? (
+ <p className="text-muted-foreground text-xs">
+ {i18next.t("agentConfig:Shared with").replace("{agents}",
source.sharedWith.join(", "))}
+ </p>
+ ) : null}
+
+ {blocked ? (
+ <MessageAlert variant="info" description={blocked} />
+ ) : (
+ <DataTable<AgentConfigItem>
+ columns={columns}
+ dataSource={items}
+ rowKey="name"
+ loading={loading && !scanned}
+ searchable
+ searchPlaceholder={i18next.t("agentConfig:Search by name")}
+ pageSize={20}
+ emptyIcon={Package}
+ emptyText={i18next.t(
+ kind === "skill" ? "agentConfig:No skills yet" :
"agentConfig:No MCP servers yet",
+ )}
+ toolbar={
+ <Button disabled={selected.length === 0} onClick={() =>
setCopyOpen(true)}>
+ <Send className="size-4" />
+ {selected.length === 0
+ ? i18next.t("agentConfig:Copy to other agents")
+ : i18next
+ .t("agentConfig:Copy {count} to other agents")
+ .replace("{count}", String(selected.length))}
+ </Button>
+ }
+ />
+ )}
+
+ <CopyDialog
+ open={copyOpen}
+ onOpenChange={setCopyOpen}
+ kind={kind}
+ source={source}
+ inventories={inventories}
+ names={selected}
+ onDone={refresh}
+ />
+ </>
+ ) : null}
+
+ <DetailDialog target={detail} onOpenChange={open => !open &&
setDetail(null)} />
+ </PageContainer>
+ );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 3a67ffc..9ec4b37 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -262,3 +262,63 @@ export interface Application {
export interface GatewayStatus {
gatewayEnabled: boolean;
}
+
+/** The two kinds of configuration the Skills & MCP page manages. */
+export type AgentConfigKind = "skill" | "mcp";
+
+export interface AgentConfigItem {
+ agentId: string;
+ owner: string;
+ kind: AgentConfigKind;
+ name: string;
+ description?: string;
+ /** The skill's own folder, or the config file the MCP server is an entry
of. */
+ path: string;
+ transport?: string;
+ command?: string;
+ url?: string;
+ files?: number;
+ bytes?: number;
+ /** Written by Gateway's own agent monitoring, and not the operator's to
move. */
+ managed?: boolean;
+}
+
+/** One installation's skills and MCP servers, as they exist in its own files.
*/
+export interface AgentConfigInventory {
+ agentId: string;
+ owner: string;
+ name: string;
+ path?: string;
+ /** The account directory the locations below were resolved under. */
+ home?: string;
+ /** False when the configuration exists but no installation was detected. */
+ installed: boolean;
+ /** Other agents reading the same files, e.g. Cursor and its CLI. */
+ sharedWith?: string[];
+ skillsDir?: string;
+ mcpFile?: string;
+ skillsSupported: boolean;
+ mcpSupported: boolean;
+ mcpWritable: boolean;
+ mcpReadOnly?: string;
+ skills: AgentConfigItem[];
+ mcpServers: AgentConfigItem[];
+ errors?: string[];
+}
+
+export interface AgentConfigDetail {
+ item: AgentConfigItem;
+ content: string;
+ files?: string[];
+}
+
+export type AgentConfigAction = "create" | "overwrite" | "skip" | "failed";
+
+/** What a copy would do, or did, to one item at one target agent. */
+export interface AgentConfigPlanItem {
+ agentId: string;
+ name: string;
+ action: AgentConfigAction;
+ reason?: string;
+ path?: string;
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]