Skip to content
This repository was archived by the owner on Dec 17, 2025. It is now read-only.

Commit 9b0b7d2

Browse files
committed
Add usage tracking for Cursor skills
1 parent ae7c57b commit 9b0b7d2

File tree

6 files changed

+198
-18
lines changed

6 files changed

+198
-18
lines changed

internal/clients/claude_code/client.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,16 +216,17 @@ func (c *Client) ListSkills(ctx context.Context, scope *clients.InstallScope) ([
216216
func (c *Client) ReadSkill(ctx context.Context, name string, scope *clients.InstallScope) (*clients.SkillContent, error) {
217217
targetBase := c.determineTargetBase(scope)
218218

219-
content, baseDir, description, err := skillOps.ReadPromptContent(targetBase, name, "SKILL.md", func(m *metadata.Metadata) string { return m.Skill.PromptFile })
219+
result, err := skillOps.ReadPromptContent(targetBase, name, "SKILL.md", func(m *metadata.Metadata) string { return m.Skill.PromptFile })
220220
if err != nil {
221221
return nil, err
222222
}
223223

224224
return &clients.SkillContent{
225225
Name: name,
226-
Description: description,
227-
Content: content,
228-
BaseDir: baseDir,
226+
Description: result.Description,
227+
Version: result.Version,
228+
Content: result.Content,
229+
BaseDir: result.BaseDir,
229230
}, nil
230231
}
231232

internal/clients/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type InstalledSkill struct {
6969
type SkillContent struct {
7070
Name string // Skill name
7171
Description string // Skill description from metadata
72+
Version string // Skill version from metadata
7273
Content string // Contents of SKILL.md (or configured prompt file)
7374
BaseDir string // Directory where skill is installed (for resolving @ file references)
7475
}

internal/clients/cursor/client.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,16 +419,17 @@ func (c *Client) ListSkills(ctx context.Context, scope *clients.InstallScope) ([
419419
func (c *Client) ReadSkill(ctx context.Context, name string, scope *clients.InstallScope) (*clients.SkillContent, error) {
420420
targetBase := c.determineTargetBase(scope)
421421

422-
content, baseDir, description, err := skillOps.ReadPromptContent(targetBase, name, "SKILL.md", func(m *metadata.Metadata) string { return m.Skill.PromptFile })
422+
result, err := skillOps.ReadPromptContent(targetBase, name, "SKILL.md", func(m *metadata.Metadata) string { return m.Skill.PromptFile })
423423
if err != nil {
424424
return nil, err
425425
}
426426

427427
return &clients.SkillContent{
428428
Name: name,
429-
Description: description,
430-
Content: content,
431-
BaseDir: baseDir,
429+
Description: result.Description,
430+
Version: result.Version,
431+
Content: result.Content,
432+
BaseDir: result.BaseDir,
432433
}, nil
433434
}
434435

internal/handlers/dirartifact/operations.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,21 +120,29 @@ func (o *Operations) GetArtifactDir(targetBase string, artifactName string) stri
120120
// Returns empty string if not specified, in which case the default will be used.
121121
type PromptFileGetter func(meta *metadata.Metadata) string
122122

123+
// PromptContent contains the result of reading an artifact's prompt file
124+
type PromptContent struct {
125+
Content string // The prompt file contents
126+
BaseDir string // Directory where the artifact is installed
127+
Description string // Artifact description from metadata
128+
Version string // Artifact version from metadata
129+
}
130+
123131
// ReadPromptContent reads the prompt/content file for an artifact.
124132
// promptFileGetter extracts the prompt file from metadata; if nil or returns empty, defaultPromptFile is used.
125-
func (o *Operations) ReadPromptContent(targetBase string, artifactName string, defaultPromptFile string, promptFileGetter PromptFileGetter) (content string, baseDir string, description string, err error) {
133+
func (o *Operations) ReadPromptContent(targetBase string, artifactName string, defaultPromptFile string, promptFileGetter PromptFileGetter) (*PromptContent, error) {
126134
artifactDir := o.GetArtifactDir(targetBase, artifactName)
127135

128136
// Check if artifact directory exists
129137
if _, err := os.Stat(artifactDir); os.IsNotExist(err) {
130-
return "", "", "", fmt.Errorf("artifact not found: %s", artifactName)
138+
return nil, fmt.Errorf("artifact not found: %s", artifactName)
131139
}
132140

133141
// Read metadata
134142
metaPath := filepath.Join(artifactDir, "metadata.toml")
135143
meta, err := metadata.ParseFile(metaPath)
136144
if err != nil {
137-
return "", "", "", fmt.Errorf("failed to read artifact metadata: %w", err)
145+
return nil, fmt.Errorf("failed to read artifact metadata: %w", err)
138146
}
139147

140148
// Determine prompt file
@@ -149,8 +157,13 @@ func (o *Operations) ReadPromptContent(targetBase string, artifactName string, d
149157
promptPath := filepath.Join(artifactDir, promptFile)
150158
contentBytes, err := os.ReadFile(promptPath)
151159
if err != nil {
152-
return "", "", "", fmt.Errorf("failed to read prompt content: %w", err)
160+
return nil, fmt.Errorf("failed to read prompt content: %w", err)
153161
}
154162

155-
return string(contentBytes), artifactDir, meta.Artifact.Description, nil
163+
return &PromptContent{
164+
Content: string(contentBytes),
165+
BaseDir: artifactDir,
166+
Description: meta.Artifact.Description,
167+
Version: meta.Artifact.Version,
168+
}, nil
156169
}

internal/mcp/server.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,40 @@ import (
66
"os"
77
"path/filepath"
88
"regexp"
9+
"time"
910

1011
"github.com/modelcontextprotocol/go-sdk/mcp"
1112
"github.com/sleuth-io/skills/internal/clients"
13+
"github.com/sleuth-io/skills/internal/config"
1214
"github.com/sleuth-io/skills/internal/gitutil"
15+
"github.com/sleuth-io/skills/internal/logger"
16+
"github.com/sleuth-io/skills/internal/repository"
17+
"github.com/sleuth-io/skills/internal/stats"
1318
)
1419

20+
// UsageReporter handles reporting skill usage
21+
type UsageReporter interface {
22+
ReportSkillUsage(skillName, skillVersion string)
23+
}
24+
1525
// Server provides an MCP server that exposes skill operations
1626
type Server struct {
17-
registry *clients.Registry
27+
registry *clients.Registry
28+
usageReporter UsageReporter
1829
}
1930

2031
// NewServer creates a new MCP server
2132
func NewServer(registry *clients.Registry) *Server {
22-
return &Server{
33+
s := &Server{
2334
registry: registry,
2435
}
36+
s.usageReporter = s // Server implements UsageReporter by default
37+
return s
38+
}
39+
40+
// SetUsageReporter sets a custom usage reporter (for testing)
41+
func (s *Server) SetUsageReporter(reporter UsageReporter) {
42+
s.usageReporter = reporter
2543
}
2644

2745
// ReadSkillInput is the input type for read_skill tool
@@ -69,6 +87,9 @@ func (s *Server) handleReadSkill(ctx context.Context, req *mcp.CallToolRequest,
6987
for _, client := range installedClients {
7088
content, err := client.ReadSkill(ctx, input.Name, scope)
7189
if err == nil {
90+
// Report usage (best-effort, won't fail the MCP call)
91+
go s.usageReporter.ReportSkillUsage(content.Name, content.Version)
92+
7293
// Resolve @file references to absolute paths
7394
resolvedContent := resolveFileReferences(content.Content, content.BaseDir)
7495

@@ -130,3 +151,44 @@ func (s *Server) detectScope(ctx context.Context) (*clients.InstallScope, error)
130151
Path: gitContext.RelativePath,
131152
}, nil
132153
}
154+
155+
// ReportSkillUsage reports usage of a skill to the repository.
156+
// This function runs in a goroutine and is best-effort - it will not block the MCP call.
157+
func (s *Server) ReportSkillUsage(skillName, skillVersion string) {
158+
log := logger.Get()
159+
160+
// Create usage event
161+
usageEvent := stats.UsageEvent{
162+
ArtifactName: skillName,
163+
ArtifactVersion: skillVersion,
164+
ArtifactType: "skill",
165+
Timestamp: time.Now().UTC().Format(time.RFC3339),
166+
}
167+
168+
// Enqueue event (fast, local file write)
169+
if err := stats.EnqueueEvent(usageEvent); err != nil {
170+
log.Warn("failed to enqueue usage event", "skill", skillName, "error", err)
171+
return
172+
}
173+
174+
log.Debug("skill usage enqueued", "name", skillName, "version", skillVersion)
175+
176+
// Try to flush queue with timeout (network call, but we're already in a goroutine)
177+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
178+
defer cancel()
179+
180+
// Load config to get repository
181+
cfg, err := config.Load()
182+
if err != nil {
183+
return
184+
}
185+
186+
// Create repository instance
187+
repo, err := repository.NewFromConfig(cfg)
188+
if err != nil {
189+
return
190+
}
191+
192+
// Try to flush queue
193+
_ = stats.FlushQueue(ctx, repo)
194+
}

internal/mcp/server_test.go

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"path/filepath"
77
"strings"
8+
"sync"
89
"testing"
910

1011
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -69,10 +70,11 @@ func (m *mockClient) ShouldInstall(ctx context.Context) (bool, error) {
6970
return true, nil
7071
}
7172

72-
func (m *mockClient) addSkill(name, description, content, baseDir string) {
73+
func (m *mockClient) addSkill(name, description, version, content, baseDir string) {
7374
m.skills[name] = &clients.SkillContent{
7475
Name: name,
7576
Description: description,
77+
Version: version,
7678
Content: content,
7779
BaseDir: baseDir,
7880
}
@@ -81,7 +83,7 @@ func (m *mockClient) addSkill(name, description, content, baseDir string) {
8183
func TestServer_ReadSkill(t *testing.T) {
8284
// Create a mock client with test skills
8385
mock := newMockClient()
84-
mock.addSkill("test-skill", "A test skill", "# Test Skill\n\nThis is the skill content.", "/tmp/skills/test-skill")
86+
mock.addSkill("test-skill", "A test skill", "1.0.0", "# Test Skill\n\nThis is the skill content.", "/tmp/skills/test-skill")
8587

8688
// Create a registry with just our mock client
8789
registry := clients.NewRegistry()
@@ -222,7 +224,7 @@ This skill helps with @example.txt integration testing.
222224

223225
// Use a mock client that reads from our temp directory
224226
mock := newMockClient()
225-
mock.addSkill("integration-skill", "An integration test skill", skillContent, skillDir)
227+
mock.addSkill("integration-skill", "An integration test skill", "1.0.0", skillContent, skillDir)
226228

227229
registry := clients.NewRegistry()
228230
registry.Register(mock)
@@ -338,3 +340,103 @@ func TestResolveFileReferences(t *testing.T) {
338340
})
339341
}
340342
}
343+
344+
// mockUsageReporter captures usage reports for testing
345+
type mockUsageReporter struct {
346+
mu sync.Mutex
347+
reports []usageReport
348+
called chan struct{}
349+
}
350+
351+
type usageReport struct {
352+
skillName string
353+
skillVersion string
354+
}
355+
356+
func newMockUsageReporter() *mockUsageReporter {
357+
return &mockUsageReporter{
358+
called: make(chan struct{}, 1),
359+
}
360+
}
361+
362+
func (m *mockUsageReporter) ReportSkillUsage(skillName, skillVersion string) {
363+
m.mu.Lock()
364+
m.reports = append(m.reports, usageReport{skillName, skillVersion})
365+
m.mu.Unlock()
366+
select {
367+
case m.called <- struct{}{}:
368+
default:
369+
}
370+
}
371+
372+
func (m *mockUsageReporter) getReports() []usageReport {
373+
m.mu.Lock()
374+
defer m.mu.Unlock()
375+
return append([]usageReport{}, m.reports...)
376+
}
377+
378+
func TestServer_ReadSkill_ReportsUsage(t *testing.T) {
379+
// Create a mock client with a test skill
380+
mock := newMockClient()
381+
mock.addSkill("usage-test-skill", "A skill for testing usage", "2.0.0", "# Usage Test\n\nContent here.", "/tmp/skills/usage-test")
382+
383+
registry := clients.NewRegistry()
384+
registry.Register(mock)
385+
386+
server := NewServer(registry)
387+
388+
// Inject mock usage reporter
389+
mockReporter := newMockUsageReporter()
390+
server.SetUsageReporter(mockReporter)
391+
392+
// Create and connect MCP server
393+
impl := &mcp.Implementation{Name: "skills", Version: "1.0.0"}
394+
mcpServer := mcp.NewServer(impl, nil)
395+
mcp.AddTool(mcpServer, &mcp.Tool{
396+
Name: "read_skill",
397+
Description: "Read a skill's full instructions and content.",
398+
}, server.handleReadSkill)
399+
400+
ctx := context.Background()
401+
t1, t2 := mcp.NewInMemoryTransports()
402+
403+
_, err := mcpServer.Connect(ctx, t1, nil)
404+
if err != nil {
405+
t.Fatalf("Failed to connect server: %v", err)
406+
}
407+
408+
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1.0.0"}, nil)
409+
session, err := client.Connect(ctx, t2, nil)
410+
if err != nil {
411+
t.Fatalf("Failed to connect client: %v", err)
412+
}
413+
defer session.Close()
414+
415+
// Read the skill
416+
result, err := session.CallTool(ctx, &mcp.CallToolParams{
417+
Name: "read_skill",
418+
Arguments: map[string]any{"name": "usage-test-skill"},
419+
})
420+
if err != nil {
421+
t.Fatalf("CallTool failed: %v", err)
422+
}
423+
if result.IsError {
424+
t.Fatalf("Tool returned error: %v", result.Content)
425+
}
426+
427+
// Wait for the goroutine to call the reporter
428+
<-mockReporter.called
429+
430+
// Verify the usage was reported
431+
reports := mockReporter.getReports()
432+
if len(reports) != 1 {
433+
t.Fatalf("Expected 1 usage report, got %d", len(reports))
434+
}
435+
436+
if reports[0].skillName != "usage-test-skill" {
437+
t.Errorf("Expected skill name 'usage-test-skill', got %q", reports[0].skillName)
438+
}
439+
if reports[0].skillVersion != "2.0.0" {
440+
t.Errorf("Expected skill version '2.0.0', got %q", reports[0].skillVersion)
441+
}
442+
}

0 commit comments

Comments
 (0)