Skip to content

Commit 089e87e

Browse files
committed
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.
1 parent 1057001 commit 089e87e

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)