From 089e87e40e7d56324acd797865b0e110475b2c44 Mon Sep 17 00:00:00 2001 From: zzxwill Date: Sun, 11 Jan 2026 00:16:08 +0800 Subject: [PATCH] feat(skills): implement dynamic skills registry with caching This commit introduces a skills registry system that fetches available skills from a remote registry instead of hardcoding them. The system includes network fetching with fallback to local cache and bundled files, plus UI integration to display and refresh skills dynamically. This enables easier skill discovery and updates without requiring code changes. --- .gitignore | 2 + internal/skills/registry.go | 171 ++++++++++++++++++++++++++++++++++++ ui/views/skills.go | 82 ++++++++++++++--- 3 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 internal/skills/registry.go diff --git a/.gitignore b/.gitignore index 035a372..87bb4b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ agentx +dist/ +checksums.txt diff --git a/internal/skills/registry.go b/internal/skills/registry.go new file mode 100644 index 0000000..1e99140 --- /dev/null +++ b/internal/skills/registry.go @@ -0,0 +1,171 @@ +package skills + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +// DefaultSkillsRegistryURL is the default URL for the skills registry +const DefaultSkillsRegistryURL = "https://raw.githubusercontent.com/agentsdance/agentskills/master/skills.json" + +// RegistrySkill represents a skill entry in the registry +type RegistrySkill struct { + Name string `json:"name"` + Description string `json:"description"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + Source string `json:"source"` +} + +// SkillsRegistry represents the skills registry +type SkillsRegistry struct { + Version string `json:"version"` + Skills []RegistrySkill `json:"skills"` +} + +// FetchSkillsRegistry fetches the skills registry from the default URL +func FetchSkillsRegistry() ([]RegistrySkill, error) { + return FetchSkillsRegistryFromURL(DefaultSkillsRegistryURL) +} + +// FetchSkillsRegistryFromURL fetches the skills registry from a specific URL +func FetchSkillsRegistryFromURL(registryURL string) ([]RegistrySkill, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(registryURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch skills registry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("skills registry returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read skills registry response: %w", err) + } + + var registry SkillsRegistry + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("failed to parse skills registry: %w", err) + } + + // Cache the registry locally + if err := cacheSkillsRegistry(body); err != nil { + // Non-fatal, just log if needed + } + + return registry.Skills, nil +} + +// GetCachedSkillsRegistry returns the cached skills registry if available +func GetCachedSkillsRegistry() ([]RegistrySkill, error) { + cachePath, err := getSkillsRegistryCachePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, err + } + + var registry SkillsRegistry + if err := json.Unmarshal(data, ®istry); err != nil { + return nil, err + } + + return registry.Skills, nil +} + +// FetchSkillsRegistryWithFallback tries to fetch from network, falls back to cache, then to local file +func FetchSkillsRegistryWithFallback() ([]RegistrySkill, error) { + skills, err := FetchSkillsRegistry() + if err == nil && len(skills) > 0 { + return skills, nil + } + + // Try cached version + cached, cacheErr := GetCachedSkillsRegistry() + if cacheErr == nil && len(cached) > 0 { + return cached, nil + } + + // Try local bundled registry file (for development) + local, localErr := GetLocalSkillsRegistry() + if localErr == nil && len(local) > 0 { + return local, nil + } + + if err != nil { + return nil, err + } + return skills, nil +} + +// GetLocalSkillsRegistry reads the skills registry from the local bundled file +func GetLocalSkillsRegistry() ([]RegistrySkill, error) { + // Try common locations for the registry file + paths := []string{ + "registry/skills.json", // Current working directory + "./registry/skills.json", // Explicit current directory + filepath.Join("..", "registry/skills.json"), // Parent directory + } + + // Also try relative to executable + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + paths = append(paths, filepath.Join(exeDir, "registry", "skills.json")) + paths = append(paths, filepath.Join(exeDir, "..", "registry", "skills.json")) + } + + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var registry SkillsRegistry + if err := json.Unmarshal(data, ®istry); err != nil { + continue + } + + return registry.Skills, nil + } + + return nil, fmt.Errorf("local skills registry not found") +} + +// cacheSkillsRegistry saves the skills registry data to local cache +func cacheSkillsRegistry(data []byte) error { + cachePath, err := getSkillsRegistryCachePath() + if err != nil { + return err + } + + // Ensure cache directory exists + cacheDir := filepath.Dir(cachePath) + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return err + } + + return os.WriteFile(cachePath, data, 0644) +} + +// getSkillsRegistryCachePath returns the path to the cached skills registry +func getSkillsRegistryCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".agentx", "cache", "skills-registry.json"), nil +} diff --git a/ui/views/skills.go b/ui/views/skills.go index 0ea99bc..555290b 100644 --- a/ui/views/skills.go +++ b/ui/views/skills.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/agentsdance/agentx/internal/agent" + "github.com/agentsdance/agentx/internal/skills" "github.com/agentsdance/agentx/ui/components" "github.com/agentsdance/agentx/ui/theme" ) @@ -18,14 +19,39 @@ type AvailableSkill struct { Source string // GitHub tree URL or repo#fragment } -// Available skills from anthropics/skills repository -var AvailableSkills = []AvailableSkill{ - {Name: "frontend-design", Description: "Production-grade UI design", Source: "https://github.com/anthropics/skills/tree/main/skills/frontend-design"}, - {Name: "mcp-builder", Description: "Build MCP servers", Source: "https://github.com/anthropics/skills/tree/main/skills/mcp-builder"}, - {Name: "pdf", Description: "PDF document handling", Source: "https://github.com/anthropics/skills/tree/main/skills/pdf"}, - {Name: "docx", Description: "Word document handling", Source: "https://github.com/anthropics/skills/tree/main/skills/docx"}, - {Name: "xlsx", Description: "Excel spreadsheet handling", Source: "https://github.com/anthropics/skills/tree/main/skills/xlsx"}, - {Name: "pptx", Description: "PowerPoint handling", Source: "https://github.com/anthropics/skills/tree/main/skills/pptx"}, +// AvailableSkills is the list of skills from the registry +var AvailableSkills []AvailableSkill + +// InitAvailableSkills fetches skills from the registry +func InitAvailableSkills() { + registrySkills, err := skills.FetchSkillsRegistryWithFallback() + if err != nil { + // Use empty list if fetch fails + AvailableSkills = []AvailableSkill{} + return + } + + AvailableSkills = make([]AvailableSkill, len(registrySkills)) + for i, s := range registrySkills { + // Convert source to installation URL + source := s.Source + if source == "local" { + // For local skills in agentsdance/agentskills repo + source = fmt.Sprintf("https://github.com/agentsdance/agentskills/tree/master/skills/%s", s.Name) + } + + // Truncate description for display + desc := s.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + + AvailableSkills[i] = AvailableSkill{ + Name: s.Name, + Description: desc, + Source: source, + } + } } // AgentSkillStatus represents an agent's skill installation status @@ -49,6 +75,9 @@ type SkillsView struct { // NewSkillsView creates a new skills view func NewSkillsView() *SkillsView { + // Initialize available skills from registry + InitAvailableSkills() + agents := agent.GetAllAgents() statuses := make([]AgentSkillStatus, len(agents)) @@ -111,12 +140,22 @@ func (v *SkillsView) Update(msg tea.Msg) (View, tea.Cmd) { case "c": v.refreshStatus() v.message = "Status refreshed" + case "R": + // Force refresh skills from registry + InitAvailableSkills() + v.refreshStatus() + v.message = fmt.Sprintf("Refreshed %d skills from registry", len(AvailableSkills)) } } return v, nil } func (v *SkillsView) installSelected() { + if len(AvailableSkills) == 0 { + v.message = "No skills available" + return + } + status := &v.agents[v.cursorCol] agentName := status.Agent.Name() skill := AvailableSkills[v.cursorRow] @@ -140,6 +179,11 @@ func (v *SkillsView) installSelected() { } func (v *SkillsView) installAllForSelectedSkill() { + if len(AvailableSkills) == 0 { + v.message = "No skills available" + return + } + installed := 0 skill := AvailableSkills[v.cursorRow] @@ -158,6 +202,11 @@ func (v *SkillsView) installAllForSelectedSkill() { } func (v *SkillsView) removeSelected() { + if len(AvailableSkills) == 0 { + v.message = "No skills available" + return + } + status := &v.agents[v.cursorCol] agentName := status.Agent.Name() skill := AvailableSkills[v.cursorRow] @@ -225,8 +274,14 @@ func (v *SkillsView) View() string { b.WriteString(borderStyle.Render(" " + strings.Repeat("─", 70))) b.WriteString("\n") + // Check if skills are available + if len(AvailableSkills) == 0 { + b.WriteString("\n No skills available. Press 'R' to refresh from registry.\n") + return b.String() + } + // Column headers (Agent names) - b.WriteString(" ") + b.WriteString(" ") for i, status := range v.agents { style := colHeaderStyle if i == v.cursorCol { @@ -251,12 +306,12 @@ func (v *SkillsView) View() string { row.WriteString(" ") } - // Skill name + // Skill name - width 28 characters for long names name := skill.Name - if len(name) > 14 { - name = name[:14] + if len(name) > 28 { + name = name[:28] } - row.WriteString(fmt.Sprintf("%-14s", name)) + row.WriteString(fmt.Sprintf("%-28s", name)) // Status for each agent for agentIdx, status := range v.agents { @@ -316,6 +371,7 @@ func (v *SkillsView) ShortHelp() []components.FooterAction { {Key: "←→", Label: "select agent"}, {Key: "↑↓", Label: "select skill"}, {Key: "c", Label: "check"}, + {Key: "R", Label: "refresh"}, {Key: "q", Label: "quit"}, } }