mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-05-22 17:08:23 +08:00
### What problem does this PR solve? As title ### Type of change - [x] Refactoring Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2154 lines
61 KiB
Go
2154 lines
61 KiB
Go
//
|
|
// 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", true, "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), true, "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), true, "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), true, "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), true, "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", true, "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", true, "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), true, "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", true, "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), true, "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", true, "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), true, "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), true, "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), true, "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), true, "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", true, "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)),
|
|
true, "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", true, "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)),
|
|
true, "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/<space>' 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", true, "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/<space>' 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", true, "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)
|
|
}
|