Skip to content

Commit b08d48c

Browse files
authored
Merge pull request #6 from agentsdance/load-skills
feat(skills): implement dynamic skills registry with caching
2 parents 1057001 + 089e87e commit b08d48c

3 files changed

Lines changed: 242 additions & 13 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
agentx
2+
dist/
3+
checksums.txt

internal/skills/registry.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package skills
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"time"
11+
)
12+
13+
// DefaultSkillsRegistryURL is the default URL for the skills registry
14+
const DefaultSkillsRegistryURL = "https://raw.githubusercontent.com/agentsdance/agentskills/master/skills.json"
15+
16+
// RegistrySkill represents a skill entry in the registry
17+
type RegistrySkill struct {
18+
Name string `json:"name"`
19+
Description string `json:"description"`
20+
Author string `json:"author,omitempty"`
21+
License string `json:"license,omitempty"`
22+
Source string `json:"source"`
23+
}
24+
25+
// SkillsRegistry represents the skills registry
26+
type SkillsRegistry struct {
27+
Version string `json:"version"`
28+
Skills []RegistrySkill `json:"skills"`
29+
}
30+
31+
// FetchSkillsRegistry fetches the skills registry from the default URL
32+
func FetchSkillsRegistry() ([]RegistrySkill, error) {
33+
return FetchSkillsRegistryFromURL(DefaultSkillsRegistryURL)
34+
}
35+
36+
// FetchSkillsRegistryFromURL fetches the skills registry from a specific URL
37+
func FetchSkillsRegistryFromURL(registryURL string) ([]RegistrySkill, error) {
38+
client := &http.Client{
39+
Timeout: 10 * time.Second,
40+
}
41+
42+
resp, err := client.Get(registryURL)
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to fetch skills registry: %w", err)
45+
}
46+
defer resp.Body.Close()
47+
48+
if resp.StatusCode != http.StatusOK {
49+
return nil, fmt.Errorf("skills registry returned status %d", resp.StatusCode)
50+
}
51+
52+
body, err := io.ReadAll(resp.Body)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to read skills registry response: %w", err)
55+
}
56+
57+
var registry SkillsRegistry
58+
if err := json.Unmarshal(body, &registry); err != nil {
59+
return nil, fmt.Errorf("failed to parse skills registry: %w", err)
60+
}
61+
62+
// Cache the registry locally
63+
if err := cacheSkillsRegistry(body); err != nil {
64+
// Non-fatal, just log if needed
65+
}
66+
67+
return registry.Skills, nil
68+
}
69+
70+
// GetCachedSkillsRegistry returns the cached skills registry if available
71+
func GetCachedSkillsRegistry() ([]RegistrySkill, error) {
72+
cachePath, err := getSkillsRegistryCachePath()
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
data, err := os.ReadFile(cachePath)
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
var registry SkillsRegistry
83+
if err := json.Unmarshal(data, &registry); err != nil {
84+
return nil, err
85+
}
86+
87+
return registry.Skills, nil
88+
}
89+
90+
// FetchSkillsRegistryWithFallback tries to fetch from network, falls back to cache, then to local file
91+
func FetchSkillsRegistryWithFallback() ([]RegistrySkill, error) {
92+
skills, err := FetchSkillsRegistry()
93+
if err == nil && len(skills) > 0 {
94+
return skills, nil
95+
}
96+
97+
// Try cached version
98+
cached, cacheErr := GetCachedSkillsRegistry()
99+
if cacheErr == nil && len(cached) > 0 {
100+
return cached, nil
101+
}
102+
103+
// Try local bundled registry file (for development)
104+
local, localErr := GetLocalSkillsRegistry()
105+
if localErr == nil && len(local) > 0 {
106+
return local, nil
107+
}
108+
109+
if err != nil {
110+
return nil, err
111+
}
112+
return skills, nil
113+
}
114+
115+
// GetLocalSkillsRegistry reads the skills registry from the local bundled file
116+
func GetLocalSkillsRegistry() ([]RegistrySkill, error) {
117+
// Try common locations for the registry file
118+
paths := []string{
119+
"registry/skills.json", // Current working directory
120+
"./registry/skills.json", // Explicit current directory
121+
filepath.Join("..", "registry/skills.json"), // Parent directory
122+
}
123+
124+
// Also try relative to executable
125+
if exe, err := os.Executable(); err == nil {
126+
exeDir := filepath.Dir(exe)
127+
paths = append(paths, filepath.Join(exeDir, "registry", "skills.json"))
128+
paths = append(paths, filepath.Join(exeDir, "..", "registry", "skills.json"))
129+
}
130+
131+
for _, path := range paths {
132+
data, err := os.ReadFile(path)
133+
if err != nil {
134+
continue
135+
}
136+
137+
var registry SkillsRegistry
138+
if err := json.Unmarshal(data, &registry); err != nil {
139+
continue
140+
}
141+
142+
return registry.Skills, nil
143+
}
144+
145+
return nil, fmt.Errorf("local skills registry not found")
146+
}
147+
148+
// cacheSkillsRegistry saves the skills registry data to local cache
149+
func cacheSkillsRegistry(data []byte) error {
150+
cachePath, err := getSkillsRegistryCachePath()
151+
if err != nil {
152+
return err
153+
}
154+
155+
// Ensure cache directory exists
156+
cacheDir := filepath.Dir(cachePath)
157+
if err := os.MkdirAll(cacheDir, 0755); err != nil {
158+
return err
159+
}
160+
161+
return os.WriteFile(cachePath, data, 0644)
162+
}
163+
164+
// getSkillsRegistryCachePath returns the path to the cached skills registry
165+
func getSkillsRegistryCachePath() (string, error) {
166+
home, err := os.UserHomeDir()
167+
if err != nil {
168+
return "", err
169+
}
170+
return filepath.Join(home, ".agentx", "cache", "skills-registry.json"), nil
171+
}

ui/views/skills.go

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
tea "github.com/charmbracelet/bubbletea"
88
"github.com/charmbracelet/lipgloss"
99
"github.com/agentsdance/agentx/internal/agent"
10+
"github.com/agentsdance/agentx/internal/skills"
1011
"github.com/agentsdance/agentx/ui/components"
1112
"github.com/agentsdance/agentx/ui/theme"
1213
)
@@ -18,14 +19,39 @@ type AvailableSkill struct {
1819
Source string // GitHub tree URL or repo#fragment
1920
}
2021

21-
// Available skills from anthropics/skills repository
22-
var AvailableSkills = []AvailableSkill{
23-
{Name: "frontend-design", Description: "Production-grade UI design", Source: "https://github.com/anthropics/skills/tree/main/skills/frontend-design"},
24-
{Name: "mcp-builder", Description: "Build MCP servers", Source: "https://github.com/anthropics/skills/tree/main/skills/mcp-builder"},
25-
{Name: "pdf", Description: "PDF document handling", Source: "https://github.com/anthropics/skills/tree/main/skills/pdf"},
26-
{Name: "docx", Description: "Word document handling", Source: "https://github.com/anthropics/skills/tree/main/skills/docx"},
27-
{Name: "xlsx", Description: "Excel spreadsheet handling", Source: "https://github.com/anthropics/skills/tree/main/skills/xlsx"},
28-
{Name: "pptx", Description: "PowerPoint handling", Source: "https://github.com/anthropics/skills/tree/main/skills/pptx"},
22+
// AvailableSkills is the list of skills from the registry
23+
var AvailableSkills []AvailableSkill
24+
25+
// InitAvailableSkills fetches skills from the registry
26+
func InitAvailableSkills() {
27+
registrySkills, err := skills.FetchSkillsRegistryWithFallback()
28+
if err != nil {
29+
// Use empty list if fetch fails
30+
AvailableSkills = []AvailableSkill{}
31+
return
32+
}
33+
34+
AvailableSkills = make([]AvailableSkill, len(registrySkills))
35+
for i, s := range registrySkills {
36+
// Convert source to installation URL
37+
source := s.Source
38+
if source == "local" {
39+
// For local skills in agentsdance/agentskills repo
40+
source = fmt.Sprintf("https://github.com/agentsdance/agentskills/tree/master/skills/%s", s.Name)
41+
}
42+
43+
// Truncate description for display
44+
desc := s.Description
45+
if len(desc) > 40 {
46+
desc = desc[:37] + "..."
47+
}
48+
49+
AvailableSkills[i] = AvailableSkill{
50+
Name: s.Name,
51+
Description: desc,
52+
Source: source,
53+
}
54+
}
2955
}
3056

3157
// AgentSkillStatus represents an agent's skill installation status
@@ -49,6 +75,9 @@ type SkillsView struct {
4975

5076
// NewSkillsView creates a new skills view
5177
func NewSkillsView() *SkillsView {
78+
// Initialize available skills from registry
79+
InitAvailableSkills()
80+
5281
agents := agent.GetAllAgents()
5382
statuses := make([]AgentSkillStatus, len(agents))
5483

@@ -111,12 +140,22 @@ func (v *SkillsView) Update(msg tea.Msg) (View, tea.Cmd) {
111140
case "c":
112141
v.refreshStatus()
113142
v.message = "Status refreshed"
143+
case "R":
144+
// Force refresh skills from registry
145+
InitAvailableSkills()
146+
v.refreshStatus()
147+
v.message = fmt.Sprintf("Refreshed %d skills from registry", len(AvailableSkills))
114148
}
115149
}
116150
return v, nil
117151
}
118152

119153
func (v *SkillsView) installSelected() {
154+
if len(AvailableSkills) == 0 {
155+
v.message = "No skills available"
156+
return
157+
}
158+
120159
status := &v.agents[v.cursorCol]
121160
agentName := status.Agent.Name()
122161
skill := AvailableSkills[v.cursorRow]
@@ -140,6 +179,11 @@ func (v *SkillsView) installSelected() {
140179
}
141180

142181
func (v *SkillsView) installAllForSelectedSkill() {
182+
if len(AvailableSkills) == 0 {
183+
v.message = "No skills available"
184+
return
185+
}
186+
143187
installed := 0
144188
skill := AvailableSkills[v.cursorRow]
145189

@@ -158,6 +202,11 @@ func (v *SkillsView) installAllForSelectedSkill() {
158202
}
159203

160204
func (v *SkillsView) removeSelected() {
205+
if len(AvailableSkills) == 0 {
206+
v.message = "No skills available"
207+
return
208+
}
209+
161210
status := &v.agents[v.cursorCol]
162211
agentName := status.Agent.Name()
163212
skill := AvailableSkills[v.cursorRow]
@@ -225,8 +274,14 @@ func (v *SkillsView) View() string {
225274
b.WriteString(borderStyle.Render(" " + strings.Repeat("─", 70)))
226275
b.WriteString("\n")
227276

277+
// Check if skills are available
278+
if len(AvailableSkills) == 0 {
279+
b.WriteString("\n No skills available. Press 'R' to refresh from registry.\n")
280+
return b.String()
281+
}
282+
228283
// Column headers (Agent names)
229-
b.WriteString(" ")
284+
b.WriteString(" ")
230285
for i, status := range v.agents {
231286
style := colHeaderStyle
232287
if i == v.cursorCol {
@@ -251,12 +306,12 @@ func (v *SkillsView) View() string {
251306
row.WriteString(" ")
252307
}
253308

254-
// Skill name
309+
// Skill name - width 28 characters for long names
255310
name := skill.Name
256-
if len(name) > 14 {
257-
name = name[:14]
311+
if len(name) > 28 {
312+
name = name[:28]
258313
}
259-
row.WriteString(fmt.Sprintf("%-14s", name))
314+
row.WriteString(fmt.Sprintf("%-28s", name))
260315

261316
// Status for each agent
262317
for agentIdx, status := range v.agents {
@@ -316,6 +371,7 @@ func (v *SkillsView) ShortHelp() []components.FooterAction {
316371
{Key: "←→", Label: "select agent"},
317372
{Key: "↑↓", Label: "select skill"},
318373
{Key: "c", Label: "check"},
374+
{Key: "R", Label: "refresh"},
319375
{Key: "q", Label: "quit"},
320376
}
321377
}

0 commit comments

Comments
 (0)