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 0b1bd06  feat(skill): install skills from GitHub repos and archives
0b1bd06 is described below

commit 0b1bd06e47d72bfd9b013a0451dc0095e4b562b7
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 23:08:50 2026 +0800

    feat(skill): install skills from GitHub repos and archives
---
 agentconfig/agentconfig.go                         |   5 +
 agentconfig/archive.go                             | 345 ++++++++++++++++
 agentconfig/install.go                             | 385 ++++++++++++++++++
 agentconfig/link_other.go                          |  24 ++
 agentconfig/link_windows.go                        |  44 ++
 agentconfig/skill.go                               |  17 +-
 agentconfig/source.go                              | 387 ++++++++++++++++++
 controllers/agent_config_skill.go                  | 172 ++++++++
 routers/router.go                                  |   6 +
 web/src/backend/AgentConfigBackend.ts              |  60 ++-
 web/src/backend/request.ts                         |  21 +
 .../agent-config/install-skill-dialog.tsx          | 447 +++++++++++++++++++++
 .../components/agent-config/skill-source-panel.tsx | 329 +++++++++++++++
 web/src/locales/en/data.json                       |  49 +++
 web/src/locales/zh/data.json                       |  49 +++
 web/src/pages/AgentConfigsPage.tsx                 |  30 ++
 web/src/types.ts                                   |  52 +++
 17 files changed, 2417 insertions(+), 5 deletions(-)

diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
index 3072451..f9aafa7 100644
--- a/agentconfig/agentconfig.go
+++ b/agentconfig/agentconfig.go
@@ -68,6 +68,11 @@ type Item struct {
        Files int   `json:"files,omitempty"`
        Bytes int64 `json:"bytes,omitempty"`
 
+       // Link is what a skill installed as a symbolic link points at, empty 
for a
+       // skill that is a folder of its own. A linked skill follows its source
+       // wherever that goes, which is the difference the listing has to show.
+       Link string `json:"link,omitempty"`
+
        // Scope tells a skill the operator wrote from one a plugin ships or 
one a
        // project checkout carries, and Origin names that plugin or project.
        Scope  string `json:"scope,omitempty"`
diff --git a/agentconfig/archive.go b/agentconfig/archive.go
new file mode 100644
index 0000000..6a41c56
--- /dev/null
+++ b/agentconfig/archive.go
@@ -0,0 +1,345 @@
+// 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 (
+       "archive/tar"
+       "archive/zip"
+       "bytes"
+       "compress/gzip"
+       "fmt"
+       "io"
+       "io/fs"
+       "net/http"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/proxy"
+)
+
+const (
+       // A skills repository is text and a few images, so these bounds hold 
what
+       // one plausibly is and stop a mistyped URL from filling the disk.
+       maxArchiveBytes     = 128 << 20
+       maxArchiveFileBytes = 16 << 20
+       maxArchiveFiles     = 20000
+       // Downloading a repository takes as long as the link does, and the 
caller
+       // is a browser waiting on it.
+       archiveTimeout = 120 * time.Second
+)
+
+// githubRefs are the branches tried for a repository given without one. HEAD 
is
+// whatever the repository calls its default branch, so it answers first and 
the
+// rest are there for a host that does not serve it.
+var githubRefs = []string{"HEAD", "main", "master"}
+
+// fetchSource fills the store with one source's content, replacing whatever 
was
+// there. A local source is read where it is and has nothing to fetch.
+func fetchSource(home string, source *SkillSource) error {
+       if source.Kind == SourceLocal {
+               return nil
+       }
+       if source.Kind == SourceUpload {
+               return fmt.Errorf("%s was uploaded, so there is nowhere to 
fetch it from again", source.Name)
+       }
+
+       urls, err := downloadUrls(source)
+       if err != nil {
+               return err
+       }
+
+       var data []byte
+       var last error
+       for _, address := range urls {
+               data, last = downloadArchive(address)
+               if last == nil {
+                       break
+               }
+       }
+       if last != nil {
+               return last
+       }
+       return storeArchive(home, source.Id, data)
+}
+
+// storeArchive unpacks one archive into a source's store folder, in one step:
+// the new content is staged beside the old and swapped in, so a download that
+// fails half way leaves the store as it was.
+func storeArchive(home string, id string, data []byte) error {
+       store := sourceStore(home, id)
+       if err := os.MkdirAll(store, 0o755); err != nil {
+               return err
+       }
+
+       staged := filepath.Join(store, storeTree+".gateway-fetch")
+       if err := os.RemoveAll(staged); err != nil {
+               return err
+       }
+       if err := extractArchive(data, staged); err != nil {
+               os.RemoveAll(staged)
+               return err
+       }
+
+       tree := filepath.Join(store, storeTree)
+       if err := os.RemoveAll(tree); err != nil {
+               os.RemoveAll(staged)
+               return err
+       }
+       if err := os.Rename(staged, tree); err != nil {
+               os.RemoveAll(staged)
+               return err
+       }
+       return nil
+}
+
+// downloadUrls is where a source's archive is fetched from, in the order they
+// are tried. A repository without a ref is tried at each of the usual ones.
+func downloadUrls(source *SkillSource) ([]string, error) {
+       if source.Kind == SourceArchive {
+               return []string{source.Url}, nil
+       }
+
+       repo, _, _, err := parseRepoUrl(source.Url)
+       if err != nil {
+               return nil, err
+       }
+       refs := githubRefs
+       if source.Ref != "" {
+               refs = []string{source.Ref}
+       }
+
+       urls := []string{}
+       for _, ref := range refs {
+               urls = append(urls, 
fmt.Sprintf("https://codeload.github.com/%s/tar.gz/%s";, repo, ref))
+       }
+       return urls, nil
+}
+
+func downloadArchive(address string) ([]byte, error) {
+       client := &http.Client{Timeout: archiveTimeout, Transport: 
proxy.Transport()}
+       resp, err := client.Get(address)
+       if err != nil {
+               return nil, fmt.Errorf("%s could not be reached: %s", address, 
err.Error())
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               return nil, fmt.Errorf("%s answered %s", address, resp.Status)
+       }
+
+       // One byte past the limit is read on purpose: it tells a file that is
+       // exactly the limit from one that was cut short.
+       data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveBytes+1))
+       if err != nil {
+               return nil, err
+       }
+       if len(data) > maxArchiveBytes {
+               return nil, fmt.Errorf("%s is larger than %d MB", address, 
maxArchiveBytes>>20)
+       }
+       return data, nil
+}
+
+// extractArchive unpacks a .zip or a .tar.gz into dest, deciding which by what
+// the bytes start with rather than by the name they arrived under.
+func extractArchive(data []byte, dest string) error {
+       switch {
+       case bytes.HasPrefix(data, []byte("PK\x03\x04")):
+               return extractZip(data, dest)
+       case bytes.HasPrefix(data, []byte{0x1f, 0x8b}):
+               return extractTarGz(data, dest)
+       }
+       return fmt.Errorf("this is neither a .zip nor a .tar.gz archive")
+}
+
+func extractZip(data []byte, dest string) error {
+       reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+       if err != nil {
+               return err
+       }
+       if len(reader.File) > maxArchiveFiles {
+               return fmt.Errorf("this archive holds more than %d files", 
maxArchiveFiles)
+       }
+
+       for _, file := range reader.File {
+               target, err := safeJoin(dest, file.Name)
+               if err != nil {
+                       continue
+               }
+               if file.FileInfo().IsDir() {
+                       if err := os.MkdirAll(target, 0o755); err != nil {
+                               return err
+                       }
+                       continue
+               }
+               // A symbolic link or a device node in an archive is not 
something a
+               // skill needs, and following one would write outside dest.
+               if !file.Mode().IsRegular() {
+                       continue
+               }
+
+               opened, err := file.Open()
+               if err != nil {
+                       return err
+               }
+               err = writeExtracted(target, opened, file.Mode().Perm())
+               opened.Close()
+               if err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+func extractTarGz(data []byte, dest string) error {
+       unzipped, err := gzip.NewReader(bytes.NewReader(data))
+       if err != nil {
+               return err
+       }
+       defer unzipped.Close()
+
+       reader := tar.NewReader(unzipped)
+       count := 0
+       for {
+               header, err := reader.Next()
+               if err == io.EOF {
+                       return nil
+               }
+               if err != nil {
+                       return err
+               }
+               count++
+               if count > maxArchiveFiles {
+                       return fmt.Errorf("this archive holds more than %d 
files", maxArchiveFiles)
+               }
+
+               target, err := safeJoin(dest, header.Name)
+               if err != nil {
+                       continue
+               }
+               switch header.Typeflag {
+               case tar.TypeDir:
+                       if err := os.MkdirAll(target, 0o755); err != nil {
+                               return err
+                       }
+               case tar.TypeReg:
+                       if err := writeExtracted(target, reader, 
os.FileMode(header.Mode).Perm()); err != nil {
+                               return err
+                       }
+               }
+       }
+}
+
+func writeExtracted(target string, content io.Reader, mode os.FileMode) error {
+       if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+               return err
+       }
+       if mode == 0 {
+               mode = 0o644
+       }
+
+       file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 
mode)
+       if err != nil {
+               return err
+       }
+       written, err := io.Copy(file, io.LimitReader(content, 
maxArchiveFileBytes+1))
+       if err != nil {
+               file.Close()
+               return err
+       }
+       if err := file.Close(); err != nil {
+               return err
+       }
+       if written > maxArchiveFileBytes {
+               os.Remove(target)
+               return fmt.Errorf("%s is larger than %d MB", 
filepath.Base(target), maxArchiveFileBytes>>20)
+       }
+       return nil
+}
+
+// safeJoin resolves one archive entry's name under dest. An archive is 
somebody
+// else's file, and an entry named ../../.ssh/authorized_keys is the reason 
this
+// is not a plain filepath.Join.
+func safeJoin(dest string, name string) (string, error) {
+       clean := filepath.Clean(filepath.FromSlash(name))
+       if clean == "." || clean == string(filepath.Separator) {
+               return "", fmt.Errorf("%q names no file", name)
+       }
+       if filepath.IsAbs(clean) || filepath.VolumeName(clean) != "" {
+               return "", fmt.Errorf("%q is an absolute path", name)
+       }
+
+       target := filepath.Join(dest, clean)
+       if target != dest && !strings.HasPrefix(target, 
dest+string(filepath.Separator)) {
+               return "", fmt.Errorf("%q points outside the archive", name)
+       }
+       return target, nil
+}
+
+// archiveRoot looks through the folder an archive unpacks into. A repository
+// tarball holds everything under one folder named after the branch, and that
+// folder is the repository rather than a part of it.
+func archiveRoot(dir string) string {
+       entries, err := os.ReadDir(dir)
+       if err != nil || len(entries) != 1 || !entries[0].IsDir() {
+               return dir
+       }
+
+       inner := filepath.Join(dir, entries[0].Name())
+       // A single folder that is itself a skill is the content, not a wrapper.
+       if _, ok := manifestPath(inner); ok {
+               return dir
+       }
+       return inner
+}
+
+// findSkillFolders lists the skills inside a source's tree, by their path
+// relative to root. A skills repository groups them however it likes, so the
+// whole tree is searched rather than one level of it.
+func findSkillFolders(root string) []string {
+       found := []string{}
+       filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) 
error {
+               if err != nil || !entry.IsDir() {
+                       return nil
+               }
+               if path != root {
+                       if strings.HasPrefix(entry.Name(), ".") || 
pluginNoise[strings.ToLower(entry.Name())] {
+                               return fs.SkipDir
+                       }
+               }
+               if len(found) >= maxPluginSkillDir {
+                       return filepath.SkipAll
+               }
+               if depthUnder(root, path) > maxPluginDepth {
+                       return fs.SkipDir
+               }
+
+               if _, ok := manifestPath(path); ok && path != root {
+                       relative, err := filepath.Rel(root, path)
+                       if err != nil {
+                               return fs.SkipDir
+                       }
+                       found = append(found, filepath.ToSlash(relative))
+                       // Whatever a skill ships beside its manifest belongs 
to it, even
+                       // when that is another folder with a manifest of its 
own.
+                       return fs.SkipDir
+               }
+               return nil
+       })
+       sort.Strings(found)
+       return found
+}
diff --git a/agentconfig/install.go b/agentconfig/install.go
new file mode 100644
index 0000000..2329f79
--- /dev/null
+++ b/agentconfig/install.go
@@ -0,0 +1,385 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentconfig
+
+import (
+       "errors"
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+)
+
+// How an installed skill relates to the source it came from.
+const (
+       // InstallCopy writes the skill into the agent's own skills directory. 
The
+       // agent then owns it, and a later fetch of the source is an update to 
take
+       // or leave.
+       InstallCopy = "copy"
+       // InstallLink points the agent at Gateway's copy of the source, so 
every
+       // agent linked to it moves together when the source is fetched again.
+       InstallLink = "link"
+)
+
+// MaxUploadBytes bounds an archive uploaded through the browser, which is the
+// same bound a downloaded one is held to.
+const MaxUploadBytes = maxArchiveBytes
+
+// SkillCatalog is what one source holds, as the page lists it.
+type SkillCatalog struct {
+       Source *SkillSource `json:"source"`
+       // Root is the folder the skills below were found in, which is the 
store for
+       // a downloaded source and the folder itself for a local one.
+       Root   string          `json:"root"`
+       Skills []*CatalogSkill `json:"skills"`
+}
+
+// CatalogSkill is one skill of a source, before it is installed anywhere.
+type CatalogSkill struct {
+       // Name is the folder the skill would be installed under, and Path is 
where
+       // it is in the source.
+       Name        string `json:"name"`
+       Path        string `json:"path"`
+       Description string `json:"description,omitempty"`
+
+       Files    int    `json:"files,omitempty"`
+       Bytes    int64  `json:"bytes,omitempty"`
+       Digest   string `json:"digest,omitempty"`
+       Modified int64  `json:"modified,omitempty"`
+}
+
+// InstallRequest is one install: some of a source's skills, into one or more
+// agents belonging to the same account.
+type InstallRequest struct {
+       Owner    string   `json:"owner"`
+       SourceId string   `json:"sourceId"`
+       To       []string `json:"to"`
+       Names    []string `json:"names"`
+       // Mode is InstallCopy or InstallLink.
+       Mode      string `json:"mode"`
+       Overwrite bool   `json:"overwrite"`
+}
+
+// ReadCatalog lists what one source holds, fetching it first when the store 
has
+// nothing or when refresh asks for it again.
+func ReadCatalog(owner string, id string, refresh bool) (*SkillCatalog, error) 
{
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+       source, err := findSource(home, id)
+       if err != nil {
+               return nil, err
+       }
+
+       tree := filepath.Join(sourceStore(home, id), storeTree)
+       if source.Kind != SourceLocal && (refresh || !exists(tree)) {
+               if err := fetchSource(home, source); err != nil {
+                       return nil, err
+               }
+       }
+
+       root, err := catalogRoot(home, source)
+       if err != nil {
+               return nil, err
+       }
+
+       catalog := &SkillCatalog{Source: source, Root: root, Skills: 
[]*CatalogSkill{}}
+       for _, relative := range findSkillFolders(root) {
+               catalog.Skills = append(catalog.Skills, readCatalogSkill(root, 
relative))
+       }
+
+       if source.Kind != SourceLocal {
+               writeFetchRecord(home, id, len(catalog.Skills))
+       }
+       attachFetch(home, source)
+       return catalog, nil
+}
+
+// UploadSource takes an archive the operator picked in the browser, unpacks it
+// into the store and records it as a source. An archive that holds no skill is
+// refused here rather than added as a source with nothing in it.
+func UploadSource(owner string, name string, data []byte) (*SkillSource, 
error) {
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+       if len(data) == 0 {
+               return nil, errors.New("the uploaded file is empty")
+       }
+
+       source := &SkillSource{Kind: SourceUpload, Name: 
strings.TrimSpace(name)}
+       if err := normalizeSource(source); err != nil {
+               return nil, err
+       }
+       if err := storeArchive(home, source.Id, data); err != nil {
+               return nil, err
+       }
+
+       root, err := catalogRoot(home, source)
+       if err != nil {
+               os.RemoveAll(sourceStore(home, source.Id))
+               return nil, err
+       }
+       if len(findSkillFolders(root)) == 0 {
+               os.RemoveAll(sourceStore(home, source.Id))
+               return nil, fmt.Errorf("%s holds no folder with a %s in it", 
source.Name, skillFile)
+       }
+
+       added, err := AddSource(owner, source)
+       if err != nil {
+               os.RemoveAll(sourceStore(home, source.Id))
+               return nil, err
+       }
+       return added, nil
+}
+
+// catalogRoot is the folder a source's skills are read from: the folder itself
+// for a local source, the unwrapped archive for the rest, narrowed by the
+// subfolder the source names.
+func catalogRoot(home string, source *SkillSource) (string, error) {
+       root := source.Url
+       if source.Kind != SourceLocal {
+               root = archiveRoot(filepath.Join(sourceStore(home, source.Id), 
storeTree))
+       }
+
+       if source.Subdir != "" {
+               narrowed, err := safeJoin(root, source.Subdir)
+               if err != nil {
+                       return "", err
+               }
+               root = narrowed
+       }
+       if !exists(root) {
+               return "", fmt.Errorf("%s is not there", root)
+       }
+       return root, nil
+}
+
+func readCatalogSkill(root string, relative string) *CatalogSkill {
+       path := filepath.Join(root, filepath.FromSlash(relative))
+       skill := &CatalogSkill{Name: relative, Path: path}
+
+       if manifest, ok := manifestPath(path); ok {
+               if raw, err := os.ReadFile(manifest); err == nil {
+                       _, skill.Description = parseFrontMatter(string(raw))
+               }
+       }
+       stat := measure(path)
+       skill.Files, skill.Bytes, skill.Digest, skill.Modified = stat.files, 
stat.bytes, stat.digest, stat.modified
+       return skill
+}
+
+// InstallSkills writes the chosen skills into every target agent. One skill's
+// failure is recorded against it and the rest still run, the way a copy 
between
+// agents is reported item by item.
+func InstallSkills(request InstallRequest) ([]*PlanItem, error) {
+       switch {
+       case request.SourceId == "":
+               return nil, errors.New("no skill source was chosen")
+       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 install")
+       case request.Mode != InstallCopy && request.Mode != InstallLink:
+               return nil, fmt.Errorf("unknown install mode: %s", request.Mode)
+       }
+
+       home, err := homeOf(request.Owner)
+       if err != nil {
+               return nil, err
+       }
+       catalog, err := ReadCatalog(request.Owner, request.SourceId, false)
+       if err != nil {
+               return nil, err
+       }
+
+       available := map[string]*CatalogSkill{}
+       for _, skill := range catalog.Skills {
+               available[skill.Name] = skill
+       }
+
+       planned := []*PlanItem{}
+       for _, agentId := range request.To {
+               for _, name := range request.Names {
+                       planned = append(planned, installOne(request, home, 
catalog, available[name], agentId, name))
+               }
+       }
+       return planned, nil
+}
+
+// installOne puts one skill of the source into one agent, deciding first what
+// it would do to what is already there.
+func installOne(
+       request InstallRequest, home string, catalog *SkillCatalog,
+       skill *CatalogSkill, agentId string, name string,
+) *PlanItem {
+       item := &PlanItem{AgentId: agentId, Name: name}
+       if skill == nil {
+               item.Action, item.Reason = ActionSkip, "this skill is no longer 
in the source"
+               return item
+       }
+
+       found, _, err := resolve(agentId, request.Owner, KindSkill)
+       if err != nil {
+               item.Action, item.Reason = ActionSkip, err.Error()
+               return item
+       }
+       dir := found.skills.dir(home)
+       if dir == "" {
+               item.Action, item.Reason = ActionSkip, "Gateway does not know 
where this agent keeps its own skills"
+               return item
+       }
+
+       folder := targetName(KindSkill, name)
+       if err := checkName(folder); err != nil {
+               item.Action, item.Reason = ActionSkip, err.Error()
+               return item
+       }
+       target := filepath.Join(dir, folder)
+       item.Path = target
+
+       // A copy holding the source's content is done; the same content behind 
a
+       // link is not, when a copy is what was asked for.
+       sameMode := isLink(target) == (request.Mode == InstallLink)
+
+       switch {
+       case !exists(target):
+               item.Action = ActionCreate
+       case sameMode && installedDigest(target) == skill.Digest:
+               item.Action, item.Reason = ActionSkip, "already installed"
+               return item
+       case !request.Overwrite:
+               item.Action, item.Reason = ActionSkip, "a different version is 
already there"
+               return item
+       default:
+               item.Action, item.Reason = ActionOverwrite, "replaces what was 
installed"
+               if err := trashInstalled(home, agentId, request.Owner, name, 
target); err != nil {
+                       item.Action, item.Reason = ActionFailed, err.Error()
+                       return item
+               }
+       }
+
+       if err := placeSkill(skill.Path, target, request.Mode); err != nil {
+               item.Action, item.Reason = ActionFailed, err.Error()
+               return item
+       }
+
+       // The store keeps what was installed, so the source of an installed 
skill
+       // is a folder on this machine like any other and the update badge, the
+       // diff against it and the Update button all work unchanged.
+       recordSkillOrigin(home, target, catalog.Source.Id, 
sourceItemName(catalog.Source, skill), skill.Path)
+       return item
+}
+
+// sourceItemName is what the update badge calls this skill's source: the skill
+// inside the source it was installed from.
+func sourceItemName(source *SkillSource, skill *CatalogSkill) string {
+       return source.Name + ":" + skill.Name
+}
+
+// placeSkill puts one skill folder at target, as a copy of the source or as a
+// link to it.
+func placeSkill(from string, target string, mode string) error {
+       if _, ok := manifestPath(from); !ok {
+               return fmt.Errorf("%s is not a skill folder", from)
+       }
+       if mode == InstallCopy {
+               _, err := copySkill(from, filepath.Dir(target), 
filepath.Base(target))
+               return err
+       }
+
+       if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+               return err
+       }
+       if err := os.RemoveAll(target); err != nil {
+               return err
+       }
+       return makeLink(from, target)
+}
+
+// trashInstalled recycles what an overwrite replaces, so an install over the
+// wrong skill is one restore away like a delete.
+func trashInstalled(home string, agentId string, owner string, name string, 
target string) error {
+       stat := measure(target)
+       return trashSkill(home, &Item{
+               AgentId: agentId,
+               Owner:   owner,
+               Kind:    KindSkill,
+               Name:    targetName(KindSkill, name),
+               Path:    target,
+               Files:   stat.files,
+               Bytes:   stat.bytes,
+       })
+}
+
+// installedDigest identifies what is at a path already, so installing the same
+// skill twice is reported as nothing to do rather than as a replacement.
+func installedDigest(path string) string {
+       if _, ok := manifestPath(path); !ok {
+               return ""
+       }
+       return measure(path).digest
+}
+
+// isLink reports a skill installed as a symbolic link rather than as a copy.
+func isLink(path string) bool {
+       info, err := os.Lstat(path)
+       return err == nil && info.Mode()&os.ModeSymlink != 0
+}
+
+// linkTarget is what a linked skill points at, for the listing to show. It is
+// empty for a skill that is a folder of its own.
+func linkTarget(path string) string {
+       if !isLink(path) {
+               return ""
+       }
+       target, err := filepath.EvalSymlinks(path)
+       if err != nil {
+               // A link whose target has been removed still says what it 
aimed at,
+               // which is what explains a skill that has stopped working.
+               if raw, readErr := os.Readlink(path); readErr == nil {
+                       return raw
+               }
+               return ""
+       }
+       return target
+}
+
+// resolvedDir looks through a symbolic link to the folder it names, because
+// walking a link visits the link and not what is inside it.
+func resolvedDir(dir string) string {
+       if !isLink(dir) {
+               return dir
+       }
+       if target, err := filepath.EvalSymlinks(dir); err == nil {
+               return target
+       }
+       return dir
+}
+
+// isDirEntry reports a directory, following a symbolic link. A skill installed
+// as a link is a link in its agent's skills directory, and os.ReadDir does not
+// look through one.
+func isDirEntry(path string, isDir bool, symlink bool) bool {
+       if isDir {
+               return true
+       }
+       if !symlink {
+               return false
+       }
+       info, err := os.Stat(path)
+       return err == nil && info.IsDir()
+}
diff --git a/agentconfig/link_other.go b/agentconfig/link_other.go
new file mode 100644
index 0000000..ca4dc86
--- /dev/null
+++ b/agentconfig/link_other.go
@@ -0,0 +1,24 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agentconfig
+
+import "os"
+
+// makeLink points target at the source folder.
+func makeLink(from string, target string) error {
+       return os.Symlink(from, target)
+}
diff --git a/agentconfig/link_windows.go b/agentconfig/link_windows.go
new file mode 100644
index 0000000..5207047
--- /dev/null
+++ b/agentconfig/link_windows.go
@@ -0,0 +1,44 @@
+// 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"
+       "os/exec"
+       "strings"
+)
+
+// makeLink points target at the source folder. Windows only lets an
+// unprivileged process make a symbolic link with Developer Mode on, so a
+// directory junction is the fallback: it needs no privilege, and every program
+// reading the folder — the agents included — sees the files behind it exactly
+// as a symbolic link would show them.
+func makeLink(from string, target string) error {
+       symlinkErr := os.Symlink(from, target)
+       if symlinkErr == nil {
+               return nil
+       }
+
+       // mklink is a shell builtin rather than a program, so it is run through
+       // cmd. /J makes the junction.
+       command := exec.Command("cmd", "/c", "mklink", "/J", target, from)
+       output, err := command.CombinedOutput()
+       if err == nil {
+               return nil
+       }
+       return fmt.Errorf("%s, and a directory junction failed too: %s",
+               symlinkErr, strings.TrimSpace(string(output)))
+}
diff --git a/agentconfig/skill.go b/agentconfig/skill.go
index 512ee19..ff684a6 100644
--- a/agentconfig/skill.go
+++ b/agentconfig/skill.go
@@ -132,13 +132,14 @@ func readSkillDir(agentId string, owner string, dir 
skillDir) ([]*Item, error) {
 
        items := []*Item{}
        for _, entry := range entries {
+               path := filepath.Join(dir.path, entry.Name())
                // 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(), ".") {
+               if !isDirEntry(path, entry.IsDir(), entry.Type()&fs.ModeSymlink 
!= 0) ||
+                       strings.HasPrefix(entry.Name(), ".") {
                        continue
                }
 
-               path := filepath.Join(dir.path, entry.Name())
                if _, ok := manifestPath(path); ok {
                        items = append(items, newSkillItem(agentId, owner, dir, 
entry.Name(), path))
                        continue
@@ -149,10 +150,11 @@ func readSkillDir(agentId string, owner string, dir 
skillDir) ([]*Item, error) {
                        continue
                }
                for _, child := range nested {
-                       if !child.IsDir() || strings.HasPrefix(child.Name(), 
".") {
+                       childPath := filepath.Join(path, child.Name())
+                       if !isDirEntry(childPath, child.IsDir(), 
child.Type()&fs.ModeSymlink != 0) ||
+                               strings.HasPrefix(child.Name(), ".") {
                                continue
                        }
-                       childPath := filepath.Join(path, child.Name())
                        if _, ok := manifestPath(childPath); ok {
                                items = append(items, newSkillItem(agentId, 
owner, dir, entry.Name()+"/"+child.Name(), childPath))
                        }
@@ -186,6 +188,7 @@ func newSkillItem(agentId string, owner string, dir 
skillDir, name string, path
                        _, item.Description = parseFrontMatter(string(raw))
                }
        }
+       item.Link = linkTarget(path)
        stat := measure(path)
        item.Files, item.Bytes, item.Digest, item.Modified = stat.files, 
stat.bytes, stat.digest, stat.modified
        return item
@@ -316,6 +319,10 @@ type treeStat struct {
 }
 
 func measure(dir string) treeStat {
+       // A skill installed as a link is a link, and walking one visits the 
link
+       // rather than the folder it names.
+       dir = resolvedDir(dir)
+
        stat := treeStat{}
        hash := sha256.New()
        // WalkDir visits in lexical order, so one folder digests the same 
wherever
@@ -363,6 +370,8 @@ func shortDigest(sum []byte) string {
 // 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 {
+       dir = resolvedDir(dir)
+
        files := []string{}
        filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) 
error {
                if err != nil || entry.IsDir() || len(files) >= maxSkillFiles {
diff --git a/agentconfig/source.go b/agentconfig/source.go
new file mode 100644
index 0000000..0bd195e
--- /dev/null
+++ b/agentconfig/source.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
+
+import (
+       "crypto/sha256"
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       // sourceFile lists the places skills are installed from, beside the 
trash
+       // and the origin records under the same home.
+       sourceFile = "skill-sources.json"
+       // storeFolder holds one folder per source: the repository or archive 
as it
+       // was last downloaded, which is what an install copies or links from.
+       storeFolder = "skill-store"
+       // storeTree is the downloaded content and storeFetch the record of 
when it
+       // was taken, kept beside it so wiping the tree cannot lose the record.
+       storeTree  = "tree"
+       storeFetch = "fetch.json"
+)
+
+// Where a source's skills come from.
+const (
+       // SourceGithub is a repository, given as owner/repo or as a github.com 
URL.
+       SourceGithub = "github"
+       // SourceArchive is a .zip or .tar.gz at a URL.
+       SourceArchive = "archive"
+       // SourceUpload is an archive uploaded through the browser, which lives 
in
+       // the store and has nowhere to be fetched from again.
+       SourceUpload = "upload"
+       // SourceLocal is a folder on this machine, read where it is.
+       SourceLocal = "local"
+)
+
+// SkillSource is one place skills can be installed from. The list is the
+// operator's: the built-in entries are seeded once and can be removed like any
+// other.
+type SkillSource struct {
+       Id   string `json:"id"`
+       Name string `json:"name"`
+       Kind string `json:"kind"`
+
+       // Url is owner/repo for a repository, the download URL for an archive, 
or
+       // the folder for a local source. An upload has none: it is already 
here.
+       Url string `json:"url,omitempty"`
+       // Ref is the branch, tag or commit of a repository. Empty follows its
+       // default branch.
+       Ref string `json:"ref,omitempty"`
+       // Subdir narrows the search to one folder of the source, for a 
repository
+       // that keeps its skills under one.
+       Subdir string `json:"subdir,omitempty"`
+
+       Builtin bool  `json:"builtin,omitempty"`
+       AddedAt int64 `json:"addedAt,omitempty"`
+
+       // FetchedAt is when the store last took a copy, and Skills how many it 
held
+       // then. Both are read from the store rather than stored in the list.
+       FetchedAt int64 `json:"fetchedAt,omitempty"`
+       Skills    int   `json:"skills,omitempty"`
+}
+
+// storeFetchRecord is what one download left in the store.
+type storeFetchRecord struct {
+       FetchedAt int64 `json:"fetchedAt"`
+       Skills    int   `json:"skills"`
+}
+
+// builtinSources are seeded into an empty list, so the page has somewhere to
+// install from before the operator has added anything.
+var builtinSources = []*SkillSource{
+       {
+               Name:    "Anthropic skills",
+               Kind:    SourceGithub,
+               Url:     "anthropics/skills",
+               Builtin: true,
+       },
+}
+
+// sourceLock serializes the read-modify-write of the source list, the way the
+// origin records are serialized.
+var sourceLock sync.Mutex
+
+// ListSources returns the places skills can be installed from, with what the
+// store holds for each. The built-in entries are written on the first read, so
+// what the page shows and what the file holds never differ.
+func ListSources(owner string) ([]*SkillSource, error) {
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+
+       sourceLock.Lock()
+       sources, seeded := loadSources(home)
+       if seeded {
+               saveSources(home, sources)
+       }
+       sourceLock.Unlock()
+
+       for _, source := range sources {
+               attachFetch(home, source)
+       }
+       return sources, nil
+}
+
+// AddSource records one new place to install from. A source already on the 
list
+// is returned as it stands rather than duplicated: the id is derived from what
+// the source points at, so pasting the same URL twice is the same source.
+func AddSource(owner string, source *SkillSource) (*SkillSource, error) {
+       home, err := homeOf(owner)
+       if err != nil {
+               return nil, err
+       }
+       if err := normalizeSource(source); err != nil {
+               return nil, err
+       }
+
+       sourceLock.Lock()
+       defer sourceLock.Unlock()
+
+       sources, _ := loadSources(home)
+       for _, existing := range sources {
+               if existing.Id == source.Id {
+                       attachFetch(home, existing)
+                       return existing, nil
+               }
+       }
+
+       source.AddedAt = time.Now().Unix()
+       sources = append(sources, source)
+       if err := saveSources(home, sources); err != nil {
+               return nil, err
+       }
+       return source, nil
+}
+
+// DeleteSource takes one source off the list and drops what the store holds 
for
+// it. Skills already installed from it are untouched: they are the agent's 
now.
+func DeleteSource(owner string, id string) error {
+       home, err := homeOf(owner)
+       if err != nil {
+               return err
+       }
+       if err := checkName(id); err != nil {
+               return err
+       }
+
+       sourceLock.Lock()
+       defer sourceLock.Unlock()
+
+       sources, _ := loadSources(home)
+       kept := []*SkillSource{}
+       found := false
+       for _, source := range sources {
+               if source.Id == id {
+                       found = true
+                       continue
+               }
+               kept = append(kept, source)
+       }
+       if !found {
+               return fmt.Errorf("no skill source with id %q", id)
+       }
+
+       if err := saveSources(home, kept); err != nil {
+               return err
+       }
+       os.RemoveAll(sourceStore(home, id))
+       return nil
+}
+
+// findSource resolves one source by id, for the catalog and the install.
+func findSource(home string, id string) (*SkillSource, error) {
+       if err := checkName(id); err != nil {
+               return nil, err
+       }
+
+       sourceLock.Lock()
+       sources, seeded := loadSources(home)
+       if seeded {
+               saveSources(home, sources)
+       }
+       sourceLock.Unlock()
+
+       for _, source := range sources {
+               if source.Id == id {
+                       return source, nil
+               }
+       }
+       return nil, fmt.Errorf("no skill source with id %q", id)
+}
+
+// normalizeSource turns what the browser sent into a source this package can
+// fetch, and gives it the id it is known by.
+func normalizeSource(source *SkillSource) error {
+       source.Url = strings.TrimSpace(source.Url)
+       source.Ref = strings.TrimSpace(source.Ref)
+       source.Name = strings.TrimSpace(source.Name)
+       source.Subdir = 
strings.Trim(strings.TrimSpace(filepath.ToSlash(source.Subdir)), "/")
+       source.Builtin = false
+
+       switch source.Kind {
+       case SourceGithub:
+               repo, ref, subdir, err := parseRepoUrl(source.Url)
+               if err != nil {
+                       return err
+               }
+               source.Url = repo
+               if source.Ref == "" {
+                       source.Ref = ref
+               }
+               if source.Subdir == "" {
+                       source.Subdir = subdir
+               }
+               if source.Name == "" {
+                       source.Name = repo
+               }
+       case SourceArchive:
+               parsed, err := url.Parse(source.Url)
+               if err != nil || (parsed.Scheme != "http" && parsed.Scheme != 
"https") || parsed.Host == "" {
+                       return fmt.Errorf("%q is not an http or https URL", 
source.Url)
+               }
+               if source.Name == "" {
+                       source.Name = 
strings.TrimSuffix(filepath.Base(parsed.Path), ".zip")
+               }
+       case SourceLocal:
+               if source.Url == "" {
+                       return fmt.Errorf("the folder is empty")
+               }
+               absolute, err := filepath.Abs(source.Url)
+               if err != nil {
+                       return err
+               }
+               info, err := os.Stat(absolute)
+               if err != nil {
+                       return err
+               }
+               if !info.IsDir() {
+                       return fmt.Errorf("%s is not a folder", absolute)
+               }
+               source.Url = absolute
+               source.Ref = ""
+               if source.Name == "" {
+                       source.Name = filepath.Base(absolute)
+               }
+       case SourceUpload:
+               if source.Name == "" {
+                       source.Name = "uploaded archive"
+               }
+       default:
+               return fmt.Errorf("unknown skill source kind: %s", source.Kind)
+       }
+
+       if source.Id == "" {
+               source.Id = sourceId(source)
+       }
+       return nil
+}
+
+// parseRepoUrl accepts a repository the several ways one gets written down:
+// owner/repo, the browser URL of the repository, and the browser URL of a
+// branch or of one folder inside it, which carries the ref and the subfolder.
+func parseRepoUrl(raw string) (repo string, ref string, subdir string, err 
error) {
+       text := strings.TrimSpace(raw)
+       text = strings.TrimSuffix(text, ".git")
+       text = strings.TrimSuffix(text, "/")
+
+       if strings.Contains(text, "://") || strings.HasPrefix(text, "git@") {
+               text = strings.TrimPrefix(text, "[email protected]:")
+               if parsed, parseErr := url.Parse(text); parseErr == nil && 
parsed.Host != "" {
+                       if !strings.EqualFold(parsed.Host, "github.com") && 
!strings.EqualFold(parsed.Host, "www.github.com") {
+                               return "", "", "", fmt.Errorf("%q is not a 
github.com repository", raw)
+                       }
+                       text = strings.Trim(parsed.Path, "/")
+               }
+       }
+
+       parts := strings.Split(text, "/")
+       if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
+               return "", "", "", fmt.Errorf("%q is not a repository; write it 
as owner/repo", raw)
+       }
+       repo = parts[0] + "/" + parts[1]
+
+       // .../tree/<ref>/<subdir> and .../blob/<ref>/<subdir> are what the 
browser
+       // puts in the address bar when one is looking at the folder to install.
+       if len(parts) > 3 && (parts[2] == "tree" || parts[2] == "blob") {
+               ref = parts[3]
+               subdir = strings.Join(parts[4:], "/")
+       }
+       return repo, ref, subdir, nil
+}
+
+// sourceId names a source by what it points at, so the same repository added
+// twice is one source and the store folder of one source is never another's.
+func sourceId(source *SkillSource) string {
+       key := strings.Join([]string{source.Kind, strings.ToLower(source.Url), 
source.Ref, source.Subdir}, "\x00")
+       if source.Kind == SourceUpload {
+               // An upload has no address to be named after, and two uploads 
of the
+               // same file are still two of them.
+               key = fmt.Sprintf("%s\x00%d", source.Name, 
time.Now().UnixNano())
+       }
+       sum := sha256.Sum256([]byte(key))
+       return fmt.Sprintf("%s-%s", slug(sourceLabel(source)), 
shortDigest(sum[:])[:8])
+}
+
+func sourceLabel(source *SkillSource) string {
+       if source.Kind == SourceGithub || source.Name == "" {
+               return strings.ReplaceAll(source.Url, "/", "-")
+       }
+       return source.Name
+}
+
+// sourceStore is where one source's downloaded content is kept.
+func sourceStore(home string, id string) string {
+       return filepath.Join(home, trashRoot, storeFolder, id)
+}
+
+func loadSources(home string) ([]*SkillSource, bool) {
+       sources := []*SkillSource{}
+       if raw, err := os.ReadFile(filepath.Join(home, trashRoot, sourceFile)); 
err == nil {
+               json.Unmarshal(raw, &sources)
+               return sources, false
+       }
+
+       for _, builtin := range builtinSources {
+               seeded := *builtin
+               seeded.AddedAt = time.Now().Unix()
+               if err := normalizeSource(&seeded); err != nil {
+                       continue
+               }
+               seeded.Builtin = true
+               sources = append(sources, &seeded)
+       }
+       return sources, true
+}
+
+func saveSources(home string, sources []*SkillSource) error {
+       sort.SliceStable(sources, func(i, j int) bool { return 
sources[i].AddedAt < sources[j].AddedAt })
+       raw, err := json.MarshalIndent(sources, "", "  ")
+       if err != nil {
+               return err
+       }
+       return writeFile(filepath.Join(home, trashRoot, sourceFile), raw, 
defaultConfigMode)
+}
+
+// attachFetch tells a source what the store holds for it, which is what the
+// page shows as "fetched 2 hours ago, 34 skills".
+func attachFetch(home string, source *SkillSource) {
+       source.FetchedAt, source.Skills = 0, 0
+       raw, err := os.ReadFile(filepath.Join(sourceStore(home, source.Id), 
storeFetch))
+       if err != nil {
+               return
+       }
+       record := storeFetchRecord{}
+       if err := json.Unmarshal(raw, &record); err != nil {
+               return
+       }
+       source.FetchedAt, source.Skills = record.FetchedAt, record.Skills
+}
+
+func writeFetchRecord(home string, id string, skills int) {
+       raw, err := json.Marshal(storeFetchRecord{FetchedAt: time.Now().Unix(), 
Skills: skills})
+       if err != nil {
+               return
+       }
+       writeFile(filepath.Join(sourceStore(home, id), storeFetch), raw, 
defaultConfigMode)
+}
diff --git a/controllers/agent_config_skill.go 
b/controllers/agent_config_skill.go
new file mode 100644
index 0000000..a0292e0
--- /dev/null
+++ b/controllers/agent_config_skill.go
@@ -0,0 +1,172 @@
+// 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"
+       "fmt"
+       "io"
+
+       "github.com/apache/casbin-gateway/agentconfig"
+)
+
+// GetSkillSources lists the places skills can be installed from: the built-in
+// repositories and whatever the operator has added.
+func (c *ApiController) GetSkillSources() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       sources, err := agentconfig.ListSources(c.GetString("owner"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(sources)
+}
+
+// AddSkillSource records one new place to install from: a GitHub repository, a
+// .zip or .tar.gz at a URL, or a folder on this machine.
+func (c *ApiController) AddSkillSource() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               Owner  string `json:"owner"`
+               Name   string `json:"name"`
+               Kind   string `json:"kind"`
+               Url    string `json:"url"`
+               Ref    string `json:"ref"`
+               Subdir string `json:"subdir"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       source, err := agentconfig.AddSource(form.Owner, 
&agentconfig.SkillSource{
+               Name:   form.Name,
+               Kind:   form.Kind,
+               Url:    form.Url,
+               Ref:    form.Ref,
+               Subdir: form.Subdir,
+       })
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(source)
+}
+
+// UploadSkillSource takes an archive of skills the operator picked in the
+// browser and adds it as a source, so a skill that is nowhere public can be
+// installed the same way as one that is.
+func (c *ApiController) UploadSkillSource() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       file, header, err := c.GetFile("file")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       defer file.Close()
+
+       // One byte past the limit tells an archive that is exactly the limit 
from
+       // one that was cut short here.
+       data, err := io.ReadAll(io.LimitReader(file, 
agentconfig.MaxUploadBytes+1))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if len(data) > agentconfig.MaxUploadBytes {
+               c.ResponseError(fmt.Sprintf("this archive is larger than %d 
MB", agentconfig.MaxUploadBytes>>20))
+               return
+       }
+
+       name := c.GetString("name")
+       if name == "" && header != nil {
+               name = header.Filename
+       }
+
+       source, err := agentconfig.UploadSource(c.GetString("owner"), name, 
data)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(source)
+}
+
+// DeleteSkillSource takes one source off the list and drops Gateway's copy of
+// it. Skills already installed from it stay where they are.
+func (c *ApiController) DeleteSkillSource() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               Owner string `json:"owner"`
+               Id    string `json:"id"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       if err := agentconfig.DeleteSource(form.Owner, form.Id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(form.Id)
+}
+
+// GetSkillCatalog lists the skills one source holds, downloading it first when
+// Gateway has no copy of it or when refresh asks for it again.
+func (c *ApiController) GetSkillCatalog() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       catalog, err := agentconfig.ReadCatalog(
+               c.GetString("owner"), c.GetString("id"), c.GetString("refresh") 
== "true")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(catalog)
+}
+
+// InstallSkills writes the chosen skills of one source into the agents it
+// names, as a copy each agent then owns or as a link that follows the source.
+func (c *ApiController) InstallSkills() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var request agentconfig.InstallRequest
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+
+       result, err := agentconfig.InstallSkills(request)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(result)
+}
diff --git a/routers/router.go b/routers/router.go
index 2a366cd..63e994f 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -74,6 +74,12 @@ func initAPI() {
        beego.Router("/api/apply-agent-provider", &controllers.ApiController{}, 
"POST:ApplyAgentProvider")
        beego.Router("/api/restore-agent-provider", 
&controllers.ApiController{}, "POST:RestoreAgentProvider")
        beego.Router("/api/copy-agent-config", &controllers.ApiController{}, 
"POST:CopyAgentConfig")
+       beego.Router("/api/get-skill-sources", &controllers.ApiController{}, 
"GET:GetSkillSources")
+       beego.Router("/api/add-skill-source", &controllers.ApiController{}, 
"POST:AddSkillSource")
+       beego.Router("/api/upload-skill-source", &controllers.ApiController{}, 
"POST:UploadSkillSource")
+       beego.Router("/api/delete-skill-source", &controllers.ApiController{}, 
"POST:DeleteSkillSource")
+       beego.Router("/api/get-skill-catalog", &controllers.ApiController{}, 
"GET:GetSkillCatalog")
+       beego.Router("/api/install-skills", &controllers.ApiController{}, 
"POST:InstallSkills")
 
        // Provider routes for LLM gateway milestone 1.1.
        beego.Router("/api/get-providers", &controllers.ApiController{}, 
"GET:GetProviders")
diff --git a/web/src/backend/AgentConfigBackend.ts 
b/web/src/backend/AgentConfigBackend.ts
index 4dc593a..4e2a221 100644
--- a/web/src/backend/AgentConfigBackend.ts
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {query, request} from "@/backend/request";
+import {query, request, upload} from "@/backend/request";
 import type {
   AgentConfigDetail,
   AgentConfigInventory,
@@ -21,6 +21,10 @@ import type {
   AgentConfigPlanItem,
   AgentConfigTrashEntry,
   McpTransport,
+  SkillCatalog,
+  SkillInstallMode,
+  SkillSource,
+  SkillSourceKind,
 } from "@/types";
 
 export interface CopyRequest {
@@ -114,3 +118,57 @@ export function planAgentConfigCopy(body: CopyRequest) {
 export function copyAgentConfig(body: CopyRequest) {
   return request<AgentConfigPlanItem[]>("/api/copy-agent-config", "POST", 
body);
 }
+
+/** One new place to install skills from. */
+export interface SkillSourceRequest {
+  owner: string;
+  name?: string;
+  kind: SkillSourceKind;
+  url?: string;
+  ref?: string;
+  subdir?: string;
+}
+
+/** Some of one source's skills, into every agent in `to`. */
+export interface InstallSkillsRequest {
+  owner: string;
+  sourceId: string;
+  to: string[];
+  names: string[];
+  mode: SkillInstallMode;
+  overwrite: boolean;
+}
+
+/** The places skills can be installed from: the built-in ones and the 
operator's. */
+export function getSkillSources(owner: string) {
+  return request<SkillSource[]>(`/api/get-skill-sources${query({owner: 
owner})}`);
+}
+
+export function addSkillSource(body: SkillSourceRequest) {
+  return request<SkillSource>("/api/add-skill-source", "POST", body);
+}
+
+/** Adds an archive of skills picked in the browser as a source of its own. */
+export function uploadSkillSource(owner: string, file: File) {
+  const form = new FormData();
+  form.append("owner", owner);
+  form.append("name", file.name);
+  form.append("file", file);
+  return upload<SkillSource>("/api/upload-skill-source", form);
+}
+
+/** Drops one source and Gateway's copy of it. Installed skills stay put. */
+export function deleteSkillSource(owner: string, id: string) {
+  return request("/api/delete-skill-source", "POST", {owner: owner, id: id});
+}
+
+/** What one source holds, downloading it when Gateway has no copy or refresh 
says so. */
+export function getSkillCatalog(owner: string, id: string, refresh = false) {
+  return request<SkillCatalog>(
+    `/api/get-skill-catalog${query({owner: owner, id: id, refresh: refresh ? 
"true" : ""})}`,
+  );
+}
+
+export function installSkills(body: InstallSkillsRequest) {
+  return request<AgentConfigPlanItem[]>("/api/install-skills", "POST", body);
+}
diff --git a/web/src/backend/request.ts b/web/src/backend/request.ts
index e837c57..aa44fa3 100644
--- a/web/src/backend/request.ts
+++ b/web/src/backend/request.ts
@@ -62,6 +62,27 @@ export async function request<T = any, T2 = any>(
   }
 }
 
+/**
+ * The one call that sends a file rather than JSON. The Content-Type is left to
+ * the browser: it is the only party that knows the multipart boundary it just
+ * generated.
+ */
+export async function upload<T = any>(path: string, form: FormData): 
Promise<ApiResponse<T>> {
+  let response: Response;
+  try {
+    response = await fetch(`${ServerUrl}${path}`, {method: "POST", 
credentials: "include", body: form});
+  } catch {
+    throw unreachable();
+  }
+
+  const text = await response.text();
+  try {
+    return JSON.parse(text) as ApiResponse<T>;
+  } catch {
+    throw unreachable(response.ok ? undefined : response.status);
+  }
+}
+
 /** Builds a query string, dropping the parameters that were left empty. */
 export function query(params: Record<string, string | number | undefined | 
null>) {
   const search = new URLSearchParams();
diff --git a/web/src/components/agent-config/install-skill-dialog.tsx 
b/web/src/components/agent-config/install-skill-dialog.tsx
new file mode 100644
index 0000000..0593449
--- /dev/null
+++ b/web/src/components/agent-config/install-skill-dialog.tsx
@@ -0,0 +1,447 @@
+// 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 {Download, Link2, RefreshCw, Search} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {ActionBadge} from "@/components/agent-config/action-badge";
+import {SkillSourcePanel} from "@/components/agent-config/skill-source-panel";
+import {TargetPicker} from "@/components/agent-config/target-picker";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {counted, formatBytes, sharedName} from "@/lib/agent-configs";
+import {cn} from "@/lib/utils";
+import type {
+  AgentConfigInventory,
+  AgentConfigPlanItem,
+  SkillCatalog,
+  SkillInstallMode,
+  SkillSource,
+} from "@/types";
+
+/** The last part of a catalog name, which is the folder a skill lands in. */
+function folderOf(name: string) {
+  const cut = name.lastIndexOf("/");
+  return cut < 0 ? name : name.slice(cut + 1);
+}
+
+/**
+ * Installing skills from outside this machine: pick a source, pick what it
+ * holds, pick the agents to put it in. A copy belongs to the agent afterwards;
+ * a link keeps every agent on Gateway's copy of the source, so fetching the
+ * source again moves all of them at once.
+ */
+export function InstallSkillDialog({
+  open,
+  onOpenChange,
+  inventories,
+  source,
+  onDone,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  inventories: AgentConfigInventory[];
+  source: AgentConfigInventory;
+  onDone: () => void;
+}) {
+  const [sources, setSources] = React.useState<SkillSource[]>([]);
+  const [sourceId, setSourceId] = React.useState("");
+  const [catalog, setCatalog] = React.useState<SkillCatalog | null>(null);
+  const [loading, setLoading] = React.useState(false);
+  const [refreshing, setRefreshing] = React.useState(false);
+  const [search, setSearch] = React.useState("");
+  const [selected, setSelected] = React.useState<string[]>([]);
+  const [targets, setTargets] = React.useState<string[]>([]);
+  const [mode, setMode] = React.useState<SkillInstallMode>("copy");
+  const [overwrite, setOverwrite] = React.useState(false);
+  const [result, setResult] = React.useState<AgentConfigPlanItem[] | 
null>(null);
+  const [busy, setBusy] = React.useState(false);
+  const [error, setError] = React.useState("");
+
+  // A skill is written into one account's home directory, so the agents on
+  // offer are the ones reading that same home.
+  const candidates = inventories.filter(
+    inventory => inventory.home === source.home && inventory.skillsSupported,
+  );
+
+  const loadSources = React.useCallback(
+    (selectId?: string) => {
+      AgentConfigBackend.getSkillSources(source.owner)
+        .then(res => {
+          if (res.status !== "ok") {
+            setError(res.msg || i18next.t("agentConfig:Failed to read the 
skill sources"));
+            return;
+          }
+          const list = res.data ?? [];
+          setSources(list);
+          setSourceId(previous => {
+            const wanted = selectId ?? previous;
+            return list.some(item => item.id === wanted) ? wanted : 
(list[0]?.id ?? "");
+          });
+        })
+        .catch(err => setError(err.message || String(err)));
+    },
+    [source.owner],
+  );
+
+  React.useEffect(() => {
+    if (open) {
+      setSearch("");
+      setSelected([]);
+      setTargets(source.skillsSupported ? [source.agentId] : []);
+      setMode("copy");
+      setOverwrite(false);
+      setResult(null);
+      setError("");
+      loadSources();
+    }
+  }, [open, source.agentId, source.skillsSupported, loadSources]);
+
+  // Reading a catalog downloads the source the first time, so it is asked for
+  // only when a source is actually being looked at.
+  const readCatalog = React.useCallback(
+    (refresh: boolean) => {
+      if (sourceId === "") {
+        setCatalog(null);
+        return;
+      }
+
+      if (refresh) {
+        setRefreshing(true);
+      } else {
+        setLoading(true);
+      }
+      setError("");
+      AgentConfigBackend.getSkillCatalog(source.owner, sourceId, refresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setCatalog(res.data ?? null);
+            // Reading the catalog is what fills the store, so the source list
+            // only learns its count and its fetch time from this.
+            loadSources(sourceId);
+          } else {
+            setCatalog(null);
+            setError(res.msg || i18next.t("agentConfig:Failed to read this 
source"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          setLoading(false);
+          setRefreshing(false);
+        });
+    },
+    [source.owner, sourceId, loadSources],
+  );
+
+  React.useEffect(() => {
+    if (open) {
+      setSelected([]);
+      setResult(null);
+      readCatalog(false);
+    }
+    // readCatalog is left out on purpose: it changes identity whenever the
+    // source list is reloaded, and re-reading the catalog on that would loop.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, sourceId]);
+
+  // What the target agents already hold, so a skill that is already there says
+  // so before it is picked rather than after the install reports a skip.
+  const heldBy = new Map<string, string[]>();
+  candidates.forEach(inventory => {
+    inventory.skills.forEach(item => {
+      const holders = heldBy.get(sharedName(item)) ?? [];
+      holders.push(inventory.name);
+      heldBy.set(sharedName(item), holders);
+    });
+  });
+
+  const skills = (catalog?.skills ?? []).filter(skill => {
+    const text = search.trim().toLowerCase();
+    return (
+      text === "" ||
+      skill.name.toLowerCase().includes(text) ||
+      (skill.description ?? "").toLowerCase().includes(text)
+    );
+  });
+  const allSelected = skills.length > 0 && selected.length === skills.length;
+
+  const toggleOne = (name: string) =>
+    setSelected(previous =>
+      previous.includes(name) ? previous.filter(item => item !== name) : 
[...previous, name],
+    );
+
+  const toggleTarget = (agentId: string) =>
+    setTargets(previous =>
+      previous.includes(agentId) ? previous.filter(item => item !== agentId) : 
[...previous, agentId],
+    );
+
+  const install = () => {
+    setBusy(true);
+    setError("");
+    AgentConfigBackend.installSkills({
+      owner: source.owner,
+      sourceId: sourceId,
+      to: targets,
+      names: selected,
+      mode: mode,
+      overwrite: overwrite,
+    })
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("agentConfig:Failed to install"));
+          return;
+        }
+
+        const written = res.data ?? [];
+        const done = written.filter(item => item.action === "create" || 
item.action === "overwrite").length;
+        const failed = written.filter(item => item.action === "failed").length;
+        setResult(written);
+        Setting.showMessage(
+          failed > 0 || done === 0 ? "error" : "success",
+          failed > 0
+            ? i18next
+              .t("agentConfig:Installed {done}, {failed} failed")
+              .replace("{done}", String(done))
+              .replace("{failed}", String(failed))
+            : counted(done, "agentConfig:Installed 1 skill", 
"agentConfig:Installed {done} skills", "{done}"),
+        );
+        onDone();
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => setBusy(false));
+  };
+
+  const nameOf = (agentId: string) =>
+    candidates.find(inventory => inventory.agentId === agentId)?.name ?? 
agentId;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="flex max-h-[88vh] flex-col gap-4 sm:max-w-5xl">
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agentConfig:Install skills")}</DialogTitle>
+          <DialogDescription>{i18next.t("agentConfig:Install skills 
hint")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="flex min-h-0 flex-1 gap-3">
+          <SkillSourcePanel
+            owner={source.owner}
+            sources={sources}
+            selectedId={sourceId}
+            onSelect={setSourceId}
+            onChanged={loadSources}
+            disabled={busy}
+          />
+
+          <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
+            <div className="flex items-center gap-2">
+              <div className="relative flex-1">
+                <Search className="text-muted-foreground absolute top-1/2 
left-2 size-4 -translate-y-1/2" />
+                <Input
+                  value={search}
+                  className="h-8 pl-8"
+                  placeholder={i18next.t("agentConfig:Search by name")}
+                  onChange={event => setSearch(event.target.value)}
+                />
+              </div>
+              <Button
+                variant="outline"
+                size="sm"
+                disabled={sourceId === "" || loading || refreshing}
+                onClick={() => readCatalog(true)}
+              >
+                <RefreshCw className={cn("size-4", refreshing && 
"animate-spin")} />
+                {i18next.t("agentConfig:Fetch again")}
+              </Button>
+            </div>
+
+            {loading ? (
+              <Loading />
+            ) : skills.length === 0 ? (
+              <p className="text-muted-foreground flex-1 py-8 text-center 
text-sm">
+                {sourceId === ""
+                  ? i18next.t("agentConfig:No sources yet")
+                  : i18next.t("agentConfig:This source holds no skills")}
+              </p>
+            ) : (
+              <>
+                <label className="text-muted-foreground flex items-center 
gap-2 text-xs">
+                  <input
+                    type="checkbox"
+                    className="accent-primary size-4"
+                    checked={allSelected}
+                    onChange={() => setSelected(allSelected ? [] : 
skills.map(skill => skill.name))}
+                  />
+                  {i18next.t("agentConfig:Select all")}
+                  <span className="ml-auto">
+                    {counted(
+                      skills.length,
+                      "agentConfig:1 skill in this source",
+                      "agentConfig:{count} skills in this source",
+                    )}
+                  </span>
+                </label>
+
+                <div className="scrollbar-thin min-h-0 flex-1 divide-y 
overflow-y-auto rounded-md border">
+                  {skills.map(skill => {
+                    const holders = heldBy.get(folderOf(skill.name)) ?? [];
+                    return (
+                      <label
+                        key={skill.name}
+                        className="hover:bg-accent flex cursor-pointer 
items-start gap-2.5 px-3 py-2"
+                      >
+                        <input
+                          type="checkbox"
+                          className="accent-primary mt-0.5 size-4 shrink-0"
+                          checked={selected.includes(skill.name)}
+                          onChange={() => toggleOne(skill.name)}
+                        />
+                        <span className="flex min-w-0 flex-col">
+                          <span className="flex flex-wrap items-center 
gap-1.5">
+                            <span className="truncate text-sm 
font-medium">{folderOf(skill.name)}</span>
+                            {skill.name.includes("/") ? (
+                              <Badge variant="muted">{skill.name.slice(0, 
skill.name.lastIndexOf("/"))}</Badge>
+                            ) : null}
+                            {holders.length > 0 ? (
+                              <SimpleTooltip
+                                title={i18next
+                                  .t("agentConfig:Already installed in")
+                                  .replace("{agents}", holders.join(", "))}
+                              >
+                                <Badge 
variant="info">{i18next.t("agentConfig:Already installed")}</Badge>
+                              </SimpleTooltip>
+                            ) : null}
+                          </span>
+                          {skill.description ? (
+                            <span className="text-muted-foreground 
line-clamp-2 text-xs">
+                              {skill.description}
+                            </span>
+                          ) : null}
+                          <span className="text-muted-foreground text-xs">
+                            {counted(skill.files ?? 0, "agentConfig:1 file", 
"agentConfig:{files} files", "{files}")}
+                            {skill.bytes ? ` · ${formatBytes(skill.bytes)}` : 
""}
+                          </span>
+                        </span>
+                      </label>
+                    );
+                  })}
+                </div>
+              </>
+            )}
+          </div>
+        </div>
+
+        <div className="grid gap-3 sm:grid-cols-2">
+          <Field label={i18next.t("agentConfig:Install into")} 
hint={i18next.t("agentConfig:Install into hint")}>
+            <TargetPicker
+              candidates={candidates}
+              kind="skill"
+              selected={targets}
+              onToggle={toggleTarget}
+              disabled={busy}
+            />
+          </Field>
+
+          <Field
+            label={i18next.t("agentConfig:How to install")}
+            hint={
+              mode === "copy"
+                ? i18next.t("agentConfig:Copy mode hint")
+                : i18next.t("agentConfig:Link mode hint")
+            }
+          >
+            <div className="flex flex-wrap gap-2">
+              {(["copy", "link"] as SkillInstallMode[]).map(option => (
+                <button
+                  key={option}
+                  type="button"
+                  disabled={busy}
+                  onClick={() => setMode(option)}
+                  className={cn(
+                    "flex items-center gap-2 rounded-md border px-3 py-2 
text-sm transition-colors",
+                    mode === option ? "border-primary bg-primary/10" : 
"hover:bg-accent",
+                    busy && "cursor-not-allowed opacity-50",
+                  )}
+                >
+                  {option === "copy" ? <Download className="size-4" /> : 
<Link2 className="size-4" />}
+                  {option === "copy"
+                    ? i18next.t("agentConfig:Copy into the agent")
+                    : i18next.t("agentConfig:Link to Gateway's copy")}
+                </button>
+              ))}
+            </div>
+            <label className="flex items-center gap-2 text-sm">
+              <Switch checked={overwrite} onCheckedChange={setOverwrite} 
disabled={busy} />
+              <span>{i18next.t("agentConfig:Replace items that already 
exist")}</span>
+            </label>
+          </Field>
+        </div>
+
+        {error ? <MessageAlert description={error} /> : null}
+
+        {result === null ? null : (
+          <ul className="scrollbar-thin max-h-32 divide-y overflow-y-auto 
rounded-md border">
+            {result.map(item => (
+              <li
+                key={`${item.agentId}/${item.name}`}
+                className="flex items-center justify-between gap-3 px-3 py-1.5 
text-sm"
+              >
+                <span className="truncate">
+                  {folderOf(item.name)}
+                  <span className="text-muted-foreground"> → 
{nameOf(item.agentId)}</span>
+                </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>
+        )}
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {i18next.t("general:Close")}
+          </Button>
+          <Button
+            onClick={install}
+            loading={busy}
+            disabled={busy || selected.length === 0 || targets.length === 0}
+          >
+            {mode === "link" ? <Link2 className="size-4" /> : <Download 
className="size-4" />}
+            {selected.length === 0
+              ? i18next.t("agentConfig:Install skills")
+              : counted(selected.length, "agentConfig:Install 1 skill", 
"agentConfig:Install {count} skills")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/components/agent-config/skill-source-panel.tsx 
b/web/src/components/agent-config/skill-source-panel.tsx
new file mode 100644
index 0000000..646bb92
--- /dev/null
+++ b/web/src/components/agent-config/skill-source-panel.tsx
@@ -0,0 +1,329 @@
+// 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 {FileArchive, FolderOpen, Github, Plus, Trash2, Upload, X} from 
"lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {formatModified} from "@/lib/agent-configs";
+import {cn} from "@/lib/utils";
+import type {SkillSource, SkillSourceKind} from "@/types";
+
+const kindIcons: Record<SkillSourceKind, React.ComponentType<{className?: 
string}>> = {
+  github: Github,
+  archive: FileArchive,
+  upload: Upload,
+  local: FolderOpen,
+};
+
+/** What one source is, in the reader's words rather than in the API's. */
+export function sourceKindLabel(kind: SkillSourceKind) {
+  const labels: Record<SkillSourceKind, string> = {
+    github: "agentConfig:GitHub repository",
+    archive: "agentConfig:Archive URL",
+    upload: "agentConfig:Uploaded archive",
+    local: "agentConfig:Local folder",
+  };
+  return i18next.t(labels[kind] ?? "agentConfig:GitHub repository");
+}
+
+/** The one line under a source's name: where it is, and what came of it. */
+function sourceSubtitle(source: SkillSource) {
+  const where = source.kind === "upload" ? sourceKindLabel(source.kind) : 
source.url || "";
+  const ref = source.ref ? `#${source.ref}` : "";
+  const inside = source.subdir ? `/${source.subdir}` : "";
+  return `${where}${ref}${inside}`;
+}
+
+/**
+ * The list of places skills are installed from, and the form that adds one.
+ * Anything with a SKILL.md in it can be a source: a GitHub repository, an
+ * archive at a URL or on this machine, or a folder that is already here.
+ */
+export function SkillSourcePanel({
+  owner,
+  sources,
+  selectedId,
+  onSelect,
+  onChanged,
+  disabled = false,
+}: {
+  owner: string;
+  sources: SkillSource[];
+  selectedId: string;
+  onSelect: (id: string) => void;
+  onChanged: (selectId?: string) => void;
+  disabled?: boolean;
+}) {
+  const [adding, setAdding] = React.useState(false);
+  const [kind, setKind] = React.useState<SkillSourceKind>("github");
+  const [url, setUrl] = React.useState("");
+  const [ref, setRef] = React.useState("");
+  const [subdir, setSubdir] = React.useState("");
+  const [name, setName] = React.useState("");
+  const [busy, setBusy] = React.useState(false);
+  const file = React.useRef<HTMLInputElement>(null);
+
+  const reset = () => {
+    setUrl("");
+    setRef("");
+    setSubdir("");
+    setName("");
+  };
+
+  const added = (source: SkillSource | undefined, message: string) => {
+    Setting.showMessage("success", message);
+    setAdding(false);
+    reset();
+    onChanged(source?.id);
+  };
+
+  const add = () => {
+    setBusy(true);
+    AgentConfigBackend.addSkillSource({
+      owner: owner,
+      kind: kind,
+      url: url.trim(),
+      ref: ref.trim(),
+      subdir: subdir.trim(),
+      name: name.trim(),
+    })
+      .then(res => {
+        if (res.status === "ok") {
+          added(res.data, i18next.t("agentConfig:Source added"));
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to add this source"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setBusy(false));
+  };
+
+  const uploadArchive = (picked: File) => {
+    setBusy(true);
+    AgentConfigBackend.uploadSkillSource(owner, picked)
+      .then(res => {
+        if (res.status === "ok") {
+          added(res.data, i18next.t("agentConfig:Source added"));
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to add this source"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => {
+        setBusy(false);
+        if (file.current) {
+          file.current.value = "";
+        }
+      });
+  };
+
+  const remove = (source: SkillSource) =>
+    AgentConfigBackend.deleteSkillSource(owner, source.id)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", `${i18next.t("agentConfig:Source 
removed")}: ${source.name}`);
+          onChanged();
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to remove this source"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)));
+
+  const ready = kind === "upload" ? false : url.trim() !== "";
+
+  return (
+    <div className="flex min-h-0 w-64 shrink-0 flex-col gap-2 border-r pr-3">
+      <div className="flex items-center justify-between">
+        <span className="text-sm 
font-medium">{i18next.t("agentConfig:Sources")}</span>
+        <Button
+          variant="ghost"
+          size="icon-sm"
+          aria-label={i18next.t("agentConfig:Add source")}
+          disabled={disabled}
+          onClick={() => setAdding(previous => !previous)}
+        >
+          {adding ? <X className="size-4" /> : <Plus className="size-4" />}
+        </Button>
+      </div>
+
+      <div className="scrollbar-thin flex min-h-0 flex-1 flex-col gap-1 
overflow-y-auto">
+        {sources.map(source => {
+          const Icon = kindIcons[source.kind] ?? Github;
+          const active = source.id === selectedId;
+          return (
+            <div
+              key={source.id}
+              className={cn(
+                "group flex items-start gap-2 rounded-md border px-2 py-1.5 
transition-colors",
+                active ? "border-primary bg-primary/10" : "hover:bg-accent",
+              )}
+            >
+              <button
+                type="button"
+                disabled={disabled}
+                onClick={() => onSelect(source.id)}
+                className="flex min-w-0 flex-1 items-start gap-2 text-left"
+              >
+                <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" 
/>
+                <span className="flex min-w-0 flex-col">
+                  <span className="flex items-center gap-1.5">
+                    <span className="truncate text-sm leading-tight 
font-medium">{source.name}</span>
+                    {source.builtin ? (
+                      <Badge variant="muted">{i18next.t("agentConfig:Built 
in")}</Badge>
+                    ) : null}
+                  </span>
+                  <span className="text-muted-foreground truncate text-xs" 
title={sourceSubtitle(source)}>
+                    {sourceSubtitle(source)}
+                  </span>
+                  {source.fetchedAt ? (
+                    <span className="text-muted-foreground text-xs">
+                      {i18next
+                        .t("agentConfig:{count} skills, fetched {when}")
+                        .replace("{count}", String(source.skills ?? 0))
+                        .replace("{when}", formatModified(source.fetchedAt))}
+                    </span>
+                  ) : null}
+                </span>
+              </button>
+              <ConfirmDialog
+                title={i18next.t("agentConfig:Remove this source?")}
+                description={i18next.t("agentConfig:Remove source 
description")}
+                onConfirm={() => remove(source)}
+                disabled={disabled}
+              >
+                <SimpleTooltip title={i18next.t("agentConfig:Remove source")}>
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="text-destructive opacity-0 
group-hover:opacity-100"
+                    aria-label={i18next.t("agentConfig:Remove source")}
+                    disabled={disabled}
+                  >
+                    <Trash2 className="size-3.5" />
+                  </Button>
+                </SimpleTooltip>
+              </ConfirmDialog>
+            </div>
+          );
+        })}
+        {sources.length === 0 && !adding ? (
+          <p className="text-muted-foreground px-1 py-4 
text-xs">{i18next.t("agentConfig:No sources yet")}</p>
+        ) : null}
+      </div>
+
+      {adding ? (
+        <div className="flex flex-col gap-2 rounded-md border p-2">
+          <div className="grid grid-cols-2 gap-1">
+            {(["github", "archive", "local", "upload"] as 
SkillSourceKind[]).map(option => {
+              const Icon = kindIcons[option];
+              return (
+                <button
+                  key={option}
+                  type="button"
+                  onClick={() => setKind(option)}
+                  className={cn(
+                    "flex items-center gap-1.5 rounded-md border px-2 py-1.5 
text-xs transition-colors",
+                    kind === option ? "border-primary bg-primary/10" : 
"hover:bg-accent",
+                  )}
+                >
+                  <Icon className="size-3.5 shrink-0" />
+                  <span className="truncate">{sourceKindLabel(option)}</span>
+                </button>
+              );
+            })}
+          </div>
+
+          {kind === "upload" ? (
+            <>
+              <input
+                ref={file}
+                type="file"
+                accept=".zip,.tgz,.gz,.tar.gz"
+                className="hidden"
+                onChange={event => {
+                  const picked = event.target.files?.[0];
+                  if (picked) {
+                    uploadArchive(picked);
+                  }
+                }}
+              />
+              <Button variant="outline" size="sm" loading={busy} onClick={() 
=> file.current?.click()}>
+                <Upload className="size-4" />
+                {i18next.t("agentConfig:Choose a .zip")}
+              </Button>
+            </>
+          ) : (
+            <>
+              <Input
+                value={url}
+                className="h-8"
+                placeholder={
+                  kind === "github"
+                    ? "anthropics/skills"
+                    : kind === "archive"
+                      ? "https://example.com/skills.zip";
+                      : "/home/me/my-skills"
+                }
+                onChange={event => setUrl(event.target.value)}
+              />
+              {kind === "github" ? (
+                <div className="flex gap-2">
+                  <Input
+                    value={ref}
+                    className="h-8"
+                    placeholder={i18next.t("agentConfig:Branch")}
+                    onChange={event => setRef(event.target.value)}
+                  />
+                  <Input
+                    value={subdir}
+                    className="h-8"
+                    placeholder={i18next.t("agentConfig:Subfolder")}
+                    onChange={event => setSubdir(event.target.value)}
+                  />
+                </div>
+              ) : null}
+              <Input
+                value={name}
+                className="h-8"
+                placeholder={i18next.t("agentConfig:Display name, optional")}
+                onChange={event => setName(event.target.value)}
+              />
+              <Button size="sm" loading={busy} disabled={!ready} onClick={add}>
+                {i18next.t("agentConfig:Add source")}
+              </Button>
+            </>
+          )}
+
+          <p className="text-muted-foreground text-xs">
+            {kind === "github"
+              ? i18next.t("agentConfig:GitHub source hint")
+              : kind === "local"
+                ? i18next.t("agentConfig:Local source hint")
+                : kind === "upload"
+                  ? i18next.t("agentConfig:Upload source hint")
+                  : i18next.t("agentConfig:Archive source hint")}
+          </p>
+        </div>
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 6e4cd8f..79900c1 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -177,16 +177,25 @@
   },
   "agentConfig": {
     "1 file": "1 file",
+    "1 skill in this source": "1 skill in this source",
     "Add MCP server": "Add MCP server",
     "Add MCP server hint": "Gateway writes the server into each agent's own 
configuration file, in that agent's own format. Restart a session that is 
already running to pick it up.",
+    "Add source": "Add source",
     "Add to": "Add to",
     "Add to hint": "Every agent picked here gets the same server.",
     "Added to 1 agent": "Added to 1 agent",
     "Added to {done} agents": "Added to {done} agents",
     "Added to {done}, {failed} failed": "Added to {done} agents, {failed} 
failed",
+    "Already installed": "Already installed",
+    "Already installed in": "Already installed in {agents}",
+    "Archive URL": "Archive URL",
+    "Archive source hint": "A .zip or .tar.gz of skill folders.",
     "Arguments": "Arguments",
+    "Branch": "Branch, optional",
+    "Built in": "Built in",
     "Bundled files": "Bundled files",
     "Changed": "Changed",
+    "Choose a .zip": "Choose a .zip",
     "Command": "Command",
     "Command hint": "The executable the agent spawns, e.g. npx or uvx.",
     "Contents": "Contents",
@@ -198,6 +207,8 @@
     "Copy 1 MCP server": "Copy 1 MCP server",
     "Copy 1 item": "Copy 1 item",
     "Copy 1 skill": "Copy 1 skill",
+    "Copy into the agent": "Copy into the agent",
+    "Copy mode hint": "The agent gets its own copy. Fetching the source again 
offers an update rather than changing it underneath.",
     "Copy these instructions": "Copy these instructions",
     "Copy to other agents": "Copy to other agents",
     "Copy {count} MCP servers": "Copy {count} MCP servers",
@@ -214,6 +225,7 @@
     "Delete undo hint": "It goes to Gateway's recycle bin first, so this can 
be undone.",
     "Deleted": "Deleted",
     "Different version here": "A different version is here",
+    "Display name, optional": "Display name, optional",
     "Diverged detail": "The source has changed and so has this copy. Updating 
replaces what was changed here, and puts it in the recycle bin.",
     "Edited here": "Edited here",
     "Edited here detail": "This copy differs from the source it came from, and 
that source has not changed since.",
@@ -223,23 +235,47 @@
     "Environment": "Environment variables",
     "Failed": "Failed",
     "Failed to add this MCP server": "Failed to add this MCP server",
+    "Failed to add this source": "This source could not be added",
     "Failed to copy": "Failed to copy",
     "Failed to delete": "Failed to delete",
+    "Failed to install": "The skills could not be installed",
     "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 the recycle bin": "Failed to read the recycle bin",
+    "Failed to read the skill sources": "The skill sources could not be read",
     "Failed to read this item": "Failed to read this item",
+    "Failed to read this source": "This source could not be read",
+    "Failed to remove this source": "This source could not be removed",
     "Failed to restore": "Failed to restore",
     "Failed to save these instructions": "Failed to save these instructions",
     "Failed to update": "Failed to update",
+    "Fetch again": "Fetch again",
     "From": "From",
     "From a plugin detail": "Shipped by the {plugin} plugin. Uninstall the 
plugin to remove it.",
     "From a project detail": "Kept in the project at {project}, not in the 
agent's own skills directory.",
     "Gateway does not know where this agent keeps these": "Gateway does not 
know where this agent keeps these",
+    "GitHub repository": "GitHub repository",
+    "GitHub source hint": "owner/repo, or the address of a repository, a 
branch or a folder inside one.",
     "HTTP endpoint": "HTTP endpoint",
     "Headers": "Headers",
+    "How to install": "How to install",
+    "Install 1 skill": "Install 1 skill",
+    "Install into": "Install into",
+    "Install into hint": "Every agent picked here gets the same skills, each 
in its own skills directory.",
+    "Install skills": "Install skills",
+    "Install skills hint": "Pick a source, pick what it holds, and put it into 
the agents you name. Anything with a SKILL.md in it can be a source.",
+    "Install {count} skills": "Install {count} skills",
+    "Installed 1 skill": "Installed 1 skill",
+    "Installed {done} skills": "Installed {done} skills",
+    "Installed {done}, {failed} failed": "Installed {done}, {failed} failed",
     "Instructions saved": "Instructions saved",
+    "Link mode hint": "The agent points at Gateway's copy of the source, so 
every agent linked to it moves together when the source is fetched again.",
+    "Link to Gateway's copy": "Link to Gateway's copy",
+    "Linked": "Linked",
+    "Linked detail": "A link to {target}, not a copy. It follows whatever that 
folder holds.",
     "Local command": "Local command (stdio)",
+    "Local folder": "Local folder",
+    "Local source hint": "A folder on this machine holding skill folders.",
     "MCP": "MCP",
     "MCP servers": "MCP servers",
     "Managed by Gateway": "Gateway",
@@ -254,6 +290,7 @@
     "No instructions yet": "This agent has no instructions yet",
     "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",
+    "No sources yet": "No sources yet. Add a GitHub repository, an archive or 
a folder to install from.",
     "Not installed": "config only",
     "Not installed detail": "Gateway found this configuration on disk but did 
not detect an installation of the agent.",
     "Not written yet": "Not written yet",
@@ -274,6 +311,9 @@
     "Read from": "Read from",
     "Recycle bin": "Recycle bin",
     "Recycle bin hint": "What deleting removed, kept for 30 days. Restoring 
puts it back where it was.",
+    "Remove source": "Remove source",
+    "Remove source description": "Gateway drops its copy of this source. 
Skills already installed from it stay where they are.",
+    "Remove this source?": "Remove this source?",
     "Replace items that already exist": "Replace items that already exist",
     "Replaced": "Replaced",
     "Restore": "Restore",
@@ -290,7 +330,12 @@
     "Skills, MCP & Prompts": "Skills, MCP & Prompts",
     "Skipped": "Skipped",
     "Source": "Source",
+    "Source added": "Source added",
+    "Source removed": "Source removed",
+    "Sources": "Sources",
+    "Subfolder": "Subfolder, optional",
     "This file is read-only for Gateway": "Gateway does not write this agent's 
configuration file",
+    "This source holds no skills": "Nothing in this source has a SKILL.md in 
it.",
     "Transport": "Transport",
     "URL": "URL",
     "Up to date": "Up to date",
@@ -302,10 +347,14 @@
     "Update this skill?": "Update this skill?",
     "Update undo hint": "The version it replaces goes to the recycle bin, so 
this can be undone.",
     "Updated": "Updated",
+    "Upload source hint": "A .zip or .tar.gz of skill folders, from this 
computer.",
+    "Uploaded archive": "Uploaded archive",
     "Version": "Version",
     "Version detail": "A digest of the skill's files. Two agents holding the 
same digest hold the same version.",
     "View": "View",
     "and {count} more folders": "and {count} more folders",
+    "{count} skills in this source": "{count} skills in this source",
+    "{count} skills, fetched {when}": "{count} skills, fetched {when}",
     "{files} files": "{files} files"
   },
   "general": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index c747fa0..493a85f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -177,16 +177,25 @@
   },
   "agentConfig": {
     "1 file": "1 个文件",
+    "1 skill in this source": "该来源共 1 个技能",
     "Add MCP server": "添加 MCP 服务器",
     "Add MCP server hint": "Gateway 会按每个 Agent 
自己的格式,把这个服务器写进它自己的配置文件。已经在运行的会话需要重启才会读到。",
+    "Add source": "添加来源",
     "Add to": "添加到",
     "Add to hint": "这里选中的每个 Agent 都会得到同一个服务器。",
     "Added to 1 agent": "已添加到 1 个 Agent",
     "Added to {done} agents": "已添加到 {done} 个 Agent",
     "Added to {done}, {failed} failed": "已添加到 {done} 个 Agent,{failed} 个失败",
+    "Already installed": "已安装",
+    "Already installed in": "已安装在:{agents}",
+    "Archive URL": "压缩包链接",
+    "Archive source hint": "包含技能目录的 .zip 或 .tar.gz。",
     "Arguments": "参数",
+    "Branch": "分支,可选",
+    "Built in": "内置",
     "Bundled files": "附带文件",
     "Changed": "修改于",
+    "Choose a .zip": "选择 .zip 文件",
     "Command": "命令",
     "Command hint": "Agent 要启动的可执行文件,例如 npx 或 uvx。",
     "Contents": "内容",
@@ -198,6 +207,8 @@
     "Copy 1 MCP server": "复制 1 个 MCP 服务器",
     "Copy 1 item": "复制 1 项",
     "Copy 1 skill": "复制 1 个技能",
+    "Copy into the agent": "复制到 agent 目录",
+    "Copy mode hint": "agent 拥有独立副本。重新拉取来源时会提示更新,而不会直接改动它。",
     "Copy these instructions": "复制这份提示词",
     "Copy to other agents": "复制到其他 Agent",
     "Copy {count} MCP servers": "复制 {count} 个 MCP 服务器",
@@ -214,6 +225,7 @@
     "Delete undo hint": "会先移入网关的回收站,可以随时撤销。",
     "Deleted": "已删除",
     "Different version here": "这里是另一个版本",
+    "Display name, optional": "显示名称,可选",
     "Diverged detail": "来源已更新,本地也改过。更新会覆盖本地修改,被覆盖的版本会放进回收站。",
     "Edited here": "本地已修改",
     "Edited here detail": "这份副本与来源不一致,而来源自复制以来没有变化。",
@@ -223,23 +235,47 @@
     "Environment": "环境变量",
     "Failed": "失败",
     "Failed to add this MCP server": "添加 MCP 服务器失败",
+    "Failed to add this source": "无法添加该来源",
     "Failed to copy": "复制失败",
     "Failed to delete": "删除失败",
+    "Failed to install": "技能安装失败",
     "Failed to plan this copy": "无法预览这次复制的结果",
     "Failed to read agent configuration": "读取 Agent 配置失败",
     "Failed to read the recycle bin": "读取回收站失败",
+    "Failed to read the skill sources": "无法读取技能来源列表",
     "Failed to read this item": "读取该项失败",
+    "Failed to read this source": "无法读取该来源",
+    "Failed to remove this source": "无法移除该来源",
     "Failed to restore": "恢复失败",
     "Failed to save these instructions": "保存提示词失败",
     "Failed to update": "更新失败",
+    "Fetch again": "重新拉取",
     "From": "从",
     "From a plugin detail": "由插件 {plugin} 提供,卸载该插件才能移除。",
     "From a project detail": "保存在项目 {project} 中,不在智能体自己的 skills 目录里。",
     "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 
把这些配置放在哪里",
+    "GitHub repository": "GitHub 仓库",
+    "GitHub source hint": "填写 owner/repo,或仓库、分支、子目录的网址。",
     "HTTP endpoint": "HTTP 端点",
     "Headers": "请求头",
+    "How to install": "安装方式",
+    "Install 1 skill": "安装 1 个技能",
+    "Install into": "安装到",
+    "Install into hint": "这里选中的每个 agent 都会安装同一批技能,各自写入自己的 skills 目录。",
+    "Install skills": "安装技能",
+    "Install skills hint": "选择来源,挑选其中的技能,安装到指定的 agent。任何含有 SKILL.md 
的目录都可以作为来源。",
+    "Install {count} skills": "安装 {count} 个技能",
+    "Installed 1 skill": "已安装 1 个技能",
+    "Installed {done} skills": "已安装 {done} 个技能",
+    "Installed {done}, {failed} failed": "已安装 {done} 个,{failed} 个失败",
     "Instructions saved": "提示词已保存",
+    "Link mode hint": "agent 通过符号链接指向 Gateway 的副本,重新拉取来源后所有链接的 agent 一起更新。",
+    "Link to Gateway's copy": "链接到 Gateway 的副本",
+    "Linked": "链接",
+    "Linked detail": "这是指向 {target} 的符号链接,不是副本,内容随目标目录变化。",
     "Local command": "本地命令(stdio)",
+    "Local folder": "本地目录",
+    "Local source hint": "本机上包含技能目录的文件夹。",
     "MCP": "MCP",
     "MCP servers": "MCP 服务器",
     "Managed by Gateway": "Gateway",
@@ -254,6 +290,7 @@
     "No instructions yet": "这个 Agent 还没有提示词",
     "No other agent on this machine can receive these": "本机没有其他 Agent 
可以接收这些内容。",
     "No skills yet": "该 Agent 还没有技能",
+    "No sources yet": "还没有来源。添加一个 GitHub 仓库、压缩包或本地目录。",
     "Not installed": "仅配置",
     "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
     "Not written yet": "尚未写入",
@@ -274,6 +311,9 @@
     "Read from": "读取自",
     "Recycle bin": "回收站",
     "Recycle bin hint": "删除的内容会在这里保留 30 天,恢复后回到原来的位置。",
+    "Remove source": "移除来源",
+    "Remove source description": "Gateway 会删除本地缓存的副本。已经安装的技能会原样保留。",
+    "Remove this source?": "移除该来源?",
     "Replace items that already exist": "覆盖已存在的同名项",
     "Replaced": "覆盖",
     "Restore": "恢复",
@@ -290,7 +330,12 @@
     "Skills, MCP & Prompts": "技能、MCP 与提示词",
     "Skipped": "跳过",
     "Source": "来源",
+    "Source added": "来源已添加",
+    "Source removed": "来源已移除",
+    "Sources": "来源",
+    "Subfolder": "子目录,可选",
     "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
+    "This source holds no skills": "该来源中没有包含 SKILL.md 的目录。",
     "Transport": "连接方式",
     "URL": "URL",
     "Up to date": "已是最新",
@@ -302,10 +347,14 @@
     "Update this skill?": "更新该技能?",
     "Update undo hint": "被替换的版本会放进回收站,可以撤销。",
     "Updated": "已更新",
+    "Upload source hint": "从本机上传包含技能目录的 .zip 或 .tar.gz。",
+    "Uploaded archive": "上传的压缩包",
     "Version": "版本",
     "Version detail": "技能文件内容的摘要。两个智能体的摘要相同,说明版本相同。",
     "View": "查看",
     "and {count} more folders": "另有 {count} 个目录",
+    "{count} skills in this source": "该来源共 {count} 个技能",
+    "{count} skills, fetched {when}": "{count} 个技能,拉取于 {when}",
     "{files} files": "{files} 个文件"
   },
   "general": {
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
index 1990b45..155e3ee 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -17,7 +17,9 @@ import {
   AlertTriangle,
   ArrowDownToLine,
   Check,
+  Download,
   Eye,
+  Link2,
   Minus,
   Package,
   Pencil,
@@ -34,6 +36,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {AddMcpDialog} from "@/components/agent-config/add-mcp-dialog";
 import {CopyDialog} from "@/components/agent-config/copy-dialog";
 import {DetailDialog, type DetailTarget} from 
"@/components/agent-config/detail-dialog";
+import {InstallSkillDialog} from 
"@/components/agent-config/install-skill-dialog";
 import {PromptDialog, type PromptTarget} from 
"@/components/agent-config/prompt-dialog";
 import {TrashDialog} from "@/components/agent-config/trash-dialog";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -162,6 +165,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   const [editing, setEditing] = React.useState<PromptTarget | null>(null);
   const [copyOpen, setCopyOpen] = React.useState(false);
   const [addOpen, setAddOpen] = React.useState(false);
+  const [installOpen, setInstallOpen] = React.useState(false);
   const [trashOpen, setTrashOpen] = React.useState(false);
   const [deleting, setDeleting] = React.useState("");
   const [updating, setUpdating] = React.useState("");
@@ -293,6 +297,16 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
                   <Badge variant="warning">{i18next.t("agentConfig:Out of 
date")}</Badge>
                 </SimpleTooltip>
               ) : null}
+              {record.link ? (
+                <SimpleTooltip
+                  title={i18next.t("agentConfig:Linked 
detail").replace("{target}", record.link)}
+                >
+                  <Badge variant="info">
+                    <Link2 className="size-3" />
+                    {i18next.t("agentConfig:Linked")}
+                  </Badge>
+                </SimpleTooltip>
+              ) : null}
               {record.managed ? (
                 <SimpleTooltip title={i18next.t("agentConfig:Managed by 
Gateway detail")}>
                   <Badge variant="info">{i18next.t("agentConfig:Managed by 
Gateway")}</Badge>
@@ -473,6 +487,12 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         description={i18next.t("agentConfig:Page description")}
         actions={
           <>
+            {kind === "skill" && inventories.length > 0 ? (
+              <Button onClick={() => setInstallOpen(true)}>
+                <Download className="size-4" />
+                {i18next.t("agentConfig:Install skills")}
+              </Button>
+            ) : null}
             {kind === "mcp" && inventories.length > 0 ? (
               <Button onClick={() => setAddOpen(true)}>
                 <Plus className="size-4" />
@@ -604,6 +624,16 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         </>
       ) : null}
 
+      {source ? (
+        <InstallSkillDialog
+          open={installOpen}
+          onOpenChange={setInstallOpen}
+          inventories={inventories}
+          source={source}
+          onDone={refresh}
+        />
+      ) : null}
+
       {source ? (
         <AddMcpDialog
           open={addOpen}
diff --git a/web/src/types.ts b/web/src/types.ts
index 9097830..9e530a1 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -557,6 +557,11 @@ export interface AgentConfigItem {
   url?: string;
   files?: number;
   bytes?: number;
+  /**
+   * What a skill installed as a symbolic link points at. A linked skill 
follows
+   * its source; a copied one is the agent's own from the moment it lands.
+   */
+  link?: string;
   /**
    * "user" for a skill the operator wrote, "plugin" for one a plugin ships,
    * "project" for one that belongs to a checkout the agent works in.
@@ -649,6 +654,53 @@ export interface AgentConfigTrashEntry {
   deletedAt: number;
 }
 
+/** Where a skill source's content comes from. */
+export type SkillSourceKind = "github" | "archive" | "upload" | "local";
+
+/** One place skills can be installed from. */
+export interface SkillSource {
+  id: string;
+  name: string;
+  kind: SkillSourceKind;
+  /** owner/repo for a repository, the download URL for an archive, the folder 
for a local source. */
+  url?: string;
+  /** The branch, tag or commit of a repository; empty follows its default 
branch. */
+  ref?: string;
+  /** The one folder of the source to look in, for a repository that groups 
its skills. */
+  subdir?: string;
+  /** Seeded by Gateway rather than added here. It can still be removed. */
+  builtin?: boolean;
+  addedAt?: number;
+  /** When Gateway last took a copy, and how many skills it held then. */
+  fetchedAt?: number;
+  skills?: number;
+}
+
+/** One skill of a source, before it is installed anywhere. */
+export interface SkillCatalogItem {
+  /** The folder it would be installed under, relative to the source. */
+  name: string;
+  path: string;
+  description?: string;
+  files?: number;
+  bytes?: number;
+  digest?: string;
+  modified?: number;
+}
+
+export interface SkillCatalog {
+  source: SkillSource;
+  /** The folder the skills below were read from. */
+  root: string;
+  skills: SkillCatalogItem[];
+}
+
+/**
+ * How an installed skill relates to its source: a copy the agent then owns, or
+ * a link that follows Gateway's copy of the source whenever it is fetched 
again.
+ */
+export type SkillInstallMode = "copy" | "link";
+
 /** How an MCP server is reached: a spawned command, or an HTTP endpoint. */
 export type McpTransport = "stdio" | "http";
 


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

Reply via email to