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]