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"}, } }