package server import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) type skillhubIndex struct { Skills []skillhubIndexEntry `json:"skills"` } type skillhubIndexEntry struct { Name string `json:"name"` Path string `json:"path"` Category string `json:"category"` Capability string `json:"capability"` } func loadSkills(skillhubDir string) ([]skill, error) { skillhubDir = strings.TrimSpace(skillhubDir) if skillhubDir == "" { return seedSkills(), nil } indexPath := filepath.Join(skillhubDir, "index.json") data, err := os.ReadFile(indexPath) if err != nil { return nil, fmt.Errorf("read skillhub index: %w", err) } var idx skillhubIndex if err := json.Unmarshal(data, &idx); err != nil { return nil, fmt.Errorf("decode skillhub index: %w", err) } items := make([]skill, 0, len(idx.Skills)) for _, entry := range idx.Skills { if !strings.HasPrefix(entry.Name, "popiskill-") { continue } skillPath := strings.TrimSpace(entry.Path) if skillPath == "" { skillPath = filepath.Join("skills", entry.Name) } docPath := filepath.Join(skillhubDir, skillPath, "SKILL.md") doc, err := parseSkillDoc(docPath) if err != nil { return nil, fmt.Errorf("parse %s: %w", docPath, err) } category := defaultString(strings.TrimSpace(entry.Category), skillCategoryFromID(entry.Name)) capability := defaultString(strings.TrimSpace(entry.Capability), skillCapabilityFromID(entry.Name)) routeKey := normalizeRouteKey(category + "." + capability) version := semverFromSkillID(entry.Name) tags := tagsFromSkillID(entry.Name) name := defaultString(strings.TrimSpace(doc.Title), displayNameFromSkillID(entry.Name)) description := strings.TrimSpace(doc.Description) items = append(items, skill{ ID: defaultString(strings.TrimSpace(doc.Name), entry.Name), Name: name, Description: description, Tags: tags, Version: version, ModelType: category, RouteKey: routeKey, EstimatedDurationS: estimateDuration(routeKey), InputSchema: inputSchemaForRoute(routeKey), OutputSchema: defaultOutputSchema(), }) } if len(items) == 0 { return seedSkills(), nil } return items, nil } type skillDoc struct { Name string Description string Title string } func parseSkillDoc(path string) (skillDoc, error) { data, err := os.ReadFile(path) if err != nil { return skillDoc{}, err } text := string(data) lines := strings.Split(text, "\n") doc := skillDoc{} start := 0 if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" { end := -1 for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { end = i break } key, value, ok := strings.Cut(lines[i], ":") if !ok { continue } switch strings.TrimSpace(key) { case "name": doc.Name = trimYAMLScalar(value) case "description": doc.Description = trimYAMLScalar(value) } } if end >= 0 { start = end + 1 } } for i := start; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) if strings.HasPrefix(line, "# ") { doc.Title = strings.TrimSpace(strings.TrimPrefix(line, "# ")) break } } if doc.Name == "" { return skillDoc{}, fmt.Errorf("missing frontmatter name") } if doc.Description == "" { return skillDoc{}, fmt.Errorf("missing frontmatter description") } return doc, nil } func trimYAMLScalar(value string) string { value = strings.TrimSpace(value) value = strings.Trim(value, `"'`) return value } func skillCategoryFromID(skillID string) string { parts := strings.Split(skillID, "-") if len(parts) >= 3 { return parts[1] } return "image" } func skillCapabilityFromID(skillID string) string { parts := strings.Split(skillID, "-") if len(parts) >= 4 { return parts[2] } return "text2image" } func semverFromSkillID(skillID string) string { parts := strings.Split(skillID, "-") last := parts[len(parts)-1] if isVersionToken(last) { if n, err := strconv.Atoi(strings.TrimPrefix(last, "v")); err == nil && n > 0 { return fmt.Sprintf("%d.0.0", n) } } return "1.0.0" } func tagsFromSkillID(skillID string) []string { parts := strings.Split(skillID, "-") seen := map[string]bool{} out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" || part == "popiskill" || isVersionToken(part) { continue } if !seen[part] { seen[part] = true out = append(out, part) } } sort.Strings(out) return out } func displayNameFromSkillID(skillID string) string { parts := strings.Split(skillID, "-") items := make([]string, 0, len(parts)) for _, part := range parts { if part == "popiskill" || isVersionToken(part) || part == "" { continue } items = append(items, strings.Title(part)) } return strings.Join(items, " ") } func isVersionToken(value string) bool { if !strings.HasPrefix(value, "v") || len(value) < 2 { return false } _, err := strconv.Atoi(strings.TrimPrefix(value, "v")) return err == nil } func estimateDuration(routeKey string) int { switch normalizeRouteKey(routeKey) { case "image.img2img": return 45 case "video.image2video": return 90 default: return 30 } } func defaultOutputSchema() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "artifact_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "usage": map[string]any{"type": "object"}, }, } } func inputSchemaForRoute(routeKey string) map[string]any { switch normalizeRouteKey(routeKey) { case "image.img2img": return map[string]any{ "type": "object", "properties": map[string]any{ "prompt": map[string]any{"type": "string"}, "scene_prompt": map[string]any{"type": "string"}, "image_url": map[string]any{"type": "string"}, "reference_image_url": map[string]any{"type": "string"}, "source_artifact_id": map[string]any{"type": "string"}, "shot_type": map[string]any{"type": "string"}, "camera": map[string]any{"type": "string"}, "mood": map[string]any{"type": "string"}, "aspect_ratio": map[string]any{"type": "string"}, }, } case "video.image2video": return map[string]any{ "type": "object", "properties": map[string]any{ "source_artifact_id": map[string]any{"type": "string"}, "image_url": map[string]any{"type": "string"}, "prompt": map[string]any{"type": "string"}, "motion_prompt": map[string]any{"type": "string"}, "duration_s": map[string]any{"type": "integer"}, "camera_motion": map[string]any{"type": "string"}, "mood": map[string]any{"type": "string"}, "aspect_ratio": map[string]any{"type": "string"}, }, } default: return map[string]any{ "type": "object", "properties": map[string]any{ "prompt": map[string]any{"type": "string"}, "style": map[string]any{"type": "string"}, "aspect_ratio": map[string]any{"type": "string"}, "seed": map[string]any{"type": "integer"}, }, "required": []string{"prompt"}, } } }