// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package filesystem import ( "bytes" stdctx "context" "encoding/json" "fmt" "mime/multipart" "net/url" "os" "path/filepath" "ragflow/internal/common" "regexp" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" "gopkg.in/yaml.v3" ) // SkillProvider handles skill operations using /skills API // Path structure: // - skills/ -> List all hubs // - skills/{space_id}/ -> List skills in space // - skills/{space_id}/{skill_name}/ -> List versions of skill // - skills/{space_id}/{skill_name}/{version}/ -> Get skill version info // // Note: Uses Go backend API (useAPIBase=true): // - GET /skills/hubs -> List all hubs // - POST /skills/search -> Search skills // - POST /skills/index -> Index skills // - DELETE /skills/index/{skill_id} -> Delete skill index // ============================================================================ // Constants // ============================================================================ const ( MaxSkillTotalSize = 50 * 1024 * 1024 // 50MB MaxSkillFileSize = 5 * 1024 * 1024 // 5MB per file DefaultSpaceID = "default" ) // Text file extensions allowed in skills var textFileExtensions = map[string]bool{ "md": true, "mdx": true, "txt": true, "json": true, "json5": true, "yaml": true, "yml": true, "toml": true, "js": true, "cjs": true, "mjs": true, "ts": true, "tsx": true, "jsx": true, "py": true, "sh": true, "rb": true, "go": true, "rs": true, "swift": true, "kt": true, "java": true, "cs": true, "cpp": true, "c": true, "h": true, "hpp": true, "sql": true, "csv": true, "ini": true, "cfg": true, "env": true, "xml": true, "html": true, "css": true, "scss": true, "sass": true, "svg": true, } // Default ignore patterns var defaultIgnorePatterns = []string{ ".git/", ".svn/", ".hg/", "node_modules/", "__MACOSX/", ".DS_Store", "._*", "*.log", "*.tmp", "*.temp", "*.swp", "*.swo", "*~", ".env", ".env.*", ".vscode/", ".idea/", "Thumbs.db", "desktop.ini", ".skill-meta.json", } // ============================================================================ // Types // ============================================================================ // SkillMetadata represents the metadata from SKILL.md frontmatter type SkillMetadata struct { Name string `yaml:"name"` Description string `yaml:"description"` Version string `yaml:"version"` Author string `yaml:"author"` Tags []string `yaml:"tags"` Tools interface{} `yaml:"tools"` } // SkillValidationResult represents the result of skill validation type SkillValidationResult struct { Valid bool Name string Description string Version string Tags []string Error string Details string } // SkillFile represents a file in the skill directory type SkillFile struct { Path string Content []byte Size int64 } // SkillConflictError represents a conflict error type SkillConflictError struct { Type string // "name" or "version" Name string Version string } func (e *SkillConflictError) Error() string { if e.Type == "version" { return fmt.Sprintf("version conflict: version '%s' already exists for skill '%s'", e.Version, e.Name) } return fmt.Sprintf("name conflict: skill '%s' already exists", e.Name) } // ============================================================================ // SkillProvider // ============================================================================ type SkillProvider struct { BaseProvider httpClient HTTPClientInterface } // NewSkillProvider creates a new SkillProvider func NewSkillProvider(httpClient HTTPClientInterface) *SkillProvider { return &SkillProvider{ BaseProvider: BaseProvider{ name: "skills", description: "Skills provider for skill management and search", rootPath: "skills", }, httpClient: httpClient, } } // Supports returns true if this provider can handle the given path func (p *SkillProvider) Supports(path string) bool { normalized := normalizePath(path) return normalized == "skills" || strings.HasPrefix(normalized, "skills/") } // isUUID checks if a string is a valid UUID func isUUID(s string) bool { _, err := uuid.Parse(s) return err == nil } // List lists nodes at the given path // Path structure: skills/ or skills/{space_id}/ or skills/{space_id}/{skill_name}/... func (p *SkillProvider) List(ctx stdctx.Context, subPath string, opts *ListOptions) (*Result, error) { if subPath == "" { // List all hubs return p.listSpaces(ctx, opts) } parts := SplitPath(subPath) switch len(parts) { case 1: // skills/{space_id} - list skills in space return p.listSkillsInSpace(ctx, parts[0], opts) case 2: // skills/{space_id}/{skill_name} - list versions of skill return p.listSkillVersions(ctx, parts[0], parts[1], opts) default: // skills/{space_id}/{skill_name}/{version}/... - skill content return p.listSkillContent(ctx, parts[0], parts[1], parts[2], parts[3:], opts) } } // Search searches for skills matching the query func (p *SkillProvider) Search(ctx stdctx.Context, subPath string, opts *SearchOptions) (*Result, error) { if opts == nil || opts.Query == "" { return nil, fmt.Errorf("search query is required") } // Parse space from path spaceName := "" parts := SplitPath(subPath) if len(parts) > 0 { spaceName = parts[0] } // Space ID can be either a name or UUID // If it's not "default" and doesn't look like a UUID, try to convert it spaceID := spaceName if spaceID != "" && spaceID != "default" && !isUUID(spaceID) { spaceUUID, err := p.getSpaceUUIDByName(ctx, spaceID) if err == nil { spaceID = spaceUUID } // If lookup fails, use the original spaceID as-is (it might already be a UUID) } // Build search payload page := 1 pageSize := 10 if opts.Limit > 0 { pageSize = opts.Limit } if opts.Offset > 0 { page = (opts.Offset / pageSize) + 1 } payload := map[string]interface{}{ "query": opts.Query, "space_id": spaceID, "page": page, "page_size": pageSize, } // Call skill search API resp, err := p.httpClient.Request("POST", "/skills/search", "auto", nil, payload) if err != nil { return nil, fmt.Errorf("search request failed: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Skills []struct { SkillID string `json:"skill_id"` Name string `json:"name"` Description string `json:"description"` Tags []string `json:"tags"` Score float64 `json:"score"` BM25Score float64 `json:"bm25_score,omitempty"` VectorScore float64 `json:"vector_score,omitempty"` CreateTime int64 `json:"create_time,omitempty"` } `json:"skills"` Total int `json:"total"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if result.Code != 0 { return nil, fmt.Errorf("search failed: %s", result.Msg) } // Convert to Result format nodes := make([]*Node, 0, len(result.Data.Skills)) for _, skill := range result.Data.Skills { var createdAt time.Time if skill.CreateTime > 0 { createdAt = time.UnixMilli(skill.CreateTime) } nodes = append(nodes, &Node{ Name: skill.Name, Type: NodeTypeDirectory, Path: fmt.Sprintf("skills/%s/%s", spaceName, skill.Name), CreatedAt: createdAt, UpdatedAt: createdAt, Metadata: map[string]interface{}{ "skill_id": skill.SkillID, "score": skill.Score, "bm25_score": skill.BM25Score, "vector_score": skill.VectorScore, "tags": skill.Tags, "description": skill.Description, }, }) } return &Result{ Nodes: nodes, Total: result.Data.Total, }, nil } // searchSkillsFromFileSystem performs a simple name-based search via file system // when the search index is unavailable or empty. func (p *SkillProvider) searchSkillsFromFileSystem(ctx stdctx.Context, spaceName string, opts *SearchOptions) (*Result, error) { listOpts := &ListOptions{ Limit: opts.Limit, Offset: opts.Offset, } result, err := p.listSkillsInSpaceFromFileSystem(ctx, spaceName, listOpts) if err != nil { return nil, err } queryLower := strings.ToLower(opts.Query) var matched []*Node for _, node := range result.Nodes { if strings.Contains(strings.ToLower(node.Name), queryLower) { matched = append(matched, node) } } return &Result{ Nodes: matched, Total: len(matched), }, nil } // Cat retrieves the content of a skill file at the given path // Path structure: skills/{space_id}/{skill_name}/{version}/.../{file_path} func (p *SkillProvider) Cat(ctx stdctx.Context, path string) ([]byte, error) { parts := SplitPath(path) if len(parts) < 4 { return nil, fmt.Errorf("invalid file path: %s (expected: skills/{space}/{skill}/{version}/.../{file})", path) } spaceID := parts[0] skillName := parts[1] version := parts[2] _ = JoinPath(parts[3:]...) // file path within version folder (used for nested directories) // Get the skill folder ID (search API or file system fallback) skillFolderID, err := p.getSkillFolderID(ctx, spaceID, skillName) if err != nil { return nil, fmt.Errorf("skill '%s' not found in space '%s': %w", skillName, spaceID, err) } // Find the version folder filesResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", skillFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list versions: %w", err) } var filesResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(filesResp.Body, &filesResult); err != nil { return nil, fmt.Errorf("failed to parse files response: %w", err) } if filesResult.Code != 0 { return nil, fmt.Errorf("failed to list files: %s", filesResult.Msg) } // Find the version folder var versionFolderID string for _, file := range filesResult.Data.Files { if file.Name == version && file.Type == "folder" { versionFolderID = file.ID break } } if versionFolderID == "" { return nil, fmt.Errorf("version '%s' not found for skill '%s'", version, skillName) } // Step 4: Navigate to the file through the path currentFolderID := versionFolderID pathParts := parts[3:] // If there's a directory path before the file, navigate through it for i := 0; i < len(pathParts)-1; i++ { subResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", currentFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to navigate path: %w", err) } var subResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(subResp.Body, &subResult); err != nil { return nil, fmt.Errorf("failed to parse navigation response: %w", err) } if subResult.Code != 0 { return nil, fmt.Errorf("navigation failed: %s", subResult.Msg) } found := false for _, file := range subResult.Data.Files { if file.Name == pathParts[i] { if file.Type != "folder" { return nil, fmt.Errorf("'%s' is not a directory", pathParts[i]) } currentFolderID = file.ID found = true break } } if !found { return nil, fmt.Errorf("directory not found: %s", pathParts[i]) } } // Step 5: Find the file in the current directory fileName := pathParts[len(pathParts)-1] finalResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", currentFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list directory: %w", err) } var finalResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Location string `json:"location"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(finalResp.Body, &finalResult); err != nil { return nil, fmt.Errorf("failed to parse final response: %w", err) } if finalResult.Code != 0 { return nil, fmt.Errorf("failed to list files: %s", finalResult.Msg) } // Find the file var fileID string for _, file := range finalResult.Data.Files { if file.Name == fileName { fileID = file.ID break } } if fileID == "" { return nil, fmt.Errorf("file '%s' not found", fileName) } // Step 6: Download the file content // First get file info to get the download URL contentResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files/%s", fileID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to get file info: %w", err) } // For now, return a placeholder - actual file download may need storage access // The file content is stored in the storage backend return contentResp.Body, nil } // listHubs lists all skills spaces func (p *SkillProvider) listSpaces(ctx stdctx.Context, opts *ListOptions) (*Result, error) { resp, err := p.httpClient.Request("GET", "/skills/spaces", "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list hubs: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Spaces []struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` } `json:"spaces"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("failed to parse hubs response: %w", err) } if result.Code != 0 { return nil, fmt.Errorf("failed to list hubs: %s", result.Msg) } nodes := make([]*Node, 0, len(result.Data.Spaces)) for _, space := range result.Data.Spaces { nodes = append(nodes, &Node{ Name: space.Name, Type: NodeTypeDirectory, Path: fmt.Sprintf("skills/%s", space.Name), Metadata: map[string]interface{}{ "id": space.ID, "description": space.Description, }, }) } return &Result{ Nodes: nodes, Total: len(nodes), }, nil } // listSkillsInSpace lists skills in a specific space // First tries search API (supports pagination & sorting), falls back to file system if search returns empty func (p *SkillProvider) listSkillsInSpace(ctx stdctx.Context, spaceName string, opts *ListOptions) (*Result, error) { // Get space UUID for search API spaceUUID, err := p.getSpaceUUIDByName(ctx, spaceName) if err != nil { return nil, fmt.Errorf("space '%s' not found: %w", spaceName, err) } // Set default limit to 10 if not specified limit := opts.Limit if limit <= 0 { limit = 10 } // Try search API first (supports pagination, sorting, and large collections) payload := map[string]interface{}{ "query": "", // Empty query = list all (match_all) "space_id": spaceUUID, "page": 1, "page_size": limit, "sort_by": opts.SortBy, "sort_order": opts.SortOrder, } common.Debug("Listing skills via search API", zap.String("space", spaceName), zap.String("spaceUUID", spaceUUID), zap.Int("limit", limit)) resp, err := p.httpClient.Request("POST", "/skills/search", "auto", nil, payload) if err == nil { var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Skills []struct { SkillID string `json:"skill_id"` Name string `json:"name"` Description string `json:"description"` Tags []string `json:"tags"` Score float64 `json:"score"` CreateTime int64 `json:"create_time,omitempty"` UpdateTime int64 `json:"update_time,omitempty"` } `json:"skills"` Total int64 `json:"total"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err == nil && result.Code == 0 { common.Debug("Search API response", zap.Int("skills_count", len(result.Data.Skills)), zap.Int64("total", result.Data.Total)) // If search returned results, use them if len(result.Data.Skills) > 0 { nodes := make([]*Node, 0, len(result.Data.Skills)) for _, skill := range result.Data.Skills { updatedAt := time.UnixMilli(skill.UpdateTime) if skill.UpdateTime == 0 { updatedAt = time.UnixMilli(skill.CreateTime) } nodes = append(nodes, &Node{ Name: skill.Name, Type: NodeTypeDirectory, Path: fmt.Sprintf("skills/%s/%s", spaceName, skill.Name), UpdatedAt: updatedAt, Metadata: map[string]interface{}{ "id": skill.SkillID, "tags": skill.Tags, "score": skill.Score, "description": skill.Description, }, }) } common.Info("Listed skills via SEARCH", zap.String("space", spaceName), zap.Int("count", len(nodes)), zap.Int64("total", result.Data.Total)) return &Result{ Nodes: nodes, Total: int(result.Data.Total), HasMore: int(result.Data.Total) > limit, NextOffset: limit, }, nil } // Search returned empty result, fall through to file system common.Debug("Search returned empty result, falling back to file system") } else { common.Debug("Search API error", zap.Error(err), zap.Int("code", result.Code), zap.String("msg", result.Msg)) } } else { common.Debug("Search request failed", zap.Error(err)) } // Fall back to file system listing (for skills not yet indexed) common.Info("Listing skills via FILE SYSTEM (search unavailable)", zap.String("space", spaceName)) return p.listSkillsInSpaceFromFileSystem(ctx, spaceName, opts) } // listSkillsInSpaceFromFileSystem lists skills from file system (fallback when search returns empty) func (p *SkillProvider) listSkillsInSpaceFromFileSystem(ctx stdctx.Context, spaceName string, opts *ListOptions) (*Result, error) { // Get the skills space folder ID from file system skillsFolderID, err := p.getSkillsFolderID(ctx) if err != nil { return nil, fmt.Errorf("failed to get skills folder: %w", err) } common.Debug("Got skills folder ID", zap.String("skillsFolderID", skillsFolderID)) // Find the space folder spaceFolderID, err := p.findFolderID(ctx, skillsFolderID, spaceName) if err != nil { return nil, fmt.Errorf("failed to find space folder: %w", err) } common.Debug("Got space folder ID", zap.String("spaceName", spaceName), zap.String("spaceFolderID", spaceFolderID)) // List all subfolders in the space folder (each subfolder is a skill) skillsResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", spaceFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list skills: %w", err) } var skillsResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` UpdateTime int64 `json:"update_time"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(skillsResp.Body, &skillsResult); err != nil { return nil, fmt.Errorf("failed to parse skills response: %w", err) } if skillsResult.Code != 0 { return nil, fmt.Errorf("failed to list skills: %s", skillsResult.Msg) } common.Debug("File system list response", zap.Int("files_count", len(skillsResult.Data.Files))) // Convert folders to nodes nodes := make([]*Node, 0) for _, file := range skillsResult.Data.Files { // Only include folders (skill directories) if file.Type == "folder" { nodes = append(nodes, &Node{ Name: file.Name, Type: NodeTypeDirectory, Path: fmt.Sprintf("skills/%s/%s", spaceName, file.Name), UpdatedAt: time.UnixMilli(file.UpdateTime), Metadata: map[string]interface{}{ "id": file.ID, }, }) } } // Apply limit limit := opts.Limit if limit <= 0 { limit = 10 } total := len(nodes) if len(nodes) > limit { nodes = nodes[:limit] } common.Info("Listed skills via FILE SYSTEM", zap.String("space", spaceName), zap.Int("count", len(nodes)), zap.Int("total", total)) return &Result{ Nodes: nodes, Total: total, HasMore: total > limit, NextOffset: limit, }, nil } // getSkillsFolderID gets the ID of the 'skills' folder func (p *SkillProvider) getSkillsFolderID(ctx stdctx.Context) (string, error) { resp, err := p.httpClient.Request("GET", "/files", "auto", nil, nil) if err != nil { return "", fmt.Errorf("failed to list root folders: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if result.Code != 0 { return "", fmt.Errorf("failed to list folders: %s", result.Msg) } for _, file := range result.Data.Files { if file.Name == "skills" && file.Type == "folder" { return file.ID, nil } } return "", fmt.Errorf("skills folder not found") } // findFolderID finds a folder by name under a parent folder func (p *SkillProvider) findFolderID(ctx stdctx.Context, parentID, folderName string) (string, error) { resp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", parentID), "auto", nil, nil) if err != nil { return "", fmt.Errorf("failed to list folders: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if result.Code != 0 { return "", fmt.Errorf("failed to list folders: %s", result.Msg) } for _, file := range result.Data.Files { if file.Name == folderName && file.Type == "folder" { return file.ID, nil } } return "", fmt.Errorf("folder '%s' not found", folderName) } // getSkillFolderID gets the folder ID of a skill in a space. // First tries the search API (which may have cached folder_id from indexing), // then falls back to direct file system traversal. func (p *SkillProvider) getSkillFolderID(ctx stdctx.Context, spaceID, skillName string) (string, error) { // Try search API first spaceUUID, err := p.getSpaceUUIDByName(ctx, spaceID) if err == nil { payload := map[string]interface{}{ "query": skillName, "space_id": spaceUUID, "page": 1, "page_size": 10, } resp, err := p.httpClient.Request("POST", "/skills/search", "auto", nil, payload) if err == nil { var searchResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Skills []struct { SkillID string `json:"skill_id"` FolderID string `json:"folder_id"` Name string `json:"name"` } `json:"skills"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &searchResult); err == nil && searchResult.Code == 0 { for _, skill := range searchResult.Data.Skills { if skill.Name == skillName { return skill.FolderID, nil } } } } } // Fallback: traverse file system directly skillsFolderID, err := p.getSkillsFolderID(ctx) if err != nil { return "", err } spaceFolderID, err := p.findFolderID(ctx, skillsFolderID, spaceID) if err != nil { return "", err } return p.findFolderID(ctx, spaceFolderID, skillName) } // listSkillVersions lists versions of a skill func (p *SkillProvider) listSkillVersions(ctx stdctx.Context, spaceID, skillName string, opts *ListOptions) (*Result, error) { skillFolderID, err := p.getSkillFolderID(ctx, spaceID, skillName) if err != nil { return nil, fmt.Errorf("skill '%s' not found in space '%s'", skillName, spaceID) } // List the skill folder to get versions (subdirectories) filesResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", skillFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list versions: %w", err) } var filesResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` UpdateTime int64 `json:"update_time"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(filesResp.Body, &filesResult); err != nil { return nil, fmt.Errorf("failed to parse files response: %w", err) } if filesResult.Code != 0 { return nil, fmt.Errorf("failed to list files: %s", filesResult.Msg) } // Convert version folders to nodes nodes := make([]*Node, 0) for _, file := range filesResult.Data.Files { // Only include folders (version directories) if file.Type == "folder" { nodes = append(nodes, &Node{ Name: file.Name, Type: NodeTypeDirectory, Path: fmt.Sprintf("skills/%s/%s/%s", spaceID, skillName, file.Name), UpdatedAt: time.UnixMilli(file.UpdateTime), Metadata: map[string]interface{}{ "id": file.ID, }, }) } } return &Result{ Nodes: nodes, Total: len(nodes), }, nil } // listSkillContent lists content of a specific skill version func (p *SkillProvider) listSkillContent(ctx stdctx.Context, spaceID, skillName, version string, extraParts []string, opts *ListOptions) (*Result, error) { // Skill content is stored in file system under skills/{space}/{skill}/{version}/ // We need to traverse the file system to find the skill folder and list its contents // Get the skill folder ID (search API or file system fallback) skillFolderID, err := p.getSkillFolderID(ctx, spaceID, skillName) if err != nil { return nil, fmt.Errorf("skill '%s' not found in space '%s'", skillName, spaceID) } // List the version folder under the skill folder filesResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", skillFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list skill versions: %w", err) } var filesResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` UpdateTime int64 `json:"update_time"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(filesResp.Body, &filesResult); err != nil { return nil, fmt.Errorf("failed to parse files response: %w", err) } if filesResult.Code != 0 { return nil, fmt.Errorf("failed to list files: %s", filesResult.Msg) } // Find the version folder var versionFolderID string for _, file := range filesResult.Data.Files { if file.Name == version && file.Type == "folder" { versionFolderID = file.ID break } } if versionFolderID == "" { return nil, fmt.Errorf("version '%s' not found for skill '%s'", version, skillName) } // Step 4: If there are extra parts, navigate deeper currentFolderID := versionFolderID currentPath := fmt.Sprintf("skills/%s/%s/%s", spaceID, skillName, version) // Check if the last part is a file (for ls on a specific file) var lastFile *struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` UpdateTime int64 `json:"update_time"` } for i, part := range extraParts { isLastPart := (i == len(extraParts)-1) // List current folder to find the next part subResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", currentFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to navigate path: %w", err) } var subResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` UpdateTime int64 `json:"update_time"` } `json:"files"` } `json:"data"` } if err := json.Unmarshal(subResp.Body, &subResult); err != nil { return nil, fmt.Errorf("failed to parse navigation response: %w", err) } if subResult.Code != 0 { return nil, fmt.Errorf("navigation failed: %s", subResult.Msg) } found := false for _, file := range subResult.Data.Files { if file.Name == part { if file.Type != "folder" { // This is a file if isLastPart { // If it's the last part, remember the file for listing lastFile = &file found = true break } // Not the last part - cannot navigate into a file return nil, fmt.Errorf("'%s' is not a directory", part) } currentFolderID = file.ID currentPath = currentPath + "/" + part found = true break } } if !found { return nil, fmt.Errorf("path not found: %s", part) } // If we found a file as the last part, return it if lastFile != nil { return &Result{ Nodes: []*Node{{ Name: lastFile.Name, Type: NodeTypeFile, Path: currentPath + "/" + lastFile.Name, Metadata: map[string]interface{}{ "id": lastFile.ID, "size": lastFile.Size, "update_time": lastFile.UpdateTime, }, }}, Total: 1, }, nil } } // Step 5: List the final folder contents finalResp, err := p.httpClient.Request("GET", fmt.Sprintf("/files?parent_id=%s", currentFolderID), "auto", nil, nil) if err != nil { return nil, fmt.Errorf("failed to list folder contents: %w", err) } var finalResult struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Files []struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` UpdateTime int64 `json:"update_time"` } `json:"files"` Total int `json:"total"` } `json:"data"` } if err := json.Unmarshal(finalResp.Body, &finalResult); err != nil { return nil, fmt.Errorf("failed to parse final response: %w", err) } if finalResult.Code != 0 { return nil, fmt.Errorf("failed to list contents: %s", finalResult.Msg) } // Convert to nodes nodes := make([]*Node, 0, len(finalResult.Data.Files)) for _, file := range finalResult.Data.Files { nodeType := NodeTypeFile if file.Type == "folder" { nodeType = NodeTypeDirectory } nodes = append(nodes, &Node{ Name: file.Name, Type: nodeType, Path: currentPath + "/" + file.Name, Size: file.Size, UpdatedAt: time.UnixMilli(file.UpdateTime), Metadata: map[string]interface{}{ "id": file.ID, }, }) } return &Result{ Nodes: nodes, Total: len(nodes), }, nil } // getSpaceUUIDByName gets space UUID by its name func (p *SkillProvider) getSpaceUUIDByName(ctx stdctx.Context, spaceName string) (string, error) { resp, err := p.httpClient.Request("GET", "/skills/spaces", "auto", nil, nil) if err != nil { return "", fmt.Errorf("failed to list hubs: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { Spaces []struct { ID string `json:"id"` Name string `json:"name"` } `json:"spaces"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", fmt.Errorf("failed to parse hubs response: %w", err) } if result.Code != 0 { return "", fmt.Errorf("failed to list hubs: %s", result.Msg) } for _, space := range result.Data.Spaces { if space.Name == spaceName { return space.ID, nil } } return "", fmt.Errorf("space with name '%s' not found", spaceName) } // DeleteSkill deletes a skill and its index func (p *SkillProvider) DeleteSkill(ctx stdctx.Context, spaceID, skillName string) error { // Get space UUID spaceUUID, err := p.getSpaceUUIDByName(ctx, spaceID) if err != nil { return err } // Call delete skill index API // API format: DELETE /skills/index?skill_id={skill_name}&space_id={space_id} resp, err := p.httpClient.Request("DELETE", fmt.Sprintf("/skills/index?skill_id=%s&space_id=%s", url.QueryEscape(skillName), url.QueryEscape(spaceUUID)), "auto", nil, nil) if err != nil { return fmt.Errorf("delete index request failed: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"message"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return fmt.Errorf("failed to parse response: %w", err) } if result.Code != 0 { if result.Msg != "" { return fmt.Errorf("delete failed: %s", result.Msg) } return fmt.Errorf("delete failed with code: %d", result.Code) } return nil } // IndexSkill indexes a skill for search func (p *SkillProvider) IndexSkill(ctx stdctx.Context, spaceID string, skillInfo map[string]interface{}) error { // Get space UUID spaceUUID, err := p.getSpaceUUIDByName(ctx, spaceID) if err != nil { return err } // Get default embedding model embdID, _ := p.getDefaultEmbdID(ctx, spaceUUID) // Build index request payload := map[string]interface{}{ "skills": []interface{}{skillInfo}, "space_id": spaceUUID, "embd_id": embdID, } // Call index API resp, err := p.httpClient.Request("POST", "/skills/index", "auto", nil, payload) if err != nil { return fmt.Errorf("index request failed: %w", err) } var result struct { Code int `json:"code"` Msg string `json:"msg"` Data struct { IndexedCount int `json:"indexed_count"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return fmt.Errorf("failed to parse index response: %w", err) } if result.Code != 0 { return fmt.Errorf("index failed: %s", result.Msg) } return nil } // getDefaultEmbdID gets the default embedding model ID from skill search config func (p *SkillProvider) getDefaultEmbdID(ctx stdctx.Context, spaceID string) (string, error) { resp, err := p.httpClient.Request("GET", fmt.Sprintf("/skills/config?embd_id=&space_id=%s", url.QueryEscape(spaceID)), "web", nil, nil) if err != nil { return "", nil } var result struct { Code int `json:"code"` Msg string `json:"message"` Data struct { EmbdID string `json:"embd_id"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", nil } if result.Code != 0 { return "", nil } return result.Data.EmbdID, nil } // ============================================================================ // Skill Upload Functions // ============================================================================ // UploadSkill uploads a skill directory to the server // nameOverride: user-specified skill name (overrides SKILL.md metadata) func (p *SkillProvider) UploadSkill(ctx stdctx.Context, skillPath string, versionOverride string, spaceID string, fileProvider Provider, nameOverride string) error { spaceID = normalizeSpaceID(spaceID) // 1. Validate the skill directory result, files, err := ValidateSkillDirectory(skillPath, versionOverride, nameOverride) if err != nil { return fmt.Errorf("validation error: %w", err) } if !result.Valid { return fmt.Errorf("validation failed: %s", GetValidationErrorMessage(result)) } // Get skill name from validation result (SKILL.md metadata or user-specified) // Fallback to directory name if not specified skillName := result.Name if skillName == "" { skillName = filepath.Base(skillPath) skillName = normalizeSkillName(skillName) } // Use provided version or default version := result.Version if version == "" { version = "1.0.0" } // 2. Ensure skills space exists spaceFolderID, err := p.ensureSkillsSpaceFolder(ctx, spaceID, fileProvider) if err != nil { return fmt.Errorf("failed to ensure skills space: %w", err) } // 3. Get or create skill folder skillFolderID, err := p.getOrCreateSkillFolder(ctx, spaceID, spaceFolderID, skillName, fileProvider) if err != nil { return err } // 4. Check if version already exists exists, err := p.versionExists(ctx, spaceID, skillName, version, fileProvider) if err != nil { return fmt.Errorf("failed to check version: %w", err) } if exists { return &SkillConflictError{Type: "version", Name: skillName, Version: version} } // 5. Create version folder versionFolderID, err := p.createFolder(ctx, skillFolderID, version) if err != nil { return fmt.Errorf("failed to create version folder: %w", err) } // 6. Upload all files for _, file := range files { sanitized := sanitizeRelPath(file.Path) if sanitized == "" || isMacJunkPath(sanitized) || shouldIgnore(sanitized, defaultIgnorePatterns) { continue } err = p.uploadFile(ctx, file, versionFolderID) if err != nil { return fmt.Errorf("failed to upload file %s: %w", file.Path, err) } } // 7. Index the skill for search if err := p.indexSkillFromUpload(ctx, result, files, spaceID, skillFolderID); err != nil { return fmt.Errorf("failed to index skill: %w", err) } return nil } // ensureSkillsSpaceFolder ensures the 'skills/' folder exists func (p *SkillProvider) ensureSkillsSpaceFolder(ctx stdctx.Context, spaceID string, fileProvider Provider) (string, error) { skillsFolderID, err := p.ensureSkillsFolder(ctx, fileProvider) if err != nil { return "", err } result, err := fileProvider.List(ctx, "skills", nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == spaceID { return GetString(node.Metadata["id"]), nil } } return p.createFolder(ctx, skillsFolderID, spaceID) } // ensureSkillsFolder ensures the 'skills' folder exists func (p *SkillProvider) ensureSkillsFolder(ctx stdctx.Context, fileProvider Provider) (string, error) { result, err := fileProvider.List(ctx, "", nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == "skills" { return GetString(node.Metadata["id"]), nil } } return p.createFolder(ctx, "", "skills") } // getOrCreateSkillFolder gets existing skill folder or creates new one func (p *SkillProvider) getOrCreateSkillFolder(ctx stdctx.Context, spaceID, parentID, skillName string, fileProvider Provider) (string, error) { result, err := fileProvider.List(ctx, fmt.Sprintf("skills/%s", spaceID), nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == skillName { return GetString(node.Metadata["id"]), nil } } return p.createFolder(ctx, parentID, skillName) } // versionExists checks if a version already exists func (p *SkillProvider) versionExists(ctx stdctx.Context, spaceID, skillName, version string, fileProvider Provider) (bool, error) { result, err := fileProvider.List(ctx, fmt.Sprintf("skills/%s/%s", spaceID, skillName), nil) if err != nil { return false, err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == version { return true, nil } } return false, nil } // createFolder creates a new folder and returns its ID func (p *SkillProvider) createFolder(ctx stdctx.Context, parentID, name string) (string, error) { payload := map[string]interface{}{ "name": name, "type": "folder", } if parentID != "" { payload["parent_id"] = parentID } resp, err := p.httpClient.Request("POST", "/files", "auto", nil, payload) if err != nil { return "", err } var result struct { Code int `json:"code"` Data struct { ID string `json:"id"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", err } if result.Code != 0 { return "", fmt.Errorf("server returned error code: %d", result.Code) } return result.Data.ID, nil } // uploadFile uploads a single file using multipart form func (p *SkillProvider) uploadFile(ctx stdctx.Context, file *SkillFile, parentID string) error { var buf bytes.Buffer writer := multipart.NewWriter(&buf) if parentID != "" { writer.WriteField("parent_id", parentID) } part, err := writer.CreateFormFile("file", file.Path) if err != nil { return err } if _, err := part.Write(file.Content); err != nil { return err } writer.Close() return p.httpClient.UploadMultipart("/files", writer.FormDataContentType(), &buf) } // indexSkillFromUpload indexes the skill after upload func (p *SkillProvider) indexSkillFromUpload(ctx stdctx.Context, result *SkillValidationResult, files []*SkillFile, spaceID string, skillFolderID string) error { var contentBuilder strings.Builder for _, file := range files { if !isTextFile(file.Path, "") { continue } if len(file.Content) > MaxSkillFileSize { continue } sanitized := sanitizeRelPath(file.Path) if sanitized == "" || isMacJunkPath(sanitized) || shouldIgnore(sanitized, defaultIgnorePatterns) { continue } contentBuilder.WriteString(fmt.Sprintf("\n=== %s ===\n", file.Path)) contentBuilder.Write(file.Content) } content := contentBuilder.String() // Use skill name as ID (without version suffix) // This ensures all versions of the same skill share the same index document skillID := result.Name skillInfo := map[string]interface{}{ "id": skillID, "folder_id": skillFolderID, "name": result.Name, "description": result.Description, "tags": result.Tags, "content": content, "version": result.Version, } return p.IndexSkill(ctx, spaceID, skillInfo) } // ============================================================================ // Validation Functions // ============================================================================ // ValidateSkillDirectory validates a skill directory // nameOverride: user-specified skill name (overrides SKILL.md metadata) func ValidateSkillDirectory(skillPath string, versionOverride string, nameOverride string) (*SkillValidationResult, []*SkillFile, error) { info, err := os.Stat(skillPath) if err != nil { return nil, nil, fmt.Errorf("cannot access directory %s: %w", skillPath, err) } if !info.IsDir() { return nil, nil, fmt.Errorf("%s is not a directory", skillPath) } files, err := readSkillFiles(skillPath) if err != nil { return nil, nil, err } if len(files) == 0 { return &SkillValidationResult{Valid: false, Error: "no_files"}, nil, nil } var totalSize int64 for _, f := range files { totalSize += f.Size } if totalSize > MaxSkillTotalSize { return &SkillValidationResult{Valid: false, Error: "total_size_exceeded"}, nil, nil } var validFiles []*SkillFile for _, f := range files { if f.Size > MaxSkillFileSize { return &SkillValidationResult{ Valid: false, Error: "file_too_large", Details: f.Path, }, nil, nil } sanitized := sanitizeRelPath(f.Path) if sanitized == "" { return &SkillValidationResult{Valid: false, Error: "invalid_path"}, nil, nil } if isMacJunkPath(sanitized) || shouldIgnore(sanitized, defaultIgnorePatterns) { continue } validFiles = append(validFiles, f) } if len(validFiles) == 0 { return &SkillValidationResult{Valid: false, Error: "no_valid_files"}, nil, nil } var skillMdFile *SkillFile for _, f := range validFiles { normalized := strings.ToLower(f.Path) if normalized == "skill.md" || strings.HasSuffix(normalized, "/skill.md") { skillMdFile = f break } } if skillMdFile == nil { return &SkillValidationResult{Valid: false, Error: "missing_skill_md"}, nil, nil } metadata, err := parseFrontmatter(string(skillMdFile.Content)) if err != nil { return &SkillValidationResult{ Valid: false, Error: "invalid_frontmatter", Details: err.Error(), }, nil, nil } if metadata.Name == "" { return &SkillValidationResult{Valid: false, Error: "missing_name"}, nil, nil } if !isValidSkillName(metadata.Name) { return &SkillValidationResult{ Valid: false, Error: "invalid_name_format", Details: metadata.Name, }, nil, nil } version := versionOverride if version == "" { version = metadata.Version } // Set default version if not provided if version == "" { version = "1.0.0" } if !isValidSemver(version) { return &SkillValidationResult{ Valid: false, Error: "invalid_version", Details: version, }, nil, nil } for _, f := range validFiles { if !isTextFile(f.Path, "") { return &SkillValidationResult{ Valid: false, Error: "invalid_file_type", Details: f.Path, }, nil, nil } } // Use user-specified name if provided, otherwise use metadata.Name from SKILL.md skillName := metadata.Name if nameOverride != "" { skillName = nameOverride } return &SkillValidationResult{ Valid: true, Name: skillName, Description: metadata.Description, Version: version, Tags: metadata.Tags, }, validFiles, nil } // readSkillFiles recursively reads all files in the skill directory func readSkillFiles(skillPath string) ([]*SkillFile, error) { var files []*SkillFile err := filepath.Walk(skillPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { relPath, err := filepath.Rel(skillPath, path) if err != nil { return err } relPath = filepath.ToSlash(relPath) content, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read file %s: %w", path, err) } files = append(files, &SkillFile{ Path: relPath, Content: content, Size: info.Size(), }) } return nil }) return files, err } // parseFrontmatter extracts YAML frontmatter from markdown content func parseFrontmatter(content string) (*SkillMetadata, error) { lines := strings.Split(content, "\n") if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { return nil, fmt.Errorf("missing frontmatter start") } var endIndex int found := false for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { endIndex = i found = true break } } if !found { return nil, fmt.Errorf("missing frontmatter end") } frontmatter := strings.Join(lines[1:endIndex], "\n") var metadata SkillMetadata if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { return nil, fmt.Errorf("failed to parse frontmatter: %w", err) } return &metadata, nil } // isValidSkillName checks if skill name follows slug format func isValidSkillName(name string) bool { matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9_-]*$`, name) return matched } // isValidSemver checks basic semver format func isValidSemver(version string) bool { matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, version) return matched } // isTextFile checks if file is text-based func isTextFile(filePath, contentType string) bool { if contentType != "" { normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) if strings.HasPrefix(normalized, "text/") { return true } textContentTypes := map[string]bool{ "application/json": true, "application/xml": true, "application/yaml": true, "application/x-yaml": true, "application/toml": true, "application/javascript": true, "application/typescript": true, "application/markdown": true, "image/svg+xml": true, } if textContentTypes[normalized] { return true } } ext := strings.ToLower(filepath.Ext(filePath)) if ext != "" { ext = ext[1:] } return textFileExtensions[ext] } // sanitizeRelPath sanitizes relative path func sanitizeRelPath(path string) string { normalized := regexp.MustCompile(`^\./+`).ReplaceAllString(path, "") normalized = strings.TrimLeft(normalized, "/") if normalized == "" || strings.HasSuffix(normalized, "/") { return "" } if strings.Contains(normalized, "..") || strings.Contains(normalized, "\\") { return "" } return normalized } // isMacJunkPath checks if path is Mac junk file func isMacJunkPath(path string) bool { normalized := strings.ToLower(path) if normalized == ".ds_store" || strings.HasSuffix(normalized, "/.ds_store") { return true } if strings.HasPrefix(normalized, "__macosx/") || normalized == "__macosx" { return true } if strings.HasPrefix(normalized, "._") || strings.Contains(normalized, "/._") { return true } return false } // shouldIgnore checks if path should be ignored func shouldIgnore(filePath string, patterns []string) bool { normalizedPath := strings.ToLower(filePath) for _, pattern := range patterns { trimmedPattern := strings.TrimSpace(pattern) if trimmedPattern == "" || strings.HasPrefix(trimmedPattern, "#") { continue } if matchPattern(normalizedPath, strings.ToLower(trimmedPattern)) { return true } } return false } // matchPattern matches path against ignore pattern func matchPattern(filePath, pattern string) bool { if strings.HasSuffix(pattern, "/") { dirPattern := strings.TrimSuffix(pattern, "/") return strings.HasPrefix(filePath, dirPattern+"/") || filePath == dirPattern } if filePath == pattern { return true } regex := globToRegex(pattern) matched, _ := regexp.MatchString(regex, filePath) return matched } // globToRegex converts glob pattern to regex func globToRegex(pattern string) string { var regex strings.Builder regex.WriteString("^") for i := 0; i < len(pattern); i++ { c := pattern[i] switch c { case '*': if i+1 < len(pattern) && pattern[i+1] == '*' { regex.WriteString(".*") i++ } else { regex.WriteString("[^/]*") } case '?': regex.WriteString("[^/]") case '.': regex.WriteString("\\.") case '\\', '/', '$', '^', '+', '(', ')', '[', ']', '{', '}': regex.WriteString("\\") regex.WriteByte(c) default: regex.WriteByte(c) } } regex.WriteString("$") return regex.String() } // normalizeSpaceID normalizes space ID func normalizeSpaceID(spaceID string) string { spaceID = strings.TrimSpace(spaceID) if spaceID == "" { return DefaultSpaceID } return spaceID } // normalizeSkillName normalizes skill name func normalizeSkillName(name string) string { name = strings.ToLower(name) name = strings.ReplaceAll(name, " ", "-") name = strings.ReplaceAll(name, "_", "-") re := regexp.MustCompile(`[^a-z0-9-]+`) name = re.ReplaceAllString(name, "-") re = regexp.MustCompile(`-+`) name = re.ReplaceAllString(name, "-") name = strings.Trim(name, "-") return name } // GetValidationErrorMessage returns human-readable error message func GetValidationErrorMessage(result *SkillValidationResult) string { switch result.Error { case "no_files": return "No files found in the skill directory" case "total_size_exceeded": return fmt.Sprintf("Total size exceeds limit of %d MB", MaxSkillTotalSize/(1024*1024)) case "file_too_large": return fmt.Sprintf("File too large: %s (max %d MB per file)", result.Details, MaxSkillFileSize/(1024*1024)) case "invalid_path": return "Invalid file path detected" case "missing_skill_md": return "SKILL.md not found in the skill directory" case "invalid_frontmatter": if result.Details != "" { return fmt.Sprintf("Invalid SKILL.md frontmatter: %s", result.Details) } return "Invalid SKILL.md frontmatter format" case "missing_name": return "SKILL.md missing required field: name" case "invalid_name_format": return fmt.Sprintf("Invalid skill name format: %s (must be lowercase, alphanumeric with hyphens/underscores)", result.Details) case "invalid_version": return fmt.Sprintf("Invalid version format: %s (must be semver like 1.0.0)", result.Details) case "invalid_file_type": return fmt.Sprintf("Invalid file type: %s (only text files allowed)", result.Details) case "no_valid_files": return "No valid files found after filtering" default: return fmt.Sprintf("Validation failed: %s", result.Error) } } // GetString safely extracts a string value from interface{} func GetString(v interface{}) string { if v == nil { return "" } switch s := v.(type) { case string: return s default: return fmt.Sprintf("%v", v) } } // ============================================================================ // Skill Uploader // ============================================================================ // SkillUploader handles uploading skills to the server type SkillUploader struct { client HTTPClientInterface fileProvider *FileProvider skillProvider Provider force bool // Force mode: overwrite existing versions } // NewSkillUploader creates a new uploader func NewSkillUploader(client HTTPClientInterface, fileProvider *FileProvider) *SkillUploader { return &SkillUploader{ client: client, fileProvider: fileProvider, } } // SetSkillProvider sets the skill provider func (u *SkillUploader) SetSkillProvider(provider Provider) { u.skillProvider = provider } // SetForce sets the force mode (overwrite existing versions) func (u *SkillUploader) SetForce(force bool) { u.force = force } // parseSpaceFromPath extracts space ID from a path like "skills/space1" or "skills" // Returns "default" for "skills" (no space specified) func parseSpaceFromPath(path string) string { path = strings.TrimSpace(path) if path == "" || path == "skills" { return DefaultSpaceID } // Handle paths like "skills/space1" or "hub1" if strings.HasPrefix(path, "skills/") { path = strings.TrimPrefix(path, "skills/") } if path == "" { return DefaultSpaceID } return normalizeSpaceID(path) } // UploadSkill uploads a skill directory to the server // nameOverride: user-specified skill name (overrides SKILL.md metadata) func (u *SkillUploader) UploadSkill(ctx stdctx.Context, skillPath string, versionOverride string, hubPath string, nameOverride string) error { // Parse space from path spaceID := parseSpaceFromPath(hubPath) // 1. Validate the skill directory fmt.Printf("Validating skill at %s...\n", skillPath) result, files, err := ValidateSkillDirectory(skillPath, versionOverride, nameOverride) if err != nil { return fmt.Errorf("validation error: %w", err) } if !result.Valid { return fmt.Errorf("validation failed: %s", GetValidationErrorMessage(result)) } // Get skill name from validation result (SKILL.md metadata or user-specified) // Fallback to directory name if not specified skillName := result.Name if skillName == "" { skillName = filepath.Base(skillPath) skillName = normalizeSkillName(skillName) } // Use provided version or default version := result.Version if version == "" { version = "1.0.0" } fmt.Printf("✓ Skill '%s' (v%s) is valid\n", skillName, version) // 2. Ensure skills space exists fmt.Printf("Checking skills space '%s'...\n", spaceID) spaceFolderID, err := u.ensureSkillsSpaceFolder(ctx, spaceID) if err != nil { return fmt.Errorf("failed to ensure skills space: %w", err) } // 3. Get or create skill folder fmt.Printf("Checking skill '%s'...\n", skillName) skillFolderID, err := u.getOrCreateSkillFolder(ctx, spaceID, spaceFolderID, skillName) if err != nil { return err } // 4. Check if version already exists fmt.Printf("Checking version '%s'...\n", version) exists, err := u.versionExists(ctx, spaceID, skillName, version) if err != nil { return fmt.Errorf("failed to check version: %w", err) } if exists { if u.force { // Force mode: delete existing version folder fmt.Printf("Force mode: removing existing version '%s'...\n", version) versionPath := fmt.Sprintf("skills/%s/%s/%s", spaceID, skillName, version) if err := u.deleteVersionFolder(ctx, versionPath); err != nil { return fmt.Errorf("failed to remove existing version: %w", err) } fmt.Printf("✓ Existing version '%s' removed\n", version) } else { return &SkillConflictError{Type: "version", Name: skillName, Version: version} } } // 5. Create version folder fmt.Println("Creating version folder...") versionFolderID, err := u.createFolder(ctx, skillFolderID, version) if err != nil { return fmt.Errorf("failed to create version folder: %w", err) } // 6. Upload all files fmt.Printf("Uploading %d files...\n", len(files)) for _, file := range files { sanitized := sanitizeRelPath(file.Path) if sanitized == "" || isMacJunkPath(sanitized) || shouldIgnore(sanitized, defaultIgnorePatterns) { continue } err = u.uploadFile(ctx, file, versionFolderID) if err != nil { return fmt.Errorf("failed to upload file %s: %w", file.Path, err) } } fmt.Printf("✓ Successfully uploaded skill '%s' version %s\n", skillName, version) // 7. Index the skill for search fmt.Println("Indexing skill for search...") if err := u.indexSkill(ctx, result, files, spaceID, skillFolderID); err != nil { fmt.Printf("⚠ Warning: Failed to index skill for search: %v\n", err) } else { fmt.Println("✓ Skill indexed successfully") } return nil } // ensureSkillsSpaceFolder ensures the 'skills/' folder exists func (u *SkillUploader) ensureSkillsSpaceFolder(ctx stdctx.Context, spaceID string) (string, error) { skillsFolderID, err := u.ensureSkillsFolder(ctx) if err != nil { return "", err } result, err := u.fileProvider.List(ctx, "skills", nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == spaceID { return GetString(node.Metadata["id"]), nil } } return u.createFolder(ctx, skillsFolderID, spaceID) } // ensureSkillsFolder ensures the 'skills' folder exists func (u *SkillUploader) ensureSkillsFolder(ctx stdctx.Context) (string, error) { result, err := u.fileProvider.List(ctx, "", nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == "skills" { return GetString(node.Metadata["id"]), nil } } return u.createFolder(ctx, "", "skills") } // getOrCreateSkillFolder gets existing skill folder or creates new one func (u *SkillUploader) getOrCreateSkillFolder(ctx stdctx.Context, spaceID, parentID, skillName string) (string, error) { result, err := u.fileProvider.List(ctx, fmt.Sprintf("skills/%s", spaceID), nil) if err != nil { return "", err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == skillName { return GetString(node.Metadata["id"]), nil } } return u.createFolder(ctx, parentID, skillName) } // versionExists checks if a version already exists func (u *SkillUploader) versionExists(ctx stdctx.Context, spaceID, skillName, version string) (bool, error) { result, err := u.fileProvider.List(ctx, fmt.Sprintf("skills/%s/%s", spaceID, skillName), nil) if err != nil { return false, err } for _, node := range result.Nodes { if node.Type == NodeTypeDirectory && node.Name == version { return true, nil } } return false, nil } // deleteVersionFolder deletes a version folder by path func (u *SkillUploader) deleteVersionFolder(ctx stdctx.Context, versionPath string) error { return u.fileProvider.DeleteFolderByPath(ctx, versionPath) } // createFolder creates a new folder and returns its ID func (u *SkillUploader) createFolder(ctx stdctx.Context, parentID, name string) (string, error) { payload := map[string]interface{}{ "name": name, "type": "folder", } if parentID != "" { payload["parent_id"] = parentID } resp, err := u.client.Request("POST", "/files", "auto", nil, payload) if err != nil { return "", err } var result struct { Code int `json:"code"` Data struct { ID string `json:"id"` } `json:"data"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return "", err } if result.Code != 0 { return "", fmt.Errorf("server returned error code: %d", result.Code) } return result.Data.ID, nil } // uploadFile uploads a single file using multipart form func (u *SkillUploader) uploadFile(ctx stdctx.Context, file *SkillFile, parentID string) error { var buf bytes.Buffer writer := multipart.NewWriter(&buf) if parentID != "" { writer.WriteField("parent_id", parentID) } part, err := writer.CreateFormFile("file", file.Path) if err != nil { return err } if _, err := part.Write(file.Content); err != nil { return err } writer.Close() return u.client.UploadMultipart("/files", writer.FormDataContentType(), &buf) } // indexSkill indexes the skill for search func (u *SkillUploader) indexSkill(ctx stdctx.Context, result *SkillValidationResult, files []*SkillFile, spaceID, skillFolderID string) error { if u.skillProvider == nil { return fmt.Errorf("skill provider not available") } skillProvider, ok := u.skillProvider.(*SkillProvider) if !ok { return fmt.Errorf("invalid skill provider type") } var contentBuilder strings.Builder for _, file := range files { if !isTextFile(file.Path, "") { continue } if len(file.Content) > MaxSkillFileSize { continue } sanitized := sanitizeRelPath(file.Path) if sanitized == "" || isMacJunkPath(sanitized) || shouldIgnore(sanitized, defaultIgnorePatterns) { continue } contentBuilder.WriteString(fmt.Sprintf("\n=== %s ===\n", file.Path)) contentBuilder.Write(file.Content) } content := contentBuilder.String() // Use skill name as ID (without version suffix) // This ensures all versions of the same skill share the same index document skillID := result.Name skillInfo := map[string]interface{}{ "id": skillID, "folder_id": skillFolderID, "name": result.Name, "description": result.Description, "tags": result.Tags, "content": content, "version": result.Version, } return skillProvider.IndexSkill(ctx, spaceID, skillInfo) }