Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
agentx
dist/
checksums.txt
171 changes: 171 additions & 0 deletions internal/skills/registry.go
Original file line number Diff line number Diff line change
@@ -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, &registry); 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, &registry); 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, &registry); 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
}
82 changes: 69 additions & 13 deletions ui/views/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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]
Expand All @@ -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]

Expand All @@ -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]
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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"},
}
}
Expand Down