mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: add bot-level skill paths configuration (#383)
This commit is contained in:
+63
-19
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user