mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
fix: allow skill_manage companion files
Squash merge PR #104 after validation.
This commit is contained in:
1 parent
484bcc7b71
commit
df8a03df48
10 files changed
+1269
-62
No files matched your search
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +33,37 @@ func TestReadableSkillRootsDoesNotFallbackForCustomSkill(t *testing.T) {
|
||||
t.Fatalf("roots = %#v, want no custom fallback", roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillVersionReadbackListsAndReadsCompanionReferenceFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
versionDir := filepath.Join(tmp, "managed", "demo", "2")
|
||||
referencePath := filepath.Join(versionDir, "references", "ship-workflow.md")
|
||||
if err := os.MkdirAll(filepath.Dir(referencePath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(versionDir, "SKILL.md"), []byte("---\nname: Demo\n---\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(referencePath, []byte("# Ship\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files := walkSkillFiles(versionDir)
|
||||
if !slices.ContainsFunc(files, func(entry fileEntry) bool {
|
||||
return entry.Path == filepath.Join("references", "ship-workflow.md") && !entry.IsDir && entry.Size == int64(len("# Ship\n"))
|
||||
}) {
|
||||
t.Fatalf("files = %#v, want references/ship-workflow.md", files)
|
||||
}
|
||||
|
||||
data, info, err := readSkillFile(referencePath)
|
||||
if err != nil {
|
||||
t.Fatalf("readSkillFile: %v", err)
|
||||
}
|
||||
if string(data) != "# Ship\n" {
|
||||
t.Fatalf("content = %q", data)
|
||||
}
|
||||
if info.Size() != int64(len("# Ship\n")) {
|
||||
t.Fatalf("size = %d", info.Size())
|
||||
}
|
||||
}
|
||||
+236
-48
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -85,8 +85,8 @@ func (t *SkillManageTool) Name() string { return "skill_manage" }
|
||||
|
||||
func (t *SkillManageTool) Description() string {
|
||||
return "Create, patch, or delete your own skills from content strings. " +
|
||||
"action=create: write a new skill from SKILL.md content (content string, no directory needed). " +
|
||||
"action=patch: update an existing skill via find/replace (creates new immutable version). " +
|
||||
"action=create: write a new skill from SKILL.md content and optional companion files. " +
|
||||
"action=patch: update an existing skill via find/replace and/or companion files (creates new immutable version). " +
|
||||
"action=delete: archive a skill so it is no longer discoverable. " +
|
||||
"Security scanner rejects dangerous patterns. You can only manage skills you own."
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (t *SkillManageTool) Parameters() map[string]any {
|
||||
},
|
||||
"find": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Exact text to find in the current SKILL.md. Required for patch unless only 'visibility' is being updated.",
|
||||
"description": "Exact text to find in the current SKILL.md. Required for content patch unless only 'files' or 'visibility' is being updated.",
|
||||
},
|
||||
"replace": map[string]any{
|
||||
"type": "string",
|
||||
@@ -121,6 +121,11 @@ func (t *SkillManageTool) Parameters() map[string]any {
|
||||
"enum": []string{skills.VisibilityPrivate, skills.VisibilityPublic},
|
||||
"description": "Skill visibility. For create: defaults to 'private'. For patch: updates who can discover the skill without creating a new version.",
|
||||
},
|
||||
"files": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": map[string]any{"type": "string"},
|
||||
"description": "Optional companion files keyed by relative path under the skill root. SKILL.md must use 'content' or find/replace. Unsafe paths and system artifacts are rejected.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
@@ -143,6 +148,13 @@ func (t *SkillManageTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
// maxSkillContentSize limits SKILL.md content to 100KB to prevent abuse.
|
||||
const maxSkillContentSize = 100 * 1024
|
||||
|
||||
const maxManagedSkillFileSize = 2 << 20
|
||||
|
||||
type managedSkillFile struct {
|
||||
Path string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// executeCreate writes a new skill from a SKILL.md content string.
|
||||
func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any) *Result {
|
||||
content, _ := args["content"].(string)
|
||||
@@ -152,6 +164,13 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
if len(content) > maxSkillContentSize {
|
||||
return ErrorResult(fmt.Sprintf("content too large (%d bytes, max %d)", len(content), maxSkillContentSize))
|
||||
}
|
||||
companionFiles, err := parseManagedSkillFiles(args["files"])
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
if err := validateManagedSkillTotalSize(companionFiles); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
rawVisibility, _ := args["visibility"].(string)
|
||||
if err := skills.ValidateVisibility(rawVisibility); err != nil {
|
||||
@@ -191,6 +210,12 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create skill directory: %v", err))
|
||||
}
|
||||
cleanupDest := true
|
||||
defer func() {
|
||||
if cleanupDest {
|
||||
_ = os.RemoveAll(destDir)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write SKILL.md
|
||||
contentBytes := []byte(content)
|
||||
@@ -198,12 +223,18 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
if err := os.WriteFile(skillPath, contentBytes, 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
||||
}
|
||||
if err := writeManagedSkillFiles(destDir, companionFiles); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write companion files: %v", err))
|
||||
}
|
||||
|
||||
// Hash + size
|
||||
hasher := sha256.New()
|
||||
hasher.Write(contentBytes)
|
||||
fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
fileSize := int64(len(contentBytes))
|
||||
fileSize, err := dirSize(destDir)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to calculate skill size: %v", err))
|
||||
}
|
||||
|
||||
// DB insert — owner = actor (real sender) so skill belongs to the individual
|
||||
// user rather than the group principal in group chats (#915).
|
||||
@@ -227,6 +258,7 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to register skill: %v", err))
|
||||
}
|
||||
cleanupDest = false
|
||||
|
||||
slog.Info("skill_manage: created", "id", id, "slug", slug, "version", version, "owner", ownerID)
|
||||
|
||||
@@ -257,6 +289,9 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Skill %q created.\n- Slug: %s\n- Version: %d", name, slug, version)
|
||||
if len(companionFiles) > 0 {
|
||||
result += fmt.Sprintf("\n- Companion files: %d", len(companionFiles))
|
||||
}
|
||||
if granted {
|
||||
result += "\n- Granted to current agent"
|
||||
}
|
||||
@@ -273,15 +308,19 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
find, _ := args["find"].(string)
|
||||
replace, _ := args["replace"].(string)
|
||||
rawVisibility, _ := args["visibility"].(string)
|
||||
companionFiles, filesErr := parseManagedSkillFiles(args["files"])
|
||||
if filesErr != nil {
|
||||
return ErrorResult(filesErr.Error())
|
||||
}
|
||||
if slug == "" {
|
||||
return ErrorResult("slug is required for action=patch")
|
||||
}
|
||||
if err := skills.ValidateVisibility(rawVisibility); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
// Patch requires at least one of: content edit (find) or visibility change.
|
||||
if find == "" && rawVisibility == "" {
|
||||
return ErrorResult("patch requires either 'find' (content edit) or 'visibility' (metadata update)")
|
||||
// Patch requires at least one of: content edit (find), file payload, or visibility change.
|
||||
if find == "" && len(companionFiles) == 0 && rawVisibility == "" {
|
||||
return ErrorResult("patch requires either 'find' (content edit), 'files' (companion files), or 'visibility' (metadata update)")
|
||||
}
|
||||
|
||||
info, ok := t.skills.GetSkill(ctx, slug)
|
||||
@@ -305,8 +344,8 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
||||
}
|
||||
|
||||
// Visibility-only patch path: no content change, no new version.
|
||||
if find == "" && rawVisibility != "" {
|
||||
// Visibility-only patch path: no content/files change, no new version.
|
||||
if find == "" && len(companionFiles) == 0 && rawVisibility != "" {
|
||||
skillID, err := uuid.Parse(info.ID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err))
|
||||
@@ -325,14 +364,43 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
return NewResult(fmt.Sprintf("Skill %q visibility set to %s.", slug, newVisibility))
|
||||
}
|
||||
|
||||
// Read current SKILL.md from latest version
|
||||
current, err := os.ReadFile(info.Path)
|
||||
newVer, commitLock, lockErr := t.skills.GetNextVersionLocked(ctx, slug)
|
||||
if lockErr != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr))
|
||||
}
|
||||
defer commitLock() //nolint:errcheck
|
||||
|
||||
latestInfo, ok := t.skills.GetSkill(ctx, slug)
|
||||
if !ok {
|
||||
return ErrorResult(fmt.Sprintf("skill %q not found or archived", slug))
|
||||
}
|
||||
if t.skills.IsSystemSkill(slug) {
|
||||
return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug))
|
||||
}
|
||||
if !canManageSkill(ctx, t.skills, latestInfo) {
|
||||
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
||||
}
|
||||
|
||||
existingFiles, err := collectExistingManagedSkillCompanionFiles(latestInfo.BaseDir)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to inspect companion files: %v", err))
|
||||
}
|
||||
finalCompanionFiles := overlayManagedSkillFiles(existingFiles, companionFiles)
|
||||
if err := validateManagedSkillTotalSize(finalCompanionFiles); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Read current SKILL.md from the latest version while the slug lock is held.
|
||||
current, err := os.ReadFile(latestInfo.Path)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read current SKILL.md: %v", err))
|
||||
}
|
||||
|
||||
patched := strings.Replace(string(current), find, replace, 1)
|
||||
if patched == string(current) {
|
||||
patched := string(current)
|
||||
if find != "" {
|
||||
patched = strings.Replace(patched, find, replace, 1)
|
||||
}
|
||||
if find != "" && patched == string(current) {
|
||||
return NewResult("no change: find text not found in current SKILL.md")
|
||||
}
|
||||
|
||||
@@ -342,16 +410,17 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
return ErrorResult(skills.FormatGuardViolations(violations))
|
||||
}
|
||||
|
||||
oldVer := info.Version
|
||||
newVer, commitLock, lockErr := t.skills.GetNextVersionLocked(ctx, slug)
|
||||
if lockErr != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr))
|
||||
}
|
||||
defer commitLock() //nolint:errcheck
|
||||
oldVer := latestInfo.Version
|
||||
destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", newVer))
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create new version directory: %v", err))
|
||||
}
|
||||
cleanupDest := true
|
||||
defer func() {
|
||||
if cleanupDest {
|
||||
_ = os.RemoveAll(destDir)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write patched SKILL.md
|
||||
patchedBytes := []byte(patched)
|
||||
@@ -359,16 +428,18 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
return ErrorResult(fmt.Sprintf("failed to write patched SKILL.md: %v", err))
|
||||
}
|
||||
|
||||
// Copy any companion files from old version (scripts, assets, etc.)
|
||||
if err := copyOtherFiles(info.BaseDir, destDir); err != nil {
|
||||
slog.Warn("skill_manage: failed to copy companion files", "error", err)
|
||||
if err := writeManagedSkillFiles(destDir, finalCompanionFiles); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write companion files: %v", err))
|
||||
}
|
||||
|
||||
// Hash + size
|
||||
hasher := sha256.New()
|
||||
hasher.Write(patchedBytes)
|
||||
fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
fileSize := int64(len(patchedBytes))
|
||||
fileSize, err := dirSize(destDir)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to calculate skill size: %v", err))
|
||||
}
|
||||
|
||||
// DB update
|
||||
skillID, err := uuid.Parse(info.ID)
|
||||
@@ -388,14 +459,19 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
|
||||
if err := t.skills.UpdateSkill(ctx, skillID, updates); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to update skill in database: %v", err))
|
||||
}
|
||||
cleanupDest = false
|
||||
|
||||
slog.Info("skill_manage: patched", "slug", slug, "old_version", oldVer, "new_version", newVer)
|
||||
slog.Info("skill_manage: patched", "slug", slug, "old_version", oldVer, "new_version", newVer, "companion_files", len(companionFiles))
|
||||
|
||||
if t.loader != nil {
|
||||
t.loader.BumpVersion()
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("Skill %q patched. v%d → v%d. Changes active next turn.", slug, oldVer, newVer))
|
||||
result := fmt.Sprintf("Skill %q patched. v%d → v%d. Changes active next turn.", slug, oldVer, newVer)
|
||||
if len(companionFiles) > 0 {
|
||||
result += fmt.Sprintf("\n- Companion files written: %d", len(companionFiles))
|
||||
}
|
||||
return NewResult(result)
|
||||
}
|
||||
|
||||
// executeDelete archives a skill in the DB and moves its directory to .trash/.
|
||||
@@ -455,53 +531,165 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any
|
||||
// maxCopySize limits total companion file copy to 20MB (matching publish_skill).
|
||||
const maxCopySize = 20 << 20
|
||||
|
||||
// copyOtherFiles copies all files from srcDir to dstDir except SKILL.md.
|
||||
// Used by patch to carry companion files (scripts, assets) into the new version directory.
|
||||
// Uses WalkDir (not Walk) so symlinks are detected via DirEntry.Type() before Stat follows them.
|
||||
// Enforces a 20MB total size limit.
|
||||
func copyOtherFiles(srcDir, dstDir string) error {
|
||||
func parseManagedSkillFiles(raw any) ([]managedSkillFile, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
files, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("files must be an object mapping relative paths to string content")
|
||||
}
|
||||
out := make([]managedSkillFile, 0, len(files))
|
||||
for rawPath, rawContent := range files {
|
||||
content, ok := rawContent.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("files[%q] must be a string", rawPath)
|
||||
}
|
||||
cleanPath, err := validateManagedSkillFilePath(rawPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(content) > maxManagedSkillFileSize {
|
||||
return nil, fmt.Errorf("file %q too large (%d bytes, max %d)", cleanPath, len(content), maxManagedSkillFileSize)
|
||||
}
|
||||
out = append(out, managedSkillFile{Path: cleanPath, Content: []byte(content)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateManagedSkillFilePath(rawPath string) (string, error) {
|
||||
if rawPath == "" {
|
||||
return "", fmt.Errorf("invalid file path %q: empty path", rawPath)
|
||||
}
|
||||
if strings.ContainsRune(rawPath, 0x00) {
|
||||
return "", fmt.Errorf("invalid file path %q: null byte", rawPath)
|
||||
}
|
||||
if len(rawPath) >= 2 && rawPath[1] == ':' {
|
||||
return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath)
|
||||
}
|
||||
normalized := strings.ReplaceAll(rawPath, "\\", "/")
|
||||
if strings.HasPrefix(normalized, "/") {
|
||||
return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath)
|
||||
}
|
||||
for part := range strings.SplitSeq(normalized, "/") {
|
||||
switch part {
|
||||
case "..":
|
||||
return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath)
|
||||
case ".git":
|
||||
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
|
||||
}
|
||||
if strings.HasPrefix(part, ".") {
|
||||
return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath)
|
||||
}
|
||||
}
|
||||
cleanPath := path.Clean(normalized)
|
||||
if cleanPath == "." || cleanPath == "SKILL.md" || strings.EqualFold(cleanPath, "SKILL.md") {
|
||||
return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath)
|
||||
}
|
||||
if strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") {
|
||||
return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath)
|
||||
}
|
||||
if skills.IsSystemArtifact(cleanPath) {
|
||||
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
|
||||
}
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
func collectExistingManagedSkillCompanionFiles(srcDir string) ([]managedSkillFile, error) {
|
||||
var out []managedSkillFile
|
||||
var totalSize int64
|
||||
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
||||
err := filepath.WalkDir(srcDir, func(filePath string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip symlinks — WalkDir exposes the raw type before following
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
rel, err := filepath.Rel(srcDir, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." || rel == "SKILL.md" {
|
||||
return nil
|
||||
}
|
||||
// Skip path traversal attempts
|
||||
if strings.Contains(rel, "..") {
|
||||
cleanPath := path.Clean(rel)
|
||||
if cleanPath == "." || strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") {
|
||||
return fmt.Errorf("existing companion file %q escapes skill root", rel)
|
||||
}
|
||||
if skills.IsSystemArtifact(cleanPath) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(filepath.Join(dstDir, rel), 0755)
|
||||
return nil
|
||||
}
|
||||
fi, err := d.Info()
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize += fi.Size()
|
||||
totalSize += info.Size()
|
||||
if totalSize > maxCopySize {
|
||||
return fmt.Errorf("companion files exceed %d bytes limit", maxCopySize)
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.Create(filepath.Join(dstDir, rel))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
_, err = io.Copy(dst, src)
|
||||
return err
|
||||
out = append(out, managedSkillFile{Path: cleanPath, Content: data})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func overlayManagedSkillFiles(existing, payload []managedSkillFile) []managedSkillFile {
|
||||
byPath := make(map[string]managedSkillFile, len(existing)+len(payload))
|
||||
order := make([]string, 0, len(existing)+len(payload))
|
||||
for _, file := range existing {
|
||||
if _, exists := byPath[file.Path]; !exists {
|
||||
order = append(order, file.Path)
|
||||
}
|
||||
byPath[file.Path] = file
|
||||
}
|
||||
for _, file := range payload {
|
||||
if _, exists := byPath[file.Path]; !exists {
|
||||
order = append(order, file.Path)
|
||||
}
|
||||
byPath[file.Path] = file
|
||||
}
|
||||
out := make([]managedSkillFile, 0, len(order))
|
||||
for _, filePath := range order {
|
||||
out = append(out, byPath[filePath])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateManagedSkillTotalSize(files []managedSkillFile) error {
|
||||
var total int64
|
||||
for _, file := range files {
|
||||
total += int64(len(file.Content))
|
||||
if total > maxCopySize {
|
||||
return fmt.Errorf("companion files exceed %d bytes limit", maxCopySize)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeManagedSkillFiles(destDir string, files []managedSkillFile) error {
|
||||
for _, file := range files {
|
||||
destPath := filepath.Join(destDir, filepath.FromSlash(file.Path))
|
||||
cleanDest := filepath.Clean(destPath)
|
||||
if !strings.HasPrefix(cleanDest, destDir+string(filepath.Separator)) {
|
||||
return fmt.Errorf("file %q escapes skill root", file.Path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cleanDest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cleanDest, file.Content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type skillManageFilesStore struct {
|
||||
nextBySlug map[string]int
|
||||
skills map[uuid.UUID]store.SkillInfo
|
||||
owners map[string]string
|
||||
lastUpdates map[uuid.UUID]map[string]any
|
||||
beforeVersionLockHook func(slug string)
|
||||
}
|
||||
|
||||
func newSkillManageFilesStore() *skillManageFilesStore {
|
||||
return &skillManageFilesStore{
|
||||
nextBySlug: map[string]int{},
|
||||
skills: map[uuid.UUID]store.SkillInfo{},
|
||||
owners: map[string]string{},
|
||||
lastUpdates: map[uuid.UUID]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func skillManageFilesContext() context.Context {
|
||||
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
ctx = store.WithUserID(ctx, "owner")
|
||||
ctx = store.WithSenderID(ctx, "owner")
|
||||
ctx = store.WithAgentID(ctx, uuid.New())
|
||||
return ctx
|
||||
}
|
||||
|
||||
func writeManagedSkillVersion(t *testing.T, root, slug string, version int, content string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "skills-store", slug, "1")
|
||||
if version != 1 {
|
||||
dir = filepath.Join(root, "skills-store", slug, "2")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("mkdir skill dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write SKILL.md: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func seedManagedSkill(t *testing.T, st *skillManageFilesStore, root, slug string, content string) (uuid.UUID, string) {
|
||||
t.Helper()
|
||||
dir := writeManagedSkillVersion(t, root, slug, 1, content)
|
||||
id := uuid.New()
|
||||
st.nextBySlug[slug] = 1
|
||||
st.owners[slug] = "owner"
|
||||
st.skills[id] = store.SkillInfo{
|
||||
ID: id.String(),
|
||||
TenantID: store.MasterTenantID.String(),
|
||||
Name: "Managed Skill",
|
||||
Slug: slug,
|
||||
Path: filepath.Join(dir, "SKILL.md"),
|
||||
BaseDir: dir,
|
||||
Version: 1,
|
||||
Status: "active",
|
||||
Enabled: true,
|
||||
Visibility: skills.VisibilityPrivate,
|
||||
OwnerID: "owner",
|
||||
}
|
||||
return id, dir
|
||||
}
|
||||
|
||||
func validManagedSkillMarkdown(slug string) string {
|
||||
return "---\nname: Managed Skill\nslug: " + slug + "\n---\nOriginal body\n"
|
||||
}
|
||||
|
||||
func newSkillManageFilesTool(root string, st *skillManageFilesStore) *SkillManageTool {
|
||||
return NewSkillManageTool(st, filepath.Join(root, "skills-store"), root, nil)
|
||||
}
|
||||
|
||||
func TestSkillManagePatchFilesOnlyCreatesNewVersionWithReferenceFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/ship-workflow.md": "# Ship\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/references/ship-workflow.md"); got != "# Ship\n" {
|
||||
t.Fatalf("reference content = %q", got)
|
||||
}
|
||||
if got := st.latestBySlug("managed-skill").Version; got != 2 {
|
||||
t.Fatalf("version = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchFindReplaceAndFilesCopiesExistingCompanions(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
_, v1Dir := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
if err := os.MkdirAll(filepath.Join(v1Dir, "assets"), 0755); err != nil {
|
||||
t.Fatalf("mkdir assets: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v1Dir, "assets/logo.txt"), []byte("logo"), 0644); err != nil {
|
||||
t.Fatalf("write asset: %v", err)
|
||||
}
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"find": "Original body",
|
||||
"replace": "Updated body",
|
||||
"files": map[string]any{
|
||||
"references/ship-workflow.md": "# Ship\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/SKILL.md"); !strings.Contains(got, "Updated body") {
|
||||
t.Fatalf("patched SKILL.md missing update: %q", got)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/assets/logo.txt"); got != "logo" {
|
||||
t.Fatalf("copied asset = %q", got)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/references/ship-workflow.md"); got != "# Ship\n" {
|
||||
t.Fatalf("reference content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchCopiesExistingHiddenCompanions(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
_, v1Dir := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
paths := map[string]string{
|
||||
".env.example": "TOKEN=\n",
|
||||
".github/workflows/check.yml": "name: check\n",
|
||||
"references/ship-workflow.md": "# Ship\n",
|
||||
"references/nested/.keep-example.md": "keep\n",
|
||||
}
|
||||
for relPath, content := range paths {
|
||||
fullPath := filepath.Join(v1Dir, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", relPath, err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", relPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/new.md": "# New\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
for relPath, want := range paths {
|
||||
got := readTestFile(t, root, filepath.Join("skills-store/managed-skill/2", filepath.ToSlash(relPath)))
|
||||
if got != want {
|
||||
t.Fatalf("copied %s = %q, want %q", relPath, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchCopiesExistingLargeCompanionUnderTotalLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
_, v1Dir := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
largeContent := strings.Repeat("x", maxManagedSkillFileSize+1)
|
||||
largePath := filepath.Join(v1Dir, "assets", "large.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(largePath), 0755); err != nil {
|
||||
t.Fatalf("mkdir large asset: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(largePath, []byte(largeContent), 0644); err != nil {
|
||||
t.Fatalf("write large asset: %v", err)
|
||||
}
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/new.md": "# New\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/assets/large.txt"); got != largeContent {
|
||||
t.Fatalf("large asset length = %d, want %d", len(got), len(largeContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchReloadsLatestVersionAfterLock(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
id, _ := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
st.beforeVersionLockHook = func(slug string) {
|
||||
if slug != "managed-skill" {
|
||||
return
|
||||
}
|
||||
v2Dir := writeManagedSkillVersion(t, root, slug, 2, validManagedSkillMarkdown(slug))
|
||||
firstPath := filepath.Join(v2Dir, "references", "first.md")
|
||||
if err := os.MkdirAll(filepath.Dir(firstPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir concurrent reference: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(firstPath, []byte("# First\n"), 0644); err != nil {
|
||||
t.Fatalf("write concurrent reference: %v", err)
|
||||
}
|
||||
skill := st.skills[id]
|
||||
skill.Version = 2
|
||||
skill.BaseDir = v2Dir
|
||||
skill.Path = filepath.Join(v2Dir, "SKILL.md")
|
||||
st.skills[id] = skill
|
||||
st.nextBySlug[slug] = 2
|
||||
st.beforeVersionLockHook = nil
|
||||
}
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/second.md": "# Second\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/3/references/first.md"); got != "# First\n" {
|
||||
t.Fatalf("first concurrent reference = %q", got)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/3/references/second.md"); got != "# Second\n" {
|
||||
t.Fatalf("second reference = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchFilesOverlayExistingCompanion(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
_, v1Dir := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
if err := os.MkdirAll(filepath.Join(v1Dir, "references"), 0755); err != nil {
|
||||
t.Fatalf("mkdir references: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v1Dir, "references/guide.md"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("write reference: %v", err)
|
||||
}
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/guide.md": "new",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/managed-skill/2/references/guide.md"); got != "new" {
|
||||
t.Fatalf("overlaid reference content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManageCreateWritesCompanionFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "create",
|
||||
"content": validManagedSkillMarkdown("new-skill"),
|
||||
"files": map[string]any{
|
||||
"references/guide.md": "# Guide\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("create returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := readTestFile(t, root, "skills-store/new-skill/1/references/guide.md"); got != "# Guide\n" {
|
||||
t.Fatalf("reference content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManageVisibilityOnlyPatchDoesNotCreateNewVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
id, _ := seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"visibility": skills.VisibilityPublic,
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if got := st.latestBySlug("managed-skill").Version; got != 1 {
|
||||
t.Fatalf("version = %d, want unchanged v1", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "skills-store/managed-skill/2")); !os.IsNotExist(err) {
|
||||
t.Fatalf("version 2 dir exists after visibility-only patch: err=%v", err)
|
||||
}
|
||||
if got := st.lastUpdates[id]["visibility"]; got != skills.VisibilityPublic {
|
||||
t.Fatalf("visibility update = %v, want public", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManageFilesRejectUnsafePathsBeforeCreatingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"/abs.md",
|
||||
"../escape.md",
|
||||
`C:/escape.md`,
|
||||
"references/ok\x00.md",
|
||||
".git/config",
|
||||
".env",
|
||||
"references/.secret",
|
||||
"__MACOSX/x",
|
||||
".DS_Store",
|
||||
"SKILL.md",
|
||||
}
|
||||
for _, relPath := range cases {
|
||||
t.Run(relPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
relPath: "bad",
|
||||
},
|
||||
})
|
||||
if !res.IsError {
|
||||
t.Fatalf("patch succeeded for unsafe path %q: %s", relPath, res.ForLLM)
|
||||
}
|
||||
if !strings.Contains(res.ForLLM, "invalid file path") {
|
||||
t.Fatalf("error = %q, want invalid file path", res.ForLLM)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "skills-store/managed-skill/2")); !os.IsNotExist(err) {
|
||||
t.Fatalf("version 2 dir exists after rejected path: err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManageFilesRejectNonStringPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/guide.md": map[string]any{"nested": "no"},
|
||||
},
|
||||
})
|
||||
if !res.IsError {
|
||||
t.Fatalf("patch succeeded with non-string payload: %s", res.ForLLM)
|
||||
}
|
||||
if !strings.Contains(res.ForLLM, "must be a string") {
|
||||
t.Fatalf("error = %q, want string validation", res.ForLLM)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "skills-store/managed-skill/2")); !os.IsNotExist(err) {
|
||||
t.Fatalf("version 2 dir exists after rejected payload: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManageFilesRejectOversizePayloadBeforeCreatingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"files": map[string]any{
|
||||
"references/too-large.md": strings.Repeat("x", maxManagedSkillFileSize+1),
|
||||
},
|
||||
})
|
||||
if !res.IsError {
|
||||
t.Fatalf("patch succeeded with oversize payload: %s", res.ForLLM)
|
||||
}
|
||||
if !strings.Contains(res.ForLLM, "too large") {
|
||||
t.Fatalf("error = %q, want size validation", res.ForLLM)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "skills-store/managed-skill/2")); !os.IsNotExist(err) {
|
||||
t.Fatalf("version 2 dir exists after rejected payload: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillManagePatchFindMissWithFilesDoesNotCreateVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
st := newSkillManageFilesStore()
|
||||
ctx := skillManageFilesContext()
|
||||
seedManagedSkill(t, st, root, "managed-skill", validManagedSkillMarkdown("managed-skill"))
|
||||
|
||||
res := newSkillManageFilesTool(root, st).Execute(ctx, map[string]any{
|
||||
"action": "patch",
|
||||
"slug": "managed-skill",
|
||||
"find": "missing text",
|
||||
"replace": "replacement",
|
||||
"files": map[string]any{
|
||||
"references/guide.md": "# Guide\n",
|
||||
},
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("patch returned error: %s", res.ForLLM)
|
||||
}
|
||||
if !strings.Contains(res.ForLLM, "no change") {
|
||||
t.Fatalf("result = %q, want no-change message", res.ForLLM)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "skills-store/managed-skill/2")); !os.IsNotExist(err) {
|
||||
t.Fatalf("version 2 dir exists after missing find: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(t *testing.T, root, rel string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", rel, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (s *skillManageFilesStore) latestBySlug(slug string) store.SkillInfo {
|
||||
var latest store.SkillInfo
|
||||
for _, skill := range s.skills {
|
||||
if skill.Slug == slug && skill.Version > latest.Version {
|
||||
latest = skill
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func (s *skillManageFilesStore) ListSkills(context.Context) []store.SkillInfo { return nil }
|
||||
func (s *skillManageFilesStore) LoadSkill(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
func (s *skillManageFilesStore) LoadForContext(context.Context, []string) string { return "" }
|
||||
func (s *skillManageFilesStore) BuildSummary(context.Context, []string) string { return "" }
|
||||
func (s *skillManageFilesStore) GetSkill(_ context.Context, slug string) (*store.SkillInfo, bool) {
|
||||
for _, skill := range s.skills {
|
||||
if skill.Slug == slug && skill.Status != "deleted" {
|
||||
copy := skill
|
||||
return ©, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
func (s *skillManageFilesStore) FilterSkills(context.Context, []string) []store.SkillInfo {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) Version() int64 { return 0 }
|
||||
func (s *skillManageFilesStore) BumpVersion() {}
|
||||
func (s *skillManageFilesStore) Dirs() []string { return nil }
|
||||
func (s *skillManageFilesStore) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
version := p.Version
|
||||
if version == 0 {
|
||||
version = s.nextBySlug[p.Slug] + 1
|
||||
}
|
||||
if version > s.nextBySlug[p.Slug] {
|
||||
s.nextBySlug[p.Slug] = version
|
||||
}
|
||||
s.owners[p.Slug] = p.OwnerID
|
||||
s.skills[id] = store.SkillInfo{
|
||||
ID: id.String(),
|
||||
TenantID: store.MasterTenantID.String(),
|
||||
Name: p.Name,
|
||||
Slug: p.Slug,
|
||||
Description: derefString(p.Description),
|
||||
Path: filepath.Join(p.FilePath, "SKILL.md"),
|
||||
BaseDir: p.FilePath,
|
||||
Version: version,
|
||||
Status: "active",
|
||||
Enabled: true,
|
||||
Visibility: p.Visibility,
|
||||
OwnerID: p.OwnerID,
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (s *skillManageFilesStore) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
skill, ok := s.skills[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if version, ok := updates["version"].(int); ok {
|
||||
skill.Version = version
|
||||
if version > s.nextBySlug[skill.Slug] {
|
||||
s.nextBySlug[skill.Slug] = version
|
||||
}
|
||||
}
|
||||
if filePath, ok := updates["file_path"].(string); ok {
|
||||
skill.BaseDir = filePath
|
||||
skill.Path = filepath.Join(filePath, "SKILL.md")
|
||||
}
|
||||
if visibility, ok := updates["visibility"].(string); ok {
|
||||
skill.Visibility = visibility
|
||||
}
|
||||
s.lastUpdates[id] = maps.Clone(updates)
|
||||
s.skills[id] = skill
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) DeleteSkill(context.Context, uuid.UUID) error { return nil }
|
||||
func (s *skillManageFilesStore) ToggleSkill(context.Context, uuid.UUID, bool) error { return nil }
|
||||
func (s *skillManageFilesStore) GetSkillByID(_ context.Context, id uuid.UUID) (store.SkillInfo, bool) {
|
||||
info, ok := s.skills[id]
|
||||
return info, ok
|
||||
}
|
||||
func (s *skillManageFilesStore) GetSkillOwnerID(context.Context, uuid.UUID) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
func (s *skillManageFilesStore) GetSkillOwnerIDBySlug(_ context.Context, slug string) (string, bool) {
|
||||
owner, ok := s.owners[slug]
|
||||
return owner, ok
|
||||
}
|
||||
func (s *skillManageFilesStore) GetNextVersion(_ context.Context, slug string) int {
|
||||
return s.nextBySlug[slug] + 1
|
||||
}
|
||||
func (s *skillManageFilesStore) GetNextVersionLocked(_ context.Context, slug string) (int, func() error, error) {
|
||||
if s.beforeVersionLockHook != nil {
|
||||
s.beforeVersionLockHook(slug)
|
||||
}
|
||||
return s.GetNextVersion(context.Background(), slug), func() error { return nil }, nil
|
||||
}
|
||||
func (s *skillManageFilesStore) GetSkillHashBySlug(context.Context, string) (string, int, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (s *skillManageFilesStore) IsSystemSkill(string) bool { return false }
|
||||
func (s *skillManageFilesStore) ListAllSkills(context.Context) []store.SkillInfo { return nil }
|
||||
func (s *skillManageFilesStore) ListAllSystemSkills(context.Context) []store.SkillInfo {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) ListSystemSkillDirs(context.Context) map[string]string {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) StoreMissingDeps(context.Context, uuid.UUID, []string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) GrantToAgent(context.Context, uuid.UUID, uuid.UUID, int, string, ...bool) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) GrantToUser(context.Context, uuid.UUID, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageFilesStore) RevokeFromUser(context.Context, uuid.UUID, string) error { return nil }
|
||||
func (s *skillManageFilesStore) ListWithGrantStatus(context.Context, uuid.UUID) ([]store.SkillWithGrantStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *skillManageFilesStore) ListAgentGrantsForSkill(context.Context, uuid.UUID) ([]store.SkillAgentGrantInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *skillManageFilesStore) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *skillManageFilesStore) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) {
|
||||
return "", "", 0, false, false
|
||||
}
|
||||
|
||||
func derefString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
Reference in new issue
Block a user