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: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ require (
github.com/sahilm/fuzzy v0.1.2
github.com/yuin/goldmark v1.8.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/sync v0.20.0
)

require (
Expand Down Expand Up @@ -96,7 +97,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.20.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Expand Down
224 changes: 223 additions & 1 deletion pkg/agentskills/agentskills.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
package agentskills

import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
Expand All @@ -14,14 +16,20 @@ import (
"path/filepath"
"strings"
"time"

"golang.org/x/sync/errgroup"
)

// IndexURL is the canonical Stripe skills index. It is a var (not a const) so
// tests can point it at an httptest server; individual file URLs are derived
// from it, so overriding it redirects file fetches too.
var IndexURL = "https://docs.stripe.com/.well-known/skills/index.json"

const requestTimeout = 10 * time.Second
const (
requestTimeout = 10 * time.Second
skillCheckConcurrency = 8
remoteFetchConcurrency = 8
)

// Index is the top-level response from IndexURL.
type Index struct {
Expand All @@ -36,6 +44,31 @@ type Skill struct {
Files []string `json:"files"`
}

const (
StatusNotInstalled = "not_installed"
StatusCurrent = "current"
StatusOutOfDate = "out_of_date"
StatusError = "error"
)

// SkillCheck is the per-skill result of comparing a local install to the index.
type SkillCheck struct {
Name string `json:"name"`
Status string `json:"status"`
MissingFiles []string `json:"missing_files,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
}

// DirStatus summarizes installed skills under a single destination directory.
type DirStatus struct {
Dir string `json:"dir"`
Status string `json:"status"`
Skills []SkillCheck `json:"skills,omitempty"`
InstalledCount int `json:"installed_count"`
OutOfDateCount int `json:"out_of_date_count"`
Error string `json:"error,omitempty"`
}

func clientOrDefault(httpClient *http.Client) *http.Client {
if httpClient != nil {
return httpClient
Expand Down Expand Up @@ -64,6 +97,195 @@ func FetchIndex(ctx context.Context, httpClient *http.Client) (*Index, error) {
return &index, nil
}

// Check compares the skills installed under destDir against the remote index.
// It fetches remote content for each indexed file and compares SHA256 hashes
// with the local copies.
func Check(ctx context.Context, httpClient *http.Client, destDir string) (*DirStatus, error) {
client := clientOrDefault(httpClient)

index, err := FetchIndex(ctx, client)
if err != nil {
return &DirStatus{Dir: destDir, Status: StatusError, Error: err.Error()}, err
}

result := &DirStatus{
Dir: destDir,
Status: StatusNotInstalled,
}

base := filesBaseURL()
remote := newLimitedGetter(client, remoteFetchConcurrency)
type skillJob struct {
skill Skill
}
jobs := make([]skillJob, 0, len(index.Skills))
for _, skill := range index.Skills {
if skill.Name == "" {
continue
}
jobs = append(jobs, skillJob{skill: skill})
}

checks := make([]SkillCheck, len(jobs))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(skillCheckConcurrency)
for i, job := range jobs {
i, job := i, job
g.Go(func() error {
checks[i] = checkSkill(ctx, remote, destDir, base, job.skill)
return nil
})
}
if err := g.Wait(); err != nil {
return &DirStatus{Dir: destDir, Status: StatusError, Error: err.Error()}, err
}

for _, check := range checks {
if check.Status == StatusCurrent || check.Status == StatusOutOfDate {
result.InstalledCount++
}
if check.Status == StatusOutOfDate {
result.OutOfDateCount++
}
result.Skills = append(result.Skills, check)
}

result.Status = aggregateDirStatus(result)
return result, nil
}

type limitedGetter struct {
client *http.Client
sem chan struct{}
}

func newLimitedGetter(client *http.Client, concurrency int) *limitedGetter {
return &limitedGetter{
client: client,
sem: make(chan struct{}, concurrency),
}
}

func (g *limitedGetter) get(ctx context.Context, rawURL string) ([]byte, error) {
select {
case g.sem <- struct{}{}:
defer func() { <-g.sem }()
case <-ctx.Done():
return nil, ctx.Err()
}
return get(ctx, g.client, rawURL)
}

func checkSkill(ctx context.Context, remote *limitedGetter, destDir, base string, skill Skill) SkillCheck {
check := SkillCheck{Name: skill.Name}

skillDir := filepath.Join(destDir, skill.Name)
if info, err := os.Stat(skillDir); err != nil || !info.IsDir() {
check.Status = StatusNotInstalled
return check
}

type fileJob struct {
file string
}
jobs := make([]fileJob, 0, len(skill.Files))
for _, file := range skill.Files {
if file == "" {
continue
}
target := filepath.Join(destDir, skill.Name, filepath.FromSlash(file))
if !isUnderDir(target, destDir) {
continue
}
jobs = append(jobs, fileJob{file: file})
}

type fileOutcome struct {
file string
missing bool
changed bool
hasLocal bool
}
outcomes := make([]fileOutcome, len(jobs))

g, ctx := errgroup.WithContext(ctx)
for i, job := range jobs {
i, job := i, job
g.Go(func() error {
target := filepath.Join(destDir, skill.Name, filepath.FromSlash(job.file))
local, err := os.ReadFile(target)
if err != nil {
outcomes[i] = fileOutcome{file: job.file, missing: true}
return nil
}

outcome := fileOutcome{file: job.file, hasLocal: true}
remoteContent, err := remote.get(ctx, base+skill.Name+"/"+job.file)
if err != nil {
// We have a local copy but couldn't fetch the remote to
// compare. Bias toward "out of date" rather than "current":
// updating is idempotent and never discards local work, so a
// false positive just re-syncs, whereas a false "current" could
// hide real drift. (A fully unreachable index is already caught
// earlier and reported as StatusError.)
outcome.changed = true
outcomes[i] = outcome
return nil
}
if !bytes.Equal(hashBytes(local), hashBytes(remoteContent)) {
outcome.changed = true
}
outcomes[i] = outcome
return nil
})
}
// Each goroutine records its result in outcomes[i] and always returns nil,
// so g.Wait never reports an error; we intentionally ignore its return and
// derive the skill status from the collected outcomes below.
_ = g.Wait()

hasFiles := false
for _, outcome := range outcomes {
if outcome.missing {
check.MissingFiles = append(check.MissingFiles, outcome.file)
continue
}
if outcome.hasLocal {
hasFiles = true
}
if outcome.changed {
check.ChangedFiles = append(check.ChangedFiles, outcome.file)
}
}

switch {
case !hasFiles && len(check.MissingFiles) > 0:
check.Status = StatusNotInstalled
case len(check.MissingFiles) > 0 || len(check.ChangedFiles) > 0:
check.Status = StatusOutOfDate
default:
check.Status = StatusCurrent
}
return check
}

func aggregateDirStatus(result *DirStatus) string {
if result.InstalledCount == 0 {
return StatusNotInstalled
}

total := len(result.Skills)
if result.OutOfDateCount > 0 || result.InstalledCount < total {
return StatusOutOfDate
}
return StatusCurrent
}

func hashBytes(b []byte) []byte {
sum := sha256.Sum256(b)
return sum[:]
}

// Install fetches every file of every skill in the index and writes it under
// destDir preserving the skill/relative-path layout (destDir/<skill>/<file>).
// Installation is best-effort: a file that fails to fetch or write is skipped.
Expand Down
109 changes: 109 additions & 0 deletions pkg/agentskills/agentskills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,112 @@ func TestInstall_IndexError(t *testing.T) {
require.Nil(t, installed)
require.Contains(t, err.Error(), "fetching skills index")
}

func TestCheck_NotInstalled(t *testing.T) {
index := Index{Skills: []Skill{
{Name: "stripe-best-practices", Files: []string{"SKILL.md"}},
}}
server := startSkillsServer(t, index, map[string]string{
"stripe-best-practices/SKILL.md": "# best practices",
})

status, err := Check(context.Background(), server.Client(), t.TempDir())

require.NoError(t, err)
require.Equal(t, StatusNotInstalled, status.Status)
require.Equal(t, 0, status.InstalledCount)
require.Equal(t, 0, status.OutOfDateCount)
require.Len(t, status.Skills, 1)
require.Equal(t, StatusNotInstalled, status.Skills[0].Status)
}

func TestCheck_Current(t *testing.T) {
content := "# best practices"
index := Index{Skills: []Skill{
{Name: "stripe-best-practices", Files: []string{"SKILL.md"}},
{Name: "upgrade-stripe", Files: []string{"SKILL.md"}},
}}
server := startSkillsServer(t, index, map[string]string{
"stripe-best-practices/SKILL.md": content,
"upgrade-stripe/SKILL.md": "# upgrade",
})

dest := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte(content), 0600))
require.NoError(t, os.MkdirAll(filepath.Join(dest, "upgrade-stripe"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dest, "upgrade-stripe", "SKILL.md"), []byte("# upgrade"), 0600))

status, err := Check(context.Background(), server.Client(), dest)

require.NoError(t, err)
require.Equal(t, StatusCurrent, status.Status)
require.Equal(t, 2, status.InstalledCount)
require.Equal(t, 0, status.OutOfDateCount)
}

func TestCheck_OutOfDate(t *testing.T) {
index := Index{Skills: []Skill{
{Name: "stripe-best-practices", Files: []string{"SKILL.md"}},
}}
server := startSkillsServer(t, index, map[string]string{
"stripe-best-practices/SKILL.md": "# updated",
})

dest := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# stale"), 0600))

status, err := Check(context.Background(), server.Client(), dest)

require.NoError(t, err)
require.Equal(t, StatusOutOfDate, status.Status)
require.Equal(t, 1, status.InstalledCount)
require.Equal(t, 1, status.OutOfDateCount)
require.Equal(t, []string{"SKILL.md"}, status.Skills[0].ChangedFiles)
}

func TestCheck_SomeSkillsMissing(t *testing.T) {
index := Index{Skills: []Skill{
{Name: "stripe-best-practices", Files: []string{"SKILL.md"}},
{Name: "upgrade-stripe", Files: []string{"SKILL.md"}},
}}
server := startSkillsServer(t, index, map[string]string{
"stripe-best-practices/SKILL.md": "# best practices",
"upgrade-stripe/SKILL.md": "# upgrade",
})

dest := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# best practices"), 0600))

status, err := Check(context.Background(), server.Client(), dest)

require.NoError(t, err)
require.Equal(t, StatusOutOfDate, status.Status)
require.Equal(t, 1, status.InstalledCount)
require.Equal(t, 0, status.OutOfDateCount)
require.Equal(t, StatusCurrent, status.Skills[0].Status)
require.Equal(t, StatusNotInstalled, status.Skills[1].Status)
}

func TestCheck_MissingFileWithinSkill(t *testing.T) {
index := Index{Skills: []Skill{
{Name: "stripe-best-practices", Files: []string{"SKILL.md", "references/billing.md"}},
}}
server := startSkillsServer(t, index, map[string]string{
"stripe-best-practices/SKILL.md": "# best practices",
"stripe-best-practices/references/billing.md": "# billing",
})

dest := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# best practices"), 0600))

status, err := Check(context.Background(), server.Client(), dest)

require.NoError(t, err)
require.Equal(t, StatusOutOfDate, status.Status)
require.Equal(t, 1, status.OutOfDateCount)
require.Equal(t, []string{"references/billing.md"}, status.Skills[0].MissingFiles)
}
Loading
Loading