Skip to content

Commit 820c56b

Browse files
committed
feat: add skills lock file support
1 parent d5a0dc8 commit 820c56b

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

internal/ai/skills/lock.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package skills
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
)
10+
11+
// SkillsLock records the installed state of the auth0 agent-skills plugin.
12+
type SkillsLock struct {
13+
Repo string `json:"repo"`
14+
Ref string `json:"ref"`
15+
CommitSHA string `json:"commitSHA"`
16+
InstalledAt time.Time `json:"installedAt"`
17+
UpdatedAt time.Time `json:"updatedAt"`
18+
LastCheckedAt time.Time `json:"lastCheckedAt"`
19+
Skills []string `json:"skills"`
20+
Agents []string `json:"agents"`
21+
Scope string `json:"scope"` // "global" or "local"
22+
}
23+
24+
// ReadLock reads the skills-lock.json at path. Returns nil, nil when the file does not exist.
25+
func ReadLock(path string) (*SkillsLock, error) {
26+
data, err := os.ReadFile(path)
27+
if err != nil {
28+
if errors.Is(err, os.ErrNotExist) {
29+
return nil, nil
30+
}
31+
return nil, err
32+
}
33+
var lock SkillsLock
34+
if err := json.Unmarshal(data, &lock); err != nil {
35+
return nil, err
36+
}
37+
return &lock, nil
38+
}
39+
40+
// WriteLock serialises lock as JSON and writes it to path, creating parent directories as needed.
41+
func WriteLock(path string, lock *SkillsLock) error {
42+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
43+
return err
44+
}
45+
data, err := json.MarshalIndent(lock, "", " ")
46+
if err != nil {
47+
return err
48+
}
49+
return os.WriteFile(path, data, 0o644)
50+
}

internal/ai/skills/lock_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package skills
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestReadLock(t *testing.T) {
14+
t.Run("returns nil nil when file does not exist", func(t *testing.T) {
15+
lock, err := ReadLock(filepath.Join(t.TempDir(), "skills-lock.json"))
16+
require.NoError(t, err)
17+
assert.Nil(t, lock)
18+
})
19+
20+
t.Run("returns parsed lock for valid file", func(t *testing.T) {
21+
dir := t.TempDir()
22+
path := filepath.Join(dir, "skills-lock.json")
23+
content := `{
24+
"repo": "https://github.com/auth0/agent-skills.git",
25+
"ref": "main",
26+
"commitSHA": "abc123",
27+
"installedAt": "2026-05-12T10:00:00Z",
28+
"updatedAt": "2026-05-12T10:00:00Z",
29+
"lastCheckedAt": "2026-05-12T11:00:00Z",
30+
"skills": ["auth0-react", "auth0-nextjs"],
31+
"agents": ["claude-code"],
32+
"scope": "global"
33+
}`
34+
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
35+
36+
lock, err := ReadLock(path)
37+
require.NoError(t, err)
38+
require.NotNil(t, lock)
39+
assert.Equal(t, "https://github.com/auth0/agent-skills.git", lock.Repo)
40+
assert.Equal(t, "main", lock.Ref)
41+
assert.Equal(t, "abc123", lock.CommitSHA)
42+
assert.Equal(t, []string{"auth0-react", "auth0-nextjs"}, lock.Skills)
43+
assert.Equal(t, []string{"claude-code"}, lock.Agents)
44+
assert.Equal(t, "global", lock.Scope)
45+
})
46+
47+
t.Run("returns error for invalid JSON", func(t *testing.T) {
48+
dir := t.TempDir()
49+
path := filepath.Join(dir, "skills-lock.json")
50+
require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644))
51+
52+
_, err := ReadLock(path)
53+
require.Error(t, err)
54+
})
55+
56+
t.Run("returns error on unreadable file", func(t *testing.T) {
57+
dir := t.TempDir()
58+
path := filepath.Join(dir, "skills-lock.json")
59+
require.NoError(t, os.WriteFile(path, []byte("{}"), 0o000))
60+
t.Cleanup(func() { os.Chmod(path, 0o644) })
61+
62+
if os.Getuid() == 0 {
63+
t.Skip("root bypasses file permissions")
64+
}
65+
_, err := ReadLock(path)
66+
require.Error(t, err)
67+
})
68+
}
69+
70+
func TestWriteLock(t *testing.T) {
71+
now := time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC)
72+
73+
t.Run("creates file with correct content", func(t *testing.T) {
74+
dir := t.TempDir()
75+
path := filepath.Join(dir, "skills-lock.json")
76+
77+
lock := &SkillsLock{
78+
Repo: "https://github.com/auth0/agent-skills.git",
79+
Ref: "main",
80+
CommitSHA: "deadbeef",
81+
InstalledAt: now,
82+
UpdatedAt: now,
83+
LastCheckedAt: now,
84+
Skills: []string{"auth0-react"},
85+
Agents: []string{"cursor"},
86+
Scope: "local",
87+
}
88+
require.NoError(t, WriteLock(path, lock))
89+
90+
got, err := ReadLock(path)
91+
require.NoError(t, err)
92+
require.NotNil(t, got)
93+
assert.Equal(t, lock.Repo, got.Repo)
94+
assert.Equal(t, lock.CommitSHA, got.CommitSHA)
95+
assert.Equal(t, lock.Skills, got.Skills)
96+
assert.Equal(t, lock.Scope, got.Scope)
97+
assert.Equal(t, lock.InstalledAt.UTC(), got.InstalledAt.UTC())
98+
assert.Equal(t, lock.LastCheckedAt.UTC(), got.LastCheckedAt.UTC())
99+
})
100+
101+
t.Run("creates parent directories when they do not exist", func(t *testing.T) {
102+
dir := t.TempDir()
103+
path := filepath.Join(dir, "nested", "deep", "skills-lock.json")
104+
105+
require.NoError(t, WriteLock(path, &SkillsLock{Scope: "global"}))
106+
107+
got, err := ReadLock(path)
108+
require.NoError(t, err)
109+
require.NotNil(t, got)
110+
assert.Equal(t, "global", got.Scope)
111+
})
112+
113+
t.Run("overwrites existing lock file", func(t *testing.T) {
114+
dir := t.TempDir()
115+
path := filepath.Join(dir, "skills-lock.json")
116+
117+
require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "first"}))
118+
require.NoError(t, WriteLock(path, &SkillsLock{CommitSHA: "second"}))
119+
120+
got, err := ReadLock(path)
121+
require.NoError(t, err)
122+
assert.Equal(t, "second", got.CommitSHA)
123+
})
124+
125+
t.Run("roundtrip preserves all fields", func(t *testing.T) {
126+
dir := t.TempDir()
127+
path := filepath.Join(dir, "skills-lock.json")
128+
129+
original := &SkillsLock{
130+
Repo: "https://github.com/auth0/agent-skills.git",
131+
Ref: "v1.2.3",
132+
CommitSHA: "cafebabe",
133+
InstalledAt: now,
134+
UpdatedAt: now.Add(time.Hour),
135+
LastCheckedAt: now.Add(2 * time.Hour),
136+
Skills: []string{"auth0-react", "auth0-nextjs", "auth0-vue"},
137+
Agents: []string{"claude-code", "cursor", "gemini-cli"},
138+
Scope: "global",
139+
}
140+
141+
require.NoError(t, WriteLock(path, original))
142+
got, err := ReadLock(path)
143+
require.NoError(t, err)
144+
require.NotNil(t, got)
145+
146+
assert.Equal(t, original.Repo, got.Repo)
147+
assert.Equal(t, original.Ref, got.Ref)
148+
assert.Equal(t, original.CommitSHA, got.CommitSHA)
149+
assert.Equal(t, original.InstalledAt.UTC(), got.InstalledAt.UTC())
150+
assert.Equal(t, original.UpdatedAt.UTC(), got.UpdatedAt.UTC())
151+
assert.Equal(t, original.LastCheckedAt.UTC(), got.LastCheckedAt.UTC())
152+
assert.Equal(t, original.Skills, got.Skills)
153+
assert.Equal(t, original.Agents, got.Agents)
154+
assert.Equal(t, original.Scope, got.Scope)
155+
})
156+
}

0 commit comments

Comments
 (0)