v0.3.84 97ae0a2

skills_test.go RawHistoryFolder

1package skills
2
3import (
4	"os"
5	"path/filepath"
6	"runtime"
7	"strings"
8	"testing"
9)
10
11func TestSplitFrontmatter(t *testing.T) {
12	in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
13	front, body := splitFrontmatter(in)
14	if front != "name: foo\ndescription: bar" {
15		t.Errorf("front = %q", front)
16	}
17	if body != "body text\n" {
18		t.Errorf("body = %q", body)
19	}
20
21	front2, body2 := splitFrontmatter("no frontmatter here")
22	if front2 != "" || body2 != "no frontmatter here" {
23		t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
24	}
25}
26
27func TestParseFrontmatter(t *testing.T) {
28	front := `name: code-review
29description: "Review a recent change."
30disable-model-invocation: true
31allowed-tools: [read, bash]
32permissions:
33  bash: ["git diff*", "git log*"]
34`
35	s := &Skill{}
36	parseFrontmatter(front, s)
37	if s.Name != "code-review" {
38		t.Errorf("name = %q", s.Name)
39	}
40	if s.Description != "Review a recent change." {
41		t.Errorf("description = %q", s.Description)
42	}
43	if !s.DisableModelInvocation {
44		t.Error("disable-model-invocation was not parsed")
45	}
46	if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
47		t.Errorf("allowed-tools = %v", got)
48	}
49	if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
50		t.Errorf("permissions[bash] = %v", got)
51	}
52}
53
54func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
55	t.Setenv("ZOT_AGENT_SKILLS", "")
56	tmp := t.TempDir()
57	zotHome := filepath.Join(tmp, "home")
58	cwd := filepath.Join(tmp, "proj")
59
60	mk := func(dir, name, desc string) {
61		full := filepath.Join(dir, name)
62		os.MkdirAll(full, 0o755)
63		body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
64		os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
65	}
66
67	// Same skill name in BOTH project and global; project should win.
68	mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
69	mk(filepath.Join(zotHome, "skills"), "shared", "global version")
70	// Unique skill in global only.
71	mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
72
73	skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
74	if len(errs) > 0 {
75		t.Fatalf("errs: %v", errs)
76	}
77	// Expect the two user skills + every built-in shipped with the
78	// binary (currently the write-zot-extension authoring guide).
79	builtins := loadBuiltins()
80	want := 2 + len(builtins)
81	if len(skills) != want {
82		t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
83	}
84	shared := FindByName(skills, "shared")
85	if shared == nil || shared.Description != "project version" {
86		t.Errorf("expected project to win for 'shared', got %v", shared)
87	}
88	if FindByName(skills, "global-only") == nil {
89		t.Errorf("global-only skill missing")
90	}
91	// At least one built-in should have made it through.
92	for _, b := range builtins {
93		if FindByName(skills, b.Name) == nil {
94			t.Errorf("built-in skill %q missing from Discover output", b.Name)
95		}
96	}
97}
98
99func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) {
100	got, errs := Discover("", "", "", false)
101	if len(errs) != 0 {
102		t.Fatalf("errs: %v", errs)
103	}
104	for _, builtin := range loadBuiltins() {
105		if FindByName(got, builtin.Name) == nil {
106			t.Errorf("built-in skill %q missing", builtin.Name)
107		}
108	}
109}
110
111func TestKebabCase(t *testing.T) {
112	cases := map[string]string{
113		"writing-git-commits": "writing-git-commits",
114		"Writing Git Commits": "writing-git-commits",
115		"git_commits":         "git-commits",
116		"git.commits":         "git-commits",
117		"Git/Commits":         "git-commits",
118	}
119	for in, want := range cases {
120		if got := KebabCase(in); got != want {
121			t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
122		}
123	}
124}
125
126func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
127	if runtime.GOOS == "windows" {
128		t.Skip("directory symlinks require elevated privileges on Windows")
129	}
130
131	target := t.TempDir()
132	path := filepath.Join(target, "nested", "review")
133	if err := os.MkdirAll(path, 0o755); err != nil {
134		t.Fatal(err)
135	}
136	if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
137		t.Fatal(err)
138	}
139	root := filepath.Join(t.TempDir(), "skills")
140	if err := os.Symlink(target, root); err != nil {
141		t.Fatal(err)
142	}
143
144	got, errs := DiscoverSources([]Source{{Root: root, Prefix: "linked:"}}, false)
145	if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:nested-review" {
146		t.Fatalf("skills=%#v errs=%v", got, errs)
147	}
148}
149
150func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151	root := t.TempDir()
152	path := filepath.Join(root, "Something", "Writing Git Commits")
153	if err := os.MkdirAll(path, 0o755); err != nil {
154		t.Fatal(err)
155	}
156	body := "---\ndescription: nested\n---\nbody"
157	if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
158		t.Fatal(err)
159	}
160	got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
161	if len(errs) != 0 {
162		t.Fatalf("errs: %v", errs)
163	}
164	if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
165		t.Fatalf("skills = %#v", got)
166	}
167	if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
168		t.Errorf("skill metadata = %#v", got[0])
169	}
170}
171
172func TestDiscoverSourcesHomeDirectoryOverlap(t *testing.T) {
173	t.Setenv("ZOT_AGENT_SKILLS", "")
174	home := t.TempDir()
175	for _, compat := range []string{".agents", ".claude"} {
176		dir := filepath.Join(home, compat, "skills", "review")
177		if err := os.MkdirAll(dir, 0o755); err != nil {
178			t.Fatal(err)
179		}
180		if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+compat[1:]+"-review\n---\nbody"), 0o644); err != nil {
181			t.Fatal(err)
182		}
183	}
184	got, errs := DiscoverSources(SearchSources(t.TempDir(), home, home), false)
185	if len(got) != 2 || len(errs) != 0 {
186		t.Fatalf("skills=%#v errs=%v", got, errs)
187	}
188	for _, s := range got {
189		if !strings.HasPrefix(s.Source, "project") {
190			t.Errorf("source = %q, want project precedence", s.Source)
191		}
192	}
193}
194
195func TestDiscoverSourcesSameFile(t *testing.T) {
196	root := t.TempDir()
197	path := filepath.Join(root, "SKILL.md")
198	if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
199		t.Fatal(err)
200	}
201	for _, second := range []string{root, path} {
202		got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: second, Label: "second"}}, false)
203		if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
204			t.Fatalf("second=%q skills=%#v errs=%v", second, got, errs)
205		}
206	}
207	got, errs := DiscoverSources([]Source{{Root: root, Prefix: "one:"}, {Root: root, Prefix: "two:"}}, false)
208	if len(got) != 2 || len(errs) != 0 || got[0].Name != "one:review" || got[1].Name != "two:review" {
209		t.Fatalf("namespaced skills=%#v errs=%v", got, errs)
210	}
211}
212
213func TestDiscoverSourcesSameFileSymlink(t *testing.T) {
214	root := t.TempDir()
215	path := filepath.Join(root, "SKILL.md")
216	if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
217		t.Fatal(err)
218	}
219	alias := filepath.Join(t.TempDir(), "SKILL.md")
220	if err := os.Symlink(path, alias); err != nil {
221		if runtime.GOOS == "windows" {
222			t.Skipf("symlinks unavailable: %v", err)
223		}
224		t.Fatal(err)
225	}
226	got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: alias}}, false)
227	if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
228		t.Fatalf("skills=%#v errs=%v", got, errs)
229	}
230}
231
232func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
233	a, b := t.TempDir(), t.TempDir()
234	for _, root := range []string{a, b} {
235		dir := filepath.Join(root, "review")
236		if err := os.MkdirAll(dir, 0o755); err != nil {
237			t.Fatal(err)
238		}
239		if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
240			t.Fatal(err)
241		}
242	}
243	got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
244	if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
245		t.Fatalf("got=%#v errs=%v", got, errs)
246	}
247}
248
249func TestVisibleSkillsHidesBuiltins(t *testing.T) {
250	in := []*Skill{
251		{Name: "user-one"},
252		{Name: "built-one", Builtin: true},
253		{Name: "user-two"},
254	}
255	out := VisibleSkills(in)
256	if len(out) != 2 {
257		t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
258	}
259	for _, s := range out {
260		if s.Builtin {
261			t.Errorf("built-in %q leaked into visible set", s.Name)
262		}
263	}
264}
265
266func TestSystemPromptAddendum(t *testing.T) {
267	skills := []*Skill{
268		{Name: "built-a", Description: "Do A.", Builtin: true},
269		{Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
270		{Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
271	}
272	out := SystemPromptAddendum(skills)
273	if !contains(out, "- built-a [builtin]: Do A.\n") {
274		t.Errorf("builtin entry missing or wrong:\n%s", out)
275	}
276	if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
277		t.Errorf("user entry missing path pointer:\n%s", out)
278	}
279	if contains(out, "manual-c") {
280		t.Errorf("manual skill leaked into system prompt:\n%s", out)
281	}
282}
283
284func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
285	out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
286	if out != "" {
287		t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
288	}
289}
290
291func contains(s, sub string) bool {
292	return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
293}
294
295func stringIndex(s, sub string) int {
296	for i := 0; i+len(sub) <= len(s); i++ {
297		if s[i:i+len(sub)] == sub {
298			return i
299		}
300	}
301	return -1
302}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close