feat: add bot-level skill paths configuration (#383)

This commit is contained in:
Chrys
2026-04-17 16:05:00 +08:00
committed by GitHub
parent 7aa6ec6ca9
commit dee82177d3
11 changed files with 780 additions and 84 deletions
+63 -19
View File
@@ -19,10 +19,11 @@ import (
)
const (
ManagedDirPath = config.DefaultDataMount + "/skills"
LegacyDirPath = config.DefaultDataMount + "/.skills"
IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
IndexFilePath = IndexDirPath + "/index.json"
ManagedDirPath = config.DefaultDataMount + "/skills"
LegacyDirPath = config.DefaultDataMount + "/.skills"
IndexDirPath = config.DefaultDataMount + "/.memoh/skills"
IndexFilePath = IndexDirPath + "/index.json"
SkillDiscoveryRootsEnvVar = "MEMOH_SKILL_DISCOVERY_ROOTS"
SourceKindManaged = "managed"
SourceKindLegacy = "legacy"
@@ -115,34 +116,39 @@ func ManagedSkillDirForName(name string) (string, error) {
return dirPath, nil
}
func ContainerEnv() []string {
return []string{
func ContainerEnv(rawCompatRoots []string) []string {
compatRoots := compatDiscoveryRoots(rawCompatRoots)
env := []string{
"HOME=" + config.DefaultDataMount,
"XDG_CONFIG_HOME=" + path.Join(config.DefaultDataMount, ".config"),
"XDG_DATA_HOME=" + path.Join(config.DefaultDataMount, ".local", "share"),
"XDG_CACHE_HOME=" + path.Join(config.DefaultDataMount, ".cache"),
}
env = append(env, SkillDiscoveryRootsEnvVar+"="+strings.Join(compatRoots, ":"))
return env
}
func DiscoveryRoots() []Root {
return []Root{
func DiscoveryRoots(rawCompatRoots []string) []Root {
roots := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
{Path: path.Join(config.DefaultDataMount, ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
{Path: path.Join("/root", ".agents", "skills"), Kind: SourceKindCompat, Managed: false},
}
for _, compatRoot := range compatDiscoveryRoots(rawCompatRoots) {
roots = append(roots, Root{Path: compatRoot, Kind: SourceKindCompat, Managed: false})
}
return roots
}
func List(ctx context.Context, client fileClient) ([]Entry, error) {
func List(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
idx := readIndex(ctx, client)
items := scan(ctx, client, DiscoveryRoots())
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
resolved := resolve(items, idx.Overrides)
writeIndex(ctx, client, idx.withItems(resolved))
return resolved, nil
}
func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
items, err := List(ctx, client)
func LoadEffective(ctx context.Context, client fileClient, rawCompatRoots []string) ([]Entry, error) {
items, err := List(ctx, client, rawCompatRoots)
if err != nil {
return nil, err
}
@@ -155,7 +161,7 @@ func LoadEffective(ctx context.Context, client fileClient) ([]Entry, error) {
return out, nil
}
func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) error {
func ApplyAction(ctx context.Context, client fileClient, rawCompatRoots []string, req ActionRequest) error {
targetPath := strings.TrimSpace(req.TargetPath)
if targetPath == "" {
return bridge.ErrBadRequest
@@ -164,7 +170,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
switch strings.TrimSpace(req.Action) {
case ActionDisable:
idx := readIndex(ctx, client)
items := scan(ctx, client, DiscoveryRoots())
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
if !containsSourcePath(items, targetPath) {
return bridge.ErrNotFound
}
@@ -176,7 +182,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
return nil
case ActionEnable:
idx := readIndex(ctx, client)
items := scan(ctx, client, DiscoveryRoots())
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
if !containsSourcePath(items, targetPath) {
return bridge.ErrNotFound
}
@@ -184,7 +190,7 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
writeIndex(ctx, client, idx.withItems(resolve(items, idx.Overrides)))
return nil
case ActionAdopt:
items := scan(ctx, client, DiscoveryRoots())
items := scan(ctx, client, DiscoveryRoots(rawCompatRoots))
target, ok := findBySourcePath(items, targetPath)
if !ok {
return bridge.ErrNotFound
@@ -208,13 +214,51 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
return err
}
idx := readIndex(ctx, client)
writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots()), idx.Overrides)))
writeIndex(ctx, client, idx.withItems(resolve(scan(ctx, client, DiscoveryRoots(rawCompatRoots)), idx.Overrides)))
return nil
default:
return bridge.ErrBadRequest
}
}
func compatDiscoveryRoots(rawRoots []string) []string {
if rawRoots == nil {
rawRoots = defaultCompatDiscoveryRoots()
}
return normalizeCompatDiscoveryRoots(rawRoots)
}
func defaultCompatDiscoveryRoots() []string {
return []string{
path.Join(config.DefaultDataMount, ".agents", "skills"),
path.Join("/root", ".agents", "skills"),
}
}
func normalizeCompatDiscoveryRoots(paths []string) []string {
out := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, p := range paths {
p = strings.TrimSpace(p)
if p == "" {
continue
}
p = path.Clean(p)
if !strings.HasPrefix(p, "/") {
continue
}
if p == ManagedDirPath || p == LegacyDirPath {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
func ParseFile(raw string, fallbackName string) Parsed {
trimmed := strings.TrimSpace(raw)
result := Parsed{
+51 -11
View File
@@ -66,7 +66,7 @@ func TestListReadsFullRawContentAndWritesIndex(t *testing.T) {
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n" + strings.Repeat("A", 7000)
items, err := List(context.Background(), client)
items, err := List(context.Background(), client, nil)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
@@ -87,7 +87,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[externalPath] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
if err := ApplyAction(context.Background(), client, ActionRequest{
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionAdopt,
TargetPath: externalPath,
}); err != nil {
@@ -97,7 +97,7 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
t.Fatalf("expected managed copy after adopt")
}
if err := ApplyAction(context.Background(), client, ActionRequest{
if err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionDisable,
TargetPath: externalPath,
}); err != nil {
@@ -115,7 +115,7 @@ func TestApplyActionAdoptRejectsInvalidManagedName(t *testing.T) {
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "escape", IsDir: true}}
client.files[externalPath] = "---\nname: ..\ndescription: Escape\n---\n\n# Escape"
err := ApplyAction(context.Background(), client, ActionRequest{
err := ApplyAction(context.Background(), client, nil, ActionRequest{
Action: ActionAdopt,
TargetPath: externalPath,
})
@@ -165,8 +165,8 @@ func TestManagedSkillDirForNameRejectsEscapingNames(t *testing.T) {
}
}
func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
roots := DiscoveryRoots()
func TestDiscoveryRootsMatchDefaultPolicy(t *testing.T) {
roots := DiscoveryRoots(nil)
want := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
@@ -178,15 +178,46 @@ func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
}
}
func TestDiscoveryRootsUseConfiguredCompatRoots(t *testing.T) {
roots := DiscoveryRoots([]string{
" /custom/skills ",
"/data/skills",
"/custom/skills",
"relative/skills",
"/root/.openclaw/skills",
})
want := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
{Path: "/custom/skills", Kind: SourceKindCompat, Managed: false},
{Path: "/root/.openclaw/skills", Kind: SourceKindCompat, Managed: false},
}
if !slices.Equal(roots, want) {
t.Fatalf("DiscoveryRoots(custom) = %+v, want %+v", roots, want)
}
}
func TestDiscoveryRootsAllowExplicitEmptyCompatRoots(t *testing.T) {
roots := DiscoveryRoots([]string{})
want := []Root{
{Path: ManagedDirPath, Kind: SourceKindManaged, Managed: true},
{Path: LegacyDirPath, Kind: SourceKindLegacy, Managed: false},
}
if !slices.Equal(roots, want) {
t.Fatalf("DiscoveryRoots(empty) = %+v, want %+v", roots, want)
}
}
func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
client := newFakeClient()
for _, root := range DiscoveryRoots() {
rawCompatRoots := []string(nil)
for _, root := range DiscoveryRoots(rawCompatRoots) {
client.listings[root.Path] = nil
}
client.listings[ManagedDirPath] = []*pb.FileEntry{{Path: "alpha", IsDir: true}}
client.files[pathJoin(ManagedDirPath, "alpha", "SKILL.md")] = "---\nname: alpha\ndescription: Alpha\n---\n\n# Alpha"
items, err := List(context.Background(), client)
items, err := List(context.Background(), client, rawCompatRoots)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
@@ -194,8 +225,8 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
t.Fatalf("List() items = %+v, want managed alpha only", items)
}
wantCalls := make([]string, 0, len(DiscoveryRoots()))
for _, root := range DiscoveryRoots() {
wantCalls := make([]string, 0, len(DiscoveryRoots(rawCompatRoots)))
for _, root := range DiscoveryRoots(rawCompatRoots) {
wantCalls = append(wantCalls, root.Path)
}
if !slices.Equal(client.listCalls, wantCalls) {
@@ -204,12 +235,13 @@ func TestListScansConfiguredDiscoveryRootsInOrder(t *testing.T) {
}
func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
env := ContainerEnv()
env := ContainerEnv(nil)
for _, want := range []string{
"HOME=/data",
"XDG_CONFIG_HOME=/data/.config",
"XDG_DATA_HOME=/data/.local/share",
"XDG_CACHE_HOME=/data/.cache",
"MEMOH_SKILL_DISCOVERY_ROOTS=/data/.agents/skills:/root/.agents/skills",
} {
if !slices.Contains(env, want) {
t.Fatalf("env %+v does not contain %q", env, want)
@@ -217,6 +249,14 @@ func TestContainerEnvUsesDataHomeAndXDGDirs(t *testing.T) {
}
}
func TestContainerEnvUsesConfiguredSkillDiscoveryRoots(t *testing.T) {
env := ContainerEnv([]string{"/custom/skills", "/root/.openclaw/skills"})
want := SkillDiscoveryRootsEnvVar + "=/custom/skills:/root/.openclaw/skills"
if !slices.Contains(env, want) {
t.Fatalf("env %+v does not contain %q", env, want)
}
}
type fakeClient struct {
listings map[string][]*pb.FileEntry
files map[string]string