mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 22:13:34 +00:00
662 lines
22 KiB
Go
662 lines
22 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// SkillManageTool provides agent-driven skill lifecycle management.
|
|
// Complements publish_skill (directory-based) with a content-string interface
|
|
// so agents can create/patch/delete skills without pre-writing files to disk.
|
|
type SkillManageTool struct {
|
|
skills store.SkillManageStore
|
|
base string // skills-store/ base directory (master tenant)
|
|
dataDir string // parent data dir for tenant-scoped skill paths
|
|
loader *skills.Loader // cache invalidation
|
|
}
|
|
|
|
func NewSkillManageTool(skills store.SkillManageStore, baseDir, dataDir string, loader *skills.Loader) *SkillManageTool {
|
|
return &SkillManageTool{skills: skills, base: baseDir, dataDir: dataDir, loader: loader}
|
|
}
|
|
|
|
// isOwnerOfSkill returns true if the caller owns the skill identified by slug.
|
|
// Matches owner_id against three identities for backward compatibility (#915):
|
|
// - ActorIDFromContext: current helper, merge-aware in DM, sender in groups
|
|
// - UserIDFromContext: legacy rows pre-#915 (group-scope) and merged tenant id
|
|
// - SenderIDFromContext: raw channel sender (covers the pre-merge-aware ActorID window)
|
|
//
|
|
// If the slug does not exist, returns true (caller sees "skill not found" error
|
|
// from the downstream resolver — preserves the existing error surface).
|
|
func isOwnerOfSkill(ctx context.Context, skills store.SkillManageStore, slug string) bool {
|
|
ownerID, found := skills.GetSkillOwnerIDBySlug(ctx, slug)
|
|
if !found {
|
|
return true
|
|
}
|
|
actorID := store.ActorIDFromContext(ctx)
|
|
userID := store.UserIDFromContext(ctx)
|
|
senderID := store.SenderIDFromContext(ctx)
|
|
return ownerID == actorID || ownerID == userID || ownerID == senderID
|
|
}
|
|
|
|
func canManageSkill(ctx context.Context, skills store.SkillManageStore, info *store.SkillInfo) bool {
|
|
if isOwnerOfSkill(ctx, skills, info.Slug) {
|
|
return true
|
|
}
|
|
if info.ID == "" {
|
|
return false
|
|
}
|
|
skillID, err := uuid.Parse(info.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if agentID == uuid.Nil {
|
|
return false
|
|
}
|
|
ok, err := skills.AgentCanManageSkill(ctx, skillID, agentID)
|
|
if err != nil {
|
|
slog.Warn("skill_manage: manage grant check failed", "skill", info.Slug, "agent_id", agentID, "error", err)
|
|
return false
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// tenantSkillsDir returns the skills-store directory scoped to the calling agent's tenant.
|
|
func (t *SkillManageTool) tenantSkillsDir(ctx context.Context) string {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
slug := store.TenantSlugFromContext(ctx)
|
|
return config.TenantSkillsStoreDir(t.dataDir, tid, slug)
|
|
}
|
|
|
|
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 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."
|
|
}
|
|
|
|
func (t *SkillManageTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"action": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"create", "patch", "delete"},
|
|
"description": "Operation to perform on the skill.",
|
|
},
|
|
"slug": map[string]any{
|
|
"type": "string",
|
|
"description": "Unique skill identifier (lowercase alphanumeric + hyphens). Required for patch/delete. For create: auto-derived from 'name' frontmatter field if omitted.",
|
|
},
|
|
"content": map[string]any{
|
|
"type": "string",
|
|
"description": "Full SKILL.md content including YAML frontmatter (---\\nname: ...\\n---\\n# ...). Required for create.",
|
|
},
|
|
"find": map[string]any{
|
|
"type": "string",
|
|
"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",
|
|
"description": "Replacement text. Required for patch.",
|
|
},
|
|
"visibility": map[string]any{
|
|
"type": "string",
|
|
"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"},
|
|
}
|
|
}
|
|
|
|
func (t *SkillManageTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
action, _ := args["action"].(string)
|
|
switch action {
|
|
case "create":
|
|
return t.executeCreate(ctx, args)
|
|
case "patch":
|
|
return t.executePatch(ctx, args)
|
|
case "delete":
|
|
return t.executeDelete(ctx, args)
|
|
default:
|
|
return ErrorResult("action must be one of: create, patch, delete")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
if strings.TrimSpace(content) == "" {
|
|
return ErrorResult("content is required for action=create")
|
|
}
|
|
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 {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
visibility := skills.NormalizeVisibility(rawVisibility)
|
|
|
|
// Security scan before any disk write
|
|
violations, safe := skills.GuardSkillContent(content)
|
|
if !safe {
|
|
return ErrorResult(skills.FormatGuardViolations(violations))
|
|
}
|
|
|
|
// Parse frontmatter
|
|
name, description, slug, frontmatter := skills.ParseSkillFrontmatter(content)
|
|
if name == "" {
|
|
return ErrorResult("SKILL.md frontmatter must contain 'name' field")
|
|
}
|
|
|
|
// Allow slug override from args
|
|
if argSlug, _ := args["slug"].(string); argSlug != "" {
|
|
slug = argSlug
|
|
}
|
|
if slug == "" {
|
|
slug = skills.Slugify(name)
|
|
}
|
|
if !skills.SlugRegexp.MatchString(slug) {
|
|
return ErrorResult(fmt.Sprintf("invalid slug %q: must be lowercase alphanumeric with hyphens", slug))
|
|
}
|
|
if t.skills.IsSystemSkill(slug) {
|
|
return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug))
|
|
}
|
|
|
|
// Version + destination (tenant-scoped)
|
|
version := t.skills.GetNextVersion(ctx, slug)
|
|
destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", version))
|
|
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)
|
|
skillPath := filepath.Join(destDir, "SKILL.md")
|
|
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, 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).
|
|
ownerID := store.ActorIDFromContext(ctx)
|
|
if ownerID == "" {
|
|
ownerID = "system"
|
|
}
|
|
desc := description
|
|
id, err := t.skills.CreateSkillManaged(ctx, store.SkillCreateParams{
|
|
Name: name,
|
|
Slug: slug,
|
|
Description: &desc,
|
|
OwnerID: ownerID,
|
|
Visibility: visibility,
|
|
Version: version,
|
|
FilePath: destDir,
|
|
FileSize: fileSize,
|
|
FileHash: &fileHash,
|
|
Frontmatter: frontmatter,
|
|
})
|
|
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)
|
|
|
|
// Auto-grant to calling agent (granted-by = owner, same as CreateSkillManaged)
|
|
granted := false
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
if agentID != uuid.Nil {
|
|
if err := t.skills.GrantToAgent(ctx, id, agentID, version, ownerID, true); err != nil {
|
|
slog.Warn("skill_manage: auto-grant failed", "error", err)
|
|
} else {
|
|
granted = true
|
|
}
|
|
}
|
|
|
|
if t.loader != nil {
|
|
t.loader.BumpVersion()
|
|
}
|
|
|
|
// Dep scan (best-effort, warn only)
|
|
var depsWarning string
|
|
manifest := skills.ScanSkillDeps(destDir)
|
|
if manifest != nil && !manifest.IsEmpty() {
|
|
ok, missing := skills.CheckSkillDeps(manifest)
|
|
if !ok {
|
|
_ = t.skills.StoreMissingDeps(ctx, id, missing)
|
|
depsWarning = skills.FormatMissing(missing)
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
result += "\n\nSkill will appear in search on next turn."
|
|
if depsWarning != "" {
|
|
result += fmt.Sprintf("\n\n⚠ Missing dependencies: %s", depsWarning)
|
|
}
|
|
return NewResult(result)
|
|
}
|
|
|
|
// executePatch applies a find/replace to the latest version and saves as a new version.
|
|
func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) *Result {
|
|
slug, _ := args["slug"].(string)
|
|
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), 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)
|
|
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))
|
|
}
|
|
|
|
// Ownership check: only the skill owner can patch.
|
|
// Accept any of three identities the same human maps to:
|
|
// - actor (current merge-aware helper — preferred for new rows)
|
|
// - userID (legacy pre-#915 rows where owner_id was group principal
|
|
// or the merged tenant identity)
|
|
// - senderID (legacy rows from the pre-merge-aware ActorID window
|
|
// where DM owners got the raw channel sender)
|
|
// A DM user merged to "viettx" with Telegram ID "386246614" matches all
|
|
// three of their skills regardless of when they were created.
|
|
if !canManageSkill(ctx, t.skills, info) {
|
|
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
newVisibility := skills.NormalizeVisibility(rawVisibility)
|
|
if err := t.skills.UpdateSkill(ctx, skillID, map[string]any{
|
|
"visibility": newVisibility,
|
|
"updated_at": time.Now(),
|
|
}); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to update skill visibility: %v", err))
|
|
}
|
|
slog.Info("skill_manage: visibility updated", "slug", slug, "visibility", newVisibility)
|
|
if t.loader != nil {
|
|
t.loader.BumpVersion()
|
|
}
|
|
return NewResult(fmt.Sprintf("Skill %q visibility set to %s.", slug, newVisibility))
|
|
}
|
|
|
|
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 := 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")
|
|
}
|
|
|
|
// Security scan on patched content
|
|
violations, safe := skills.GuardSkillContent(patched)
|
|
if !safe {
|
|
return ErrorResult(skills.FormatGuardViolations(violations))
|
|
}
|
|
|
|
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)
|
|
if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), patchedBytes, 0644); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write patched SKILL.md: %v", 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, 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)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err))
|
|
}
|
|
updates := map[string]any{
|
|
"version": newVer,
|
|
"file_path": destDir,
|
|
"file_size": fileSize,
|
|
"file_hash": &fileHash,
|
|
"updated_at": time.Now(),
|
|
}
|
|
if rawVisibility != "" {
|
|
updates["visibility"] = skills.NormalizeVisibility(rawVisibility)
|
|
}
|
|
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, "companion_files", len(companionFiles))
|
|
|
|
if t.loader != nil {
|
|
t.loader.BumpVersion()
|
|
}
|
|
|
|
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/.
|
|
func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any) *Result {
|
|
slug, _ := args["slug"].(string)
|
|
if slug == "" {
|
|
return ErrorResult("slug is required for action=delete")
|
|
}
|
|
|
|
info, ok := t.skills.GetSkill(ctx, slug)
|
|
if !ok {
|
|
return ErrorResult(fmt.Sprintf("skill %q not found or already archived", slug))
|
|
}
|
|
if t.skills.IsSystemSkill(slug) {
|
|
return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug))
|
|
}
|
|
|
|
// Ownership check: only the skill owner can delete.
|
|
// Same three-identity match as the patch flow above (#915).
|
|
if !canManageSkill(ctx, t.skills, info) {
|
|
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
|
|
}
|
|
|
|
// Soft-delete on disk: move to .trash/<slug>.<unix-timestamp>
|
|
skillsDir := t.tenantSkillsDir(ctx)
|
|
trashDir := filepath.Join(skillsDir, ".trash")
|
|
if err := os.MkdirAll(trashDir, 0755); err != nil {
|
|
slog.Warn("skill_manage: failed to create .trash dir", "error", err)
|
|
} else {
|
|
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
|
src := filepath.Join(skillsDir, slug)
|
|
dst := filepath.Join(trashDir, slug+"."+timestamp)
|
|
if err := os.Rename(src, dst); err != nil {
|
|
// Cross-device rename fails on some setups — log and continue (DB archive is primary)
|
|
slog.Warn("skill_manage: disk move to .trash failed", "slug", slug, "error", err)
|
|
}
|
|
}
|
|
|
|
// DB archive
|
|
skillID, err := uuid.Parse(info.ID)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err))
|
|
}
|
|
if err := t.skills.DeleteSkill(ctx, skillID); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to archive skill in database: %v", err))
|
|
}
|
|
|
|
slog.Info("skill_manage: deleted", "slug", slug, "id", info.ID)
|
|
|
|
if t.loader != nil {
|
|
t.loader.BumpVersion()
|
|
}
|
|
|
|
return NewResult(fmt.Sprintf("Skill %q deleted and removed from search.", slug))
|
|
}
|
|
|
|
// maxCopySize limits total companion file copy to 20MB (matching publish_skill).
|
|
const maxCopySize = 20 << 20
|
|
|
|
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) {
|
|
return skills.ValidateSkillTargetPath(rawPath, false)
|
|
}
|
|
|
|
func collectExistingManagedSkillCompanionFiles(srcDir string) ([]managedSkillFile, error) {
|
|
var out []managedSkillFile
|
|
var totalSize int64
|
|
err := filepath.WalkDir(srcDir, func(filePath string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(srcDir, filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || rel == "SKILL.md" {
|
|
return nil
|
|
}
|
|
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 nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
totalSize += info.Size()
|
|
if totalSize > maxCopySize {
|
|
return fmt.Errorf("companion files exceed %d bytes limit", maxCopySize)
|
|
}
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
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
|
|
}
|