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 482c960 feat(skill): find skills on disk that no source accounts for
482c960 is described below
commit 482c960d4e0baf997e428f4d4e65d733efbb5713
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 11:29:38 2026 +0800
feat(skill): find skills on disk that no source accounts for
---
agentconfig/adopt.go | 243 ++++++++++++++++++
controllers/agent_config.go | 16 +-
controllers/agent_config_skill.go | 41 +++
routers/router.go | 2 +
web/src/backend/AgentConfigBackend.ts | 19 ++
.../agent-config/install-skill-dialog.tsx | 8 +-
.../agent-config/unmanaged-skills-dialog.tsx | 278 +++++++++++++++++++++
web/src/lib/agent-configs.ts | 6 +
web/src/locales/en/data.json | 17 ++
web/src/locales/zh/data.json | 17 ++
web/src/pages/AgentConfigsPage.tsx | 16 ++
web/src/types.ts | 27 ++
12 files changed, 678 insertions(+), 12 deletions(-)
diff --git a/agentconfig/adopt.go b/agentconfig/adopt.go
new file mode 100644
index 0000000..26e5660
--- /dev/null
+++ b/agentconfig/adopt.go
@@ -0,0 +1,243 @@
+// 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"
+ "path/filepath"
+)
+
+// UnmanagedSkill is one skill an agent reads that Gateway has no record of:
+// installed by hand, by another tool, or through a Gateway whose records are
+// gone. It works either way, but nothing says which version of it this is, so
+// the update badge stays blank and Update has nothing to copy from.
+type UnmanagedSkill struct {
+ AgentId string `json:"agentId"`
+ Owner string `json:"owner"`
+ 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"`
+
+ // Match is the source skill this one was recognized as, when a source
on
+ // this machine holds it. Adopting it is what records the two as one.
+ Match *SkillMatch `json:"match,omitempty"`
+}
+
+// SkillMatch is where an unmanaged skill appears to have come from.
+type SkillMatch struct {
+ SourceId string `json:"sourceId"`
+ SourceName string `json:"sourceName"`
+ // Skill is the name inside the source, and Path the folder it is in.
+ Skill string `json:"skill"`
+ Path string `json:"path"`
+
+ // Same marks a source holding exactly this content. False is the same
skill
+ // at another version, which adopting turns into an offered update.
+ Same bool `json:"same"`
+ // ByDigest marks a skill recognized by its content after being renamed
here.
+ ByDigest bool `json:"byDigest,omitempty"`
+}
+
+// AdoptRequest records some scanned skills against the sources they came from.
+type AdoptRequest struct {
+ Owner string `json:"owner"`
+ Items []AdoptItem `json:"items"`
+}
+
+// AdoptItem is one skill and the source skill it is to be recorded against.
+type AdoptItem struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+
+ SourceId string `json:"sourceId"`
+ Skill string `json:"skill"`
+}
+
+// storeSkill is one skill of one source, as the store holds it now.
+type storeSkill struct {
+ source *SkillSource
+ skill *CatalogSkill
+}
+
+// ScanUnmanaged lists the skills on this host that Gateway did not install and
+// has no origin record for, each with the source skill it looks like a copy
of.
+// Only what is already downloaded is compared against: a scan reads the store
+// as it stands rather than fetching every source again.
+func ScanUnmanaged(inventories []*Inventory) []*UnmanagedSkill {
+ stores := map[string][]*storeSkill{}
+ found := []*UnmanagedSkill{}
+
+ for _, inventory := range inventories {
+ for _, item := range inventory.Skills {
+ if !untracked(item) {
+ continue
+ }
+
+ skills, ok := stores[inventory.Owner]
+ if !ok {
+ skills = storeSkills(inventory.Owner)
+ stores[inventory.Owner] = skills
+ }
+ found = append(found, &UnmanagedSkill{
+ AgentId: item.AgentId,
+ Owner: item.Owner,
+ Name: item.Name,
+ Path: item.Path,
+ Description: item.Description,
+ Files: item.Files,
+ Bytes: item.Bytes,
+ Digest: item.Digest,
+ Modified: item.Modified,
+ Match: matchStore(item, skills),
+ })
+ }
+ }
+ return found
+}
+
+// untracked is a skill the operator keeps that says nothing about where it
came
+// from. A plugin's or a checkout's skills are left out: those belong to the
+// thing that ships them, and Gateway does not update them either way.
+func untracked(item *Item) bool {
+ return item.Update == nil && item.Scope == ScopeUser && !item.Managed
&& !item.Missing
+}
+
+// matchStore finds the source skill an unmanaged one came from. Identical
+// content is the answer wherever it is found, because a skill renamed here is
+// still that skill; failing that, a source holding the same name is the copy
it
+// has moved on from.
+func matchStore(item *Item, skills []*storeSkill) *SkillMatch {
+ var named *SkillMatch
+ for _, held := range skills {
+ if item.Digest != "" && held.skill.Digest == item.Digest {
+ return newMatch(held, true, targetName(KindSkill,
held.skill.Name) != targetName(KindSkill, item.Name))
+ }
+ if named == nil && targetName(KindSkill, held.skill.Name) ==
targetName(KindSkill, item.Name) {
+ named = newMatch(held, false, false)
+ }
+ }
+ return named
+}
+
+func newMatch(held *storeSkill, same bool, byDigest bool) *SkillMatch {
+ return &SkillMatch{
+ SourceId: held.source.Id,
+ SourceName: held.source.Name,
+ Skill: held.skill.Name,
+ Path: held.skill.Path,
+ Same: same,
+ ByDigest: byDigest,
+ }
+}
+
+// storeSkills is every skill of every source that is on this machine already.
+// A source that has never been fetched is skipped rather than downloaded: a
+// scan of what is installed should not reach the network.
+func storeSkills(owner string) []*storeSkill {
+ home, err := homeOf(owner)
+ if err != nil {
+ return nil
+ }
+ sources, err := ListSources(owner)
+ if err != nil {
+ return nil
+ }
+
+ skills := []*storeSkill{}
+ for _, source := range sources {
+ root, err := catalogRoot(home, source)
+ if err != nil {
+ continue
+ }
+ for _, relative := range findSkillFolders(root) {
+ skills = append(skills, &storeSkill{source: source,
skill: readCatalogSkill(root, relative)})
+ }
+ }
+ return skills
+}
+
+// AdoptSkills records each scanned skill as a copy of the source skill it was
+// matched to, which is the same record an install writes. From then on the
+// listing can say whether it is current, and Update has somewhere to copy
from.
+func AdoptSkills(request AdoptRequest) ([]*PlanItem, error) {
+ if len(request.Items) == 0 {
+ return nil, errors.New("no skill was picked to import")
+ }
+ home, err := homeOf(request.Owner)
+ if err != nil {
+ return nil, err
+ }
+ sources, err := ListSources(request.Owner)
+ if err != nil {
+ return nil, err
+ }
+
+ held := map[string]*SkillSource{}
+ for _, source := range sources {
+ held[source.Id] = source
+ }
+
+ planned := []*PlanItem{}
+ for _, adopt := range request.Items {
+ plan := &PlanItem{AgentId: adopt.AgentId, Name: adopt.Name}
+ planned = append(planned, plan)
+
+ item, err := findItem(adopt.AgentId, request.Owner, KindSkill,
adopt.Name)
+ if err != nil {
+ plan.Action, plan.Reason = ActionSkip, err.Error()
+ continue
+ }
+ path, err := sourceSkillPath(home, held[adopt.SourceId],
adopt.Skill)
+ if err != nil {
+ plan.Action, plan.Reason = ActionSkip, err.Error()
+ continue
+ }
+
+ plan.Path = item.Path
+ plan.Action = ActionCreate
+ recordSkillOrigin(home, item.Path, adopt.SourceId,
sourceItemName(held[adopt.SourceId], &CatalogSkill{Name: adopt.Skill}), path)
+ }
+ return planned, nil
+}
+
+// sourceSkillPath is where one named skill of a source is on this machine, and
+// an error when the source or the skill is no longer there.
+func sourceSkillPath(home string, source *SkillSource, skill string) (string,
error) {
+ if source == nil {
+ return "", errors.New("that skill source is no longer on the
list")
+ }
+ if skill == "" {
+ return "", errors.New("no skill of that source was chosen")
+ }
+
+ root, err := catalogRoot(home, source)
+ if err != nil {
+ return "", err
+ }
+ path, err := safeJoin(root, skill)
+ if err != nil {
+ return "", err
+ }
+ if _, ok := manifestPath(path); !ok {
+ return "", fmt.Errorf("%s no longer holds %s", source.Name,
filepath.Base(skill))
+ }
+ return path, nil
+}
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
index af6cccd..880cca8 100644
--- a/controllers/agent_config.go
+++ b/controllers/agent_config.go
@@ -41,14 +41,20 @@ type agentConfigView struct {
// GetAgentConfigs lists the skills, MCP servers and instructions of every
agent
// on this host whose configuration layout Gateway knows.
func (c *ApiController) GetAgentConfigs() {
- if c.RequireAdmin() {
+ views, err := agentConfigViews(c.GetString("refresh") == "true")
+ if err != nil {
+ c.ResponseError(err.Error())
return
}
+ c.ResponseOk(views)
+}
- installations, err := agent.Scan(c.GetString("refresh") == "true")
+// agentConfigViews reads the configuration of every agent on this host, one
+// view per location rather than per agent id.
+func agentConfigViews(refresh bool) ([]*agentConfigView, error) {
+ installations, err := agent.Scan(refresh)
if err != nil {
- c.ResponseError(err.Error())
- return
+ return nil, err
}
collected := &agentConfigs{seen: map[string]*agentConfigView{}}
@@ -69,7 +75,7 @@ func (c *ApiController) GetAgentConfigs() {
}
sort.Slice(collected.views, func(i, j int) bool { return
collected.views[i].Name < collected.views[j].Name })
- c.ResponseOk(collected.views)
+ return collected.views, nil
}
// agentConfigs collects one view per configuration location. Several agent ids
diff --git a/controllers/agent_config_skill.go
b/controllers/agent_config_skill.go
index a0292e0..a092605 100644
--- a/controllers/agent_config_skill.go
+++ b/controllers/agent_config_skill.go
@@ -170,3 +170,44 @@ func (c *ApiController) InstallSkills() {
}
c.ResponseOk(result)
}
+
+// GetUnmanagedSkills lists the skills on this host that Gateway did not
install
+// and has no record of, each with the source skill it looks like a copy of.
+func (c *ApiController) GetUnmanagedSkills() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ views, err := agentConfigViews(false)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ inventories := make([]*agentconfig.Inventory, 0, len(views))
+ for _, view := range views {
+ inventories = append(inventories, view.Inventory)
+ }
+ c.ResponseOk(agentconfig.ScanUnmanaged(inventories))
+}
+
+// AdoptSkills records scanned skills against the sources they came from, so a
+// skill installed by hand is tracked from then on like an installed one.
+func (c *ApiController) AdoptSkills() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request agentconfig.AdoptRequest
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+
+ result, err := agentconfig.AdoptSkills(request)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(result)
+}
diff --git a/routers/router.go b/routers/router.go
index 60b0e92..5fa4b5c 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -104,6 +104,8 @@ func initAPI() {
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")
+ beego.Router("/api/get-unmanaged-skills", &controllers.ApiController{},
"GET:GetUnmanagedSkills")
+ beego.Router("/api/adopt-skills", &controllers.ApiController{},
"POST:AdoptSkills")
// 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 4e2a221..4683773 100644
--- a/web/src/backend/AgentConfigBackend.ts
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -25,6 +25,7 @@ import type {
SkillInstallMode,
SkillSource,
SkillSourceKind,
+ UnmanagedSkill,
} from "@/types";
export interface CopyRequest {
@@ -172,3 +173,21 @@ export function getSkillCatalog(owner: string, id: string,
refresh = false) {
export function installSkills(body: InstallSkillsRequest) {
return request<AgentConfigPlanItem[]>("/api/install-skills", "POST", body);
}
+
+/** One scanned skill, and the source skill it is to be recorded against. */
+export interface AdoptSkillItem {
+ agentId: string;
+ name: string;
+ sourceId: string;
+ skill: string;
+}
+
+/** The skills on this host that Gateway did not install and has no record of.
*/
+export function getUnmanagedSkills() {
+ return request<UnmanagedSkill[]>("/api/get-unmanaged-skills");
+}
+
+/** Records scanned skills against their sources, so they are tracked from now
on. */
+export function adoptSkills(owner: string, items: AdoptSkillItem[]) {
+ return request<AgentConfigPlanItem[]>("/api/adopt-skills", "POST", {owner:
owner, items: items});
+}
diff --git a/web/src/components/agent-config/install-skill-dialog.tsx
b/web/src/components/agent-config/install-skill-dialog.tsx
index 0593449..a1705fc 100644
--- a/web/src/components/agent-config/install-skill-dialog.tsx
+++ b/web/src/components/agent-config/install-skill-dialog.tsx
@@ -37,7 +37,7 @@ import {
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 {counted, folderOf, formatBytes, sharedName} from "@/lib/agent-configs";
import {cn} from "@/lib/utils";
import type {
AgentConfigInventory,
@@ -47,12 +47,6 @@ import type {
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;
diff --git a/web/src/components/agent-config/unmanaged-skills-dialog.tsx
b/web/src/components/agent-config/unmanaged-skills-dialog.tsx
new file mode 100644
index 0000000..68d709b
--- /dev/null
+++ b/web/src/components/agent-config/unmanaged-skills-dialog.tsx
@@ -0,0 +1,278 @@
+// 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 {ShieldCheck} 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 {EmptyState} from "@/components/shared/empty-state";
+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 {counted, folderOf, formatBytes} from "@/lib/agent-configs";
+import type {AgentConfigPlanItem, UnmanagedSkill} from "@/types";
+
+/** The key one scanned skill is selected by: it names one folder on one
agent. */
+function keyOf(skill: UnmanagedSkill) {
+ return `${skill.agentId}/${skill.name}`;
+}
+
+/**
+ * The skills on this machine that Gateway did not install: written by hand,
+ * installed by another tool, or left over from a Gateway whose records are
+ * gone. They work as they are, which is why nothing else on the page points
+ * them out - what they lack is a source, so the listing cannot say whether one
+ * is the current version and Update has nothing to copy from.
+ *
+ * Importing one records it against the source it was recognized in, which is
+ * the same record an install writes. Nothing on disk is touched.
+ */
+export function UnmanagedSkillsDialog({
+ open,
+ onOpenChange,
+ agentNames,
+ onDone,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ agentNames: Map<string, string>;
+ onDone: () => void;
+}) {
+ const [skills, setSkills] = React.useState<UnmanagedSkill[]>([]);
+ const [selected, setSelected] = React.useState<string[]>([]);
+ const [result, setResult] = React.useState<AgentConfigPlanItem[] |
null>(null);
+ const [loading, setLoading] = React.useState(false);
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState("");
+
+ React.useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ let current = true;
+ setSkills([]);
+ setSelected([]);
+ setResult(null);
+ setError("");
+ setLoading(true);
+ AgentConfigBackend.getUnmanagedSkills()
+ .then(res => {
+ if (!current) {
+ return;
+ }
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("agentConfig:Failed to scan for
untracked skills"));
+ return;
+ }
+ const found = res.data ?? [];
+ setSkills(found);
+ // A skill holding exactly what a source holds is the safe half of the
+ // scan, so those are ticked and the rest are left to be read first.
+ setSelected(found.filter(skill => skill.match?.same).map(keyOf));
+ })
+ .catch(err => current && setError(err.message || String(err)))
+ .then(() => current && setLoading(false));
+
+ return () => {
+ current = false;
+ };
+ }, [open]);
+
+ const toggle = (skill: UnmanagedSkill) => {
+ const key = keyOf(skill);
+ setSelected(previous =>
+ previous.includes(key) ? previous.filter(item => item !== key) :
[...previous, key],
+ );
+ };
+
+ const nameOf = (agentId: string) => agentNames.get(agentId) ?? agentId;
+
+ const matched = skills.filter(skill => skill.match !== undefined);
+ const unmatched = skills.filter(skill => skill.match === undefined);
+
+ const adopt = () => {
+ const picked = skills.filter(skill => skill.match &&
selected.includes(keyOf(skill)));
+ if (picked.length === 0) {
+ return;
+ }
+
+ setBusy(true);
+ setError("");
+ AgentConfigBackend.adoptSkills(
+ picked[0].owner,
+ picked.map(skill => ({
+ agentId: skill.agentId,
+ name: skill.name,
+ sourceId: skill.match?.sourceId ?? "",
+ skill: skill.match?.skill ?? "",
+ })),
+ )
+ .then(res => {
+ if (res.status !== "ok") {
+ setError(res.msg || i18next.t("agentConfig:Failed to import these
skills"));
+ return;
+ }
+
+ const written = res.data ?? [];
+ const done = written.filter(item => item.action === "create").length;
+ setResult(written);
+ Setting.showMessage(
+ done === 0 ? "error" : "success",
+ counted(done, "agentConfig:Imported 1 skill", "agentConfig:Imported
{done} skills", "{done}"),
+ );
+ onDone();
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setBusy(false));
+ };
+
+ const row = (skill: UnmanagedSkill) => {
+ const match = skill.match;
+ const key = keyOf(skill);
+ return (
+ <li key={key} className="flex items-start justify-between gap-3 px-3
py-2 text-sm">
+ <label className="flex min-w-0 items-start gap-2">
+ <input
+ type="checkbox"
+ className="accent-primary mt-1 size-4 shrink-0"
+ checked={selected.includes(key)}
+ disabled={match === undefined || result !== null}
+ onChange={() => toggle(skill)}
+ />
+ <span className="min-w-0">
+ <span className="flex flex-wrap items-center gap-2">
+ <span className="truncate
font-medium">{folderOf(skill.name)}</span>
+ <Badge variant="muted">{nameOf(skill.agentId)}</Badge>
+ {skill.bytes ? (
+ <span className="text-muted-foreground
text-xs">{formatBytes(skill.bytes)}</span>
+ ) : null}
+ </span>
+ <span className="text-muted-foreground block text-xs">
+ {match === undefined
+ ? i18next.t("agentConfig:No source holds this skill")
+ : `${match.sourceName} · ${match.skill}`}
+ </span>
+ </span>
+ </label>
+
+ {match === undefined ? null : (
+ <Badge variant="muted" className="shrink-0">
+ {match.same
+ ? i18next.t("agentConfig:Same content")
+ : i18next.t("agentConfig:Another version")}
+ </Badge>
+ )}
+ </li>
+ );
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="flex max-h-[85vh] flex-col overflow-hidden
sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>{i18next.t("agentConfig:Untracked
skills")}</DialogTitle>
+ <DialogDescription>{i18next.t("agentConfig:Untracked skills
hint")}</DialogDescription>
+ </DialogHeader>
+
+ <div className="scrollbar-thin -mx-1 flex min-h-0 flex-1 flex-col
gap-4 overflow-y-auto px-1 py-0.5">
+ {error ? <MessageAlert description={error} /> : null}
+
+ {loading ? <Loading /> : null}
+
+ {!loading && skills.length === 0 && error === "" ? (
+ <EmptyState
+ icon={ShieldCheck}
+ title={i18next.t("agentConfig:Every skill is accounted for")}
+ description={i18next.t("agentConfig:Every skill is accounted for
hint")}
+ />
+ ) : null}
+
+ {result !== null ? (
+ <ul className="divide-y 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>
+ ) : (
+ <>
+ {matched.length === 0 ? null : (
+ <div className="grid gap-2">
+ <span className="text-muted-foreground text-xs font-medium
tracking-wide uppercase">
+ {i18next.t("agentConfig:Recognized in a source")}
+ </span>
+ <ul className="divide-y rounded-md
border">{matched.map(row)}</ul>
+ </div>
+ )}
+
+ {unmatched.length === 0 ? null : (
+ <div className="grid gap-2">
+ <span className="text-muted-foreground text-xs font-medium
tracking-wide uppercase">
+ {i18next.t("agentConfig:Not in any source")}
+ </span>
+ <ul className="divide-y rounded-md
border">{unmatched.map(row)}</ul>
+ <p className="text-muted-foreground text-xs">
+ {i18next.t("agentConfig:Not in any source hint")}
+ </p>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ {result === null ? i18next.t("general:Cancel") :
i18next.t("general:Close")}
+ </Button>
+ {result === null && matched.length > 0 ? (
+ <Button onClick={adopt} loading={busy} disabled={busy ||
selected.length === 0}>
+ {selected.length === 0
+ ? i18next.t("agentConfig:Import into Gateway")
+ : counted(
+ selected.length,
+ "agentConfig:Import 1 skill",
+ "agentConfig:Import {done} skills",
+ "{done}",
+ )}
+ </Button>
+ ) : null}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
index a7e4961..f35e6ec 100644
--- a/web/src/lib/agent-configs.ts
+++ b/web/src/lib/agent-configs.ts
@@ -217,6 +217,12 @@ export function counted(count: number, oneKey: string,
manyKey: string, token =
return count === 1 ? i18next.t(oneKey) : i18next.t(manyKey).replace(token,
String(count));
}
+/** The last part of a catalog name, which is the folder a skill lands in. */
+export function folderOf(name: string) {
+ const cut = name.lastIndexOf("/");
+ return cut < 0 ? name : name.slice(cut + 1);
+}
+
export function formatBytes(bytes: number | undefined) {
if (!bytes) {
return "";
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index f448001..f32cea6 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -298,6 +298,7 @@
"Added to {done}, {failed} failed": "Added to {done} agents, {failed}
failed",
"Already installed": "Already installed",
"Already installed in": "Already installed in {agents}",
+ "Another version": "Another version",
"Archive URL": "Archive URL",
"Archive source hint": "A .zip or .tar.gz of skill folders.",
"Arguments": "Arguments",
@@ -347,12 +348,15 @@
"Empty the recycle bin?": "Empty the recycle bin?",
"Endpoint": "Endpoint",
"Environment": "Environment variables",
+ "Every skill is accounted for": "Every skill is accounted for",
+ "Every skill is accounted for hint": "Gateway knows where every skill on
this machine came from.",
"Everyday servers": "Everyday servers",
"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 import these skills": "Failed to import these skills",
"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",
@@ -363,6 +367,7 @@
"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 scan for untracked skills": "Failed to scan for untracked
skills",
"Failed to update": "Failed to update",
"Fetch again": "Fetch again",
"Fill in by hand": "Fill in by hand",
@@ -376,6 +381,11 @@
"HTTP endpoint": "HTTP endpoint",
"Headers": "Headers",
"How to install": "How to install",
+ "Import 1 skill": "Import 1 skill",
+ "Import into Gateway": "Import into Gateway",
+ "Import {done} skills": "Import {done} skills",
+ "Imported 1 skill": "Imported 1 skill",
+ "Imported {done} skills": "Imported {done} skills",
"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.",
@@ -409,7 +419,10 @@
"No other agent on this machine can receive these": "No other agent on
this machine can receive these.",
"No server matches": "No server matches",
"No skills yet": "This agent has no skills yet",
+ "No source holds this skill": "No source holds this skill",
"No sources yet": "No sources yet. Add a GitHub repository, an archive or
a folder to install from.",
+ "Not in any source": "Not in any source",
+ "Not in any source hint": "None of the sources on the list holds these.
Add the source they came from, refresh it, then scan again.",
"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",
@@ -434,6 +447,7 @@
"Prompt placeholder": "Nothing here yet. Anything written here is read by
this agent at the start of every session.",
"Prompts": "Prompts",
"Read from": "Read from",
+ "Recognized in a source": "Recognized in a source",
"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",
@@ -446,6 +460,7 @@
"Restore as a replacement description": "Something is at that path again.
It is moved to the recycle bin in its turn, and this item takes its place.",
"Restore as a replacement?": "Restore as a replacement?",
"Restored": "Restored",
+ "Same content": "Same content",
"Same version": "The same version",
"Search and the web": "Search and the web",
"Search by name": "Search by name",
@@ -469,6 +484,8 @@
"This source holds no skills": "Nothing in this source has a SKILL.md in
it.",
"Transport": "Transport",
"URL": "URL",
+ "Untracked skills": "Untracked skills",
+ "Untracked skills hint": "These skills are installed on an agent but
Gateway did not put them there, and nothing records where they came from.
Importing one tells Gateway which source it belongs to, so the listing can say
whether it is current and Update has somewhere to copy from. Nothing on disk is
touched.",
"Up to date": "Up to date",
"Up to date detail": "This holds exactly what its source holds.",
"Update available": "Update available",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index cf63341..b695a8c 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -298,6 +298,7 @@
"Added to {done}, {failed} failed": "已添加到 {done} 个 Agent,{failed} 个失败",
"Already installed": "已安装",
"Already installed in": "已安装在:{agents}",
+ "Another version": "版本不同",
"Archive URL": "压缩包链接",
"Archive source hint": "包含技能目录的 .zip 或 .tar.gz。",
"Arguments": "参数",
@@ -347,12 +348,15 @@
"Empty the recycle bin?": "清空回收站?",
"Endpoint": "端点",
"Environment": "环境变量",
+ "Every skill is accounted for": "每个 Skill 都有来源",
+ "Every skill is accounted for hint": "这台机器上的每个 Skill,Gateway 都知道它来自哪里。",
"Everyday servers": "常用服务器",
"Failed": "失败",
"Failed to add this MCP server": "添加 MCP 服务器失败",
"Failed to add this source": "无法添加该来源",
"Failed to copy": "复制失败",
"Failed to delete": "删除失败",
+ "Failed to import these skills": "导入这些 Skill 失败",
"Failed to install": "技能安装失败",
"Failed to plan this copy": "无法预览这次复制的结果",
"Failed to read agent configuration": "读取 Agent 配置失败",
@@ -363,6 +367,7 @@
"Failed to remove this source": "无法移除该来源",
"Failed to restore": "恢复失败",
"Failed to save these instructions": "保存提示词失败",
+ "Failed to scan for untracked skills": "扫描未托管的 Skill 失败",
"Failed to update": "更新失败",
"Fetch again": "重新拉取",
"Fill in by hand": "手动填写",
@@ -376,6 +381,11 @@
"HTTP endpoint": "HTTP 端点",
"Headers": "请求头",
"How to install": "安装方式",
+ "Import 1 skill": "导入 1 个 Skill",
+ "Import into Gateway": "导入 Gateway",
+ "Import {done} skills": "导入 {done} 个 Skill",
+ "Imported 1 skill": "已导入 1 个 Skill",
+ "Imported {done} skills": "已导入 {done} 个 Skill",
"Install 1 skill": "安装 1 个技能",
"Install into": "安装到",
"Install into hint": "这里选中的每个 agent 都会安装同一批技能,各自写入自己的 skills 目录。",
@@ -409,7 +419,10 @@
"No other agent on this machine can receive these": "本机没有其他 Agent
可以接收这些内容。",
"No server matches": "没有匹配的服务器",
"No skills yet": "该 Agent 还没有技能",
+ "No source holds this skill": "没有来源包含这个 Skill",
"No sources yet": "还没有来源。添加一个 GitHub 仓库、压缩包或本地目录。",
+ "Not in any source": "没有匹配的来源",
+ "Not in any source hint": "这些 Skill 在已添加的来源里找不到。先添加它们所在的来源并刷新,再回到这里。",
"Not installed": "仅配置",
"Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
"Not written yet": "尚未写入",
@@ -434,6 +447,7 @@
"Prompt placeholder": "目前是空的。写在这里的内容,这个 Agent 每次会话开始时都会读到。",
"Prompts": "提示词",
"Read from": "读取自",
+ "Recognized in a source": "在来源中找到",
"Recycle bin": "回收站",
"Recycle bin hint": "删除的内容会在这里保留 30 天,恢复后回到原来的位置。",
"Remove source": "移除来源",
@@ -446,6 +460,7 @@
"Restore as a replacement description": "原路径上已经有别的内容。它会被移入回收站,由这一项取代。",
"Restore as a replacement?": "覆盖恢复?",
"Restored": "已恢复",
+ "Same content": "内容一致",
"Same version": "版本相同",
"Search and the web": "搜索与网页",
"Search by name": "按名称搜索",
@@ -469,6 +484,8 @@
"This source holds no skills": "该来源中没有包含 SKILL.md 的目录。",
"Transport": "连接方式",
"URL": "URL",
+ "Untracked skills": "未托管的 Skill",
+ "Untracked skills hint": "这些 Skill 装在 Agent 里,但不是 Gateway
装的,也没有任何记录说明它们来自哪里。导入之后,Gateway 就能判断它们是不是最新版本,也能一键更新。磁盘上的文件不会被改动。",
"Up to date": "已是最新",
"Up to date detail": "内容与来源完全一致。",
"Update available": "有新版本",
diff --git a/web/src/pages/AgentConfigsPage.tsx
b/web/src/pages/AgentConfigsPage.tsx
index 155e3ee..afbaa11 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -25,6 +25,7 @@ import {
Pencil,
Plus,
RefreshCw,
+ ScanSearch,
Send,
Trash2,
} from "lucide-react";
@@ -39,6 +40,7 @@ import {DetailDialog, type DetailTarget} from
"@/components/agent-config/detail-
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 {UnmanagedSkillsDialog} from
"@/components/agent-config/unmanaged-skills-dialog";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {DataTable, type Column} from "@/components/shared/data-table";
import {EmptyState} from "@/components/shared/empty-state";
@@ -167,6 +169,7 @@ export default function AgentConfigsPage({account}:
{account: Account}) {
const [addOpen, setAddOpen] = React.useState(false);
const [installOpen, setInstallOpen] = React.useState(false);
const [trashOpen, setTrashOpen] = React.useState(false);
+ const [untrackedOpen, setUntrackedOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState("");
const [updating, setUpdating] = React.useState("");
@@ -499,6 +502,12 @@ export default function AgentConfigsPage({account}:
{account: Account}) {
{i18next.t("agentConfig:Add MCP server")}
</Button>
) : null}
+ {kind === "skill" && inventories.length > 0 ? (
+ <Button variant="outline" onClick={() => setUntrackedOpen(true)}>
+ <ScanSearch className="size-4" />
+ {i18next.t("agentConfig:Untracked skills")}
+ </Button>
+ ) : null}
{inventories.length > 0 ? (
<Button variant="outline" onClick={() => setTrashOpen(true)}>
<Trash2 className="size-4" />
@@ -644,6 +653,13 @@ export default function AgentConfigsPage({account}:
{account: Account}) {
/>
) : null}
+ <UnmanagedSkillsDialog
+ open={untrackedOpen}
+ onOpenChange={setUntrackedOpen}
+ agentNames={agentNames}
+ onDone={refresh}
+ />
+
<TrashDialog
open={trashOpen}
onOpenChange={setTrashOpen}
diff --git a/web/src/types.ts b/web/src/types.ts
index 030b528..c7ef833 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1096,6 +1096,33 @@ export interface SkillCatalog {
skills: SkillCatalogItem[];
}
+/** Where an untracked skill appears to have come from. */
+export interface SkillMatch {
+ sourceId: string;
+ sourceName: string;
+ /** The name inside the source, and the folder it is in. */
+ skill: string;
+ path: string;
+ /** The source holds exactly this content. */
+ same: boolean;
+ /** Recognized by its content after being renamed here. */
+ byDigest?: boolean;
+}
+
+/** One skill on disk that Gateway did not install and has no record of. */
+export interface UnmanagedSkill {
+ agentId: string;
+ owner: string;
+ name: string;
+ path: string;
+ description?: string;
+ files?: number;
+ bytes?: number;
+ digest?: string;
+ modified?: number;
+ match?: SkillMatch;
+}
+
/**
* 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.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]