v0.4.10 5ac0009

skills_test.go RawHistoryFolder

1package skills
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"runtime"
8	"strings"
9	"syscall"
10	"testing"
11)
12
13func TestSplitFrontmatter(t *testing.T) {
14	in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
15	front, body := splitFrontmatter(in)
16	if front != "name: foo\ndescription: bar" {
17		t.Errorf("front = %q", front)
18	}
19	if body != "body text\n" {
20		t.Errorf("body = %q", body)
21	}
22
23	front2, body2 := splitFrontmatter("no frontmatter here")
24	if front2 != "" || body2 != "no frontmatter here" {
25		t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
26	}
27}
28
29func TestParseFrontmatter(t *testing.T) {
30	front := `name: code-review
31description: "Review a recent change."
32disable-model-invocation: true
33allowed-tools: [read, bash]
34permissions:
35  bash: ["git diff*", "git log*"]
36`
37	s := &Skill{}
38	parseFrontmatter(front, s)
39	if s.Name != "code-review" {
40		t.Errorf("name = %q", s.Name)
41	}
42	if s.Description != "Review a recent change." {
43		t.Errorf("description = %q", s.Description)
44	}
45	if !s.DisableModelInvocation {
46		t.Error("disable-model-invocation was not parsed")
47	}
48	if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
49		t.Errorf("allowed-tools = %v", got)
50	}
51	if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
52		t.Errorf("permissions[bash] = %v", got)
53	}
54}
55
56func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
57	t.Setenv("ZOT_AGENT_SKILLS", "")
58	tmp := t.TempDir()
59	zotHome := filepath.Join(tmp, "home")
60	cwd := filepath.Join(tmp, "proj")
61
62	mk := func(dir, name, desc string) {
63		full := filepath.Join(dir, name)
64		os.MkdirAll(full, 0o755)
65		body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
66		os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
67	}
68
69	// Same skill name in BOTH project and global; project should win.
70	mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
71	mk(filepath.Join(zotHome, "skills"), "shared", "global version")
72	// Unique skill in global only.
73	mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
74
75	skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
76	if len(errs) > 0 {
77		t.Fatalf("errs: %v", errs)
78	}
79	// Expect the two user skills + every built-in shipped with the
80	// binary (currently the write-zot-extension authoring guide).
81	builtins := loadBuiltins()
82	want := 2 + len(builtins)
83	if len(skills) != want {
84		t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
85	}
86	shared := FindByName(skills, "shared")
87	if shared == nil || shared.Description != "project version" {
88		t.Errorf("expected project to win for 'shared', got %v", shared)
89	}
90	if FindByName(skills, "global-only") == nil {
91		t.Errorf("global-only skill missing")
92	}
93	// At least one built-in should have made it through.
94	for _, b := range builtins {
95		if FindByName(skills, b.Name) == nil {
96			t.Errorf("built-in skill %q missing from Discover output", b.Name)
97		}
98	}
99}
100
101func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) {
102	got, errs := Discover("", "", "", false)
103	if len(errs) != 0 {
104		t.Fatalf("errs: %v", errs)
105	}
106	for _, builtin := range loadBuiltins() {
107		if FindByName(got, builtin.Name) == nil {
108			t.Errorf("built-in skill %q missing", builtin.Name)
109		}
110	}
111}
112
113func TestKebabCase(t *testing.T) {
114	cases := map[string]string{
115		"writing-git-commits": "writing-git-commits",
116		"Writing Git Commits": "writing-git-commits",
117		"git_commits":         "git-commits",
118		"git.commits":         "git-commits",
119		"Git/Commits":         "git-commits",
120	}
121	for in, want := range cases {
122		if got := KebabCase(in); got != want {
123			t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
124		}
125	}
126}
127
128func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
129	if runtime.GOOS == "windows" {
130		t.Skip("directory symlinks require elevated privileges on Windows")
131	}
132
133	target := t.TempDir()
134	path := filepath.Join(target, "nested", "review")
135	if err := os.MkdirAll(path, 0o755); err != nil {
136		t.Fatal(err)
137	}
138	if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
139		t.Fatal(err)
140	}
141	root := filepath.Join(t.TempDir(), "skills")
142	if err := os.Symlink(target, root); err != nil {
143		t.Fatal(err)
144	}
145
146	got, errs := DiscoverSources([]Source{{Root: root, Prefix: "linked:"}}, false)
147	if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:nested-review" {
148		t.Fatalf("skills=%#v errs=%v", got, errs)
149	}
150}
151
152func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) {
153	shared := t.TempDir()
154	writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body")
155	root := t.TempDir()
156	linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd"))
157
158	got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false)
159	if len(errs) != 0 || len(got) != 1 {
160		t.Fatalf("skills=%#v errs=%v", got, errs)
161	}
162	if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" {
163		t.Errorf("skill = %#v", got[0])
164	}
165	if want := filepath.Join(mustEvalSymlinks(t, root), "tdd", "SKILL.md"); got[0].Path != want {
166		t.Errorf("path = %q, want the link location %q", got[0].Path, want)
167	}
168}
169
170func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) {
171	shared := t.TempDir()
172	writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body")
173	writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body")
174	root := t.TempDir()
175	linkDir(t, shared, filepath.Join(root, "engineering"))
176
177	got, errs := DiscoverSources([]Source{{Root: root}}, false)
178	if len(errs) != 0 || len(got) != 2 {
179		t.Fatalf("skills=%#v errs=%v", got, errs)
180	}
181	if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" {
182		t.Errorf("names = %q, %q", got[0].Name, got[1].Name)
183	}
184}
185
186func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) {
187	root := t.TempDir()
188	writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
189	linkDir(t, root, filepath.Join(root, "loop"))
190	linkDir(t, root, filepath.Join(root, "alias"))
191
192	got, errs := DiscoverSources([]Source{{Root: root}}, false)
193	if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
194		t.Fatalf("skills=%#v errs=%v", got, errs)
195	}
196}
197
198func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) {
199	root := t.TempDir()
200	writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
201	missing := filepath.Join(t.TempDir(), "gone")
202	linkDir(t, missing, filepath.Join(root, "gone"))
203
204	got, errs := DiscoverSources([]Source{{Root: root}}, false)
205	if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
206		t.Fatalf("skills=%#v errs=%v", got, errs)
207	}
208}
209
210func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) {
211	root := t.TempDir()
212	real := filepath.Join(root, "shared")
213	writeSkill(t, real, "name: review", "body")
214	linkDir(t, real, filepath.Join(root, "alias"))
215
216	got, errs := DiscoverSources([]Source{{Root: root}}, false)
217	if len(errs) != 0 || len(got) != 1 {
218		t.Fatalf("skills=%#v errs=%v", got, errs)
219	}
220	if want := filepath.Join(mustEvalSymlinks(t, root), "shared", "SKILL.md"); got[0].Path != want {
221		t.Errorf("path = %q, want %q", got[0].Path, want)
222	}
223}
224
225func TestDiscoverSourcesKeepsUnnamedRealSkillWhenAliasSortsFirst(t *testing.T) {
226	root := t.TempDir()
227	real := filepath.Join(root, "z", "review")
228	writeSkill(t, real, "", "body")
229	linkDir(t, real, filepath.Join(root, "alias"))
230
231	got, errs := DiscoverSources([]Source{{Root: root}}, false)
232	if len(errs) != 0 || len(got) != 1 {
233		t.Fatalf("skills=%#v errs=%v", got, errs)
234	}
235	if got[0].Name != "z-review" || got[0].Path != filepath.Join(mustEvalSymlinks(t, root), "z", "review", "SKILL.md") {
236		t.Errorf("skill = %#v, want the real directory name and path", got[0])
237	}
238}
239
240func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) {
241	root := t.TempDir()
242	writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
243	linkDir(t, filepath.Join(root, "loop"), filepath.Join(root, "loop"))
244
245	got, errs := DiscoverSources([]Source{{Root: root}}, false)
246	if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
247		t.Fatalf("skills=%#v errs=%v", got, errs)
248	}
249}
250
251func TestSymlinkLoopError(t *testing.T) {
252	if !symlinkLoopError(fmt.Errorf("stat: %w", syscall.ELOOP)) {
253		t.Error("ELOOP should be ignored")
254	}
255	if runtime.GOOS == "windows" && !symlinkLoopError(fmt.Errorf("stat: %w", syscall.Errno(1921))) {
256		t.Error("Windows cyclic-link error should be ignored")
257	}
258	if symlinkLoopError(fmt.Errorf("stat: %w", os.ErrPermission)) {
259		t.Error("permission errors must still be reported")
260	}
261}
262
263func mustEvalSymlinks(t *testing.T, path string) string {
264	t.Helper()
265	resolved, err := filepath.EvalSymlinks(path)
266	if err != nil {
267		t.Fatal(err)
268	}
269	return resolved
270}
271
272func writeSkill(t *testing.T, dir, front, body string) {
273	t.Helper()
274	if err := os.MkdirAll(dir, 0o755); err != nil {
275		t.Fatal(err)
276	}
277	frontmatter := "---\ndescription: test\n---\n"
278	if front != "" {
279		frontmatter = "---\n" + front + "\ndescription: test\n---\n"
280	}
281	if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
282		t.Fatal(err)
283	}
284}
285
286func linkDir(t *testing.T, target, path string) {
287	t.Helper()
288	if err := os.Symlink(target, path); err != nil {
289		if runtime.GOOS == "windows" {
290			t.Skipf("symlinks unavailable: %v", err)
291		}
292		t.Fatal(err)
293	}
294}
295
296func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
297	root := t.TempDir()
298	path := filepath.Join(root, "Something", "Writing Git Commits")
299	if err := os.MkdirAll(path, 0o755); err != nil {
300		t.Fatal(err)
301	}
302	body := "---\ndescription: nested\n---\nbody"
303	if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
304		t.Fatal(err)
305	}
306	got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
307	if len(errs) != 0 {
308		t.Fatalf("errs: %v", errs)
309	}
310	if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
311		t.Fatalf("skills = %#v", got)
312	}
313	if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
314		t.Errorf("skill metadata = %#v", got[0])
315	}
316}
317
318func TestDiscoverSourcesHomeDirectoryOverlap(t *testing.T) {
319	t.Setenv("ZOT_AGENT_SKILLS", "")
320	home := t.TempDir()
321	for _, compat := range []string{".agents", ".claude"} {
322		dir := filepath.Join(home, compat, "skills", "review")
323		if err := os.MkdirAll(dir, 0o755); err != nil {
324			t.Fatal(err)
325		}
326		if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+compat[1:]+"-review\n---\nbody"), 0o644); err != nil {
327			t.Fatal(err)
328		}
329	}
330	got, errs := DiscoverSources(SearchSources(t.TempDir(), home, home), false)
331	if len(got) != 2 || len(errs) != 0 {
332		t.Fatalf("skills=%#v errs=%v", got, errs)
333	}
334	for _, s := range got {
335		if !strings.HasPrefix(s.Source, "project") {
336			t.Errorf("source = %q, want project precedence", s.Source)
337		}
338	}
339}
340
341func TestDiscoverSourcesSameFile(t *testing.T) {
342	root := t.TempDir()
343	path := filepath.Join(root, "SKILL.md")
344	if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
345		t.Fatal(err)
346	}
347	for _, second := range []string{root, path} {
348		got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: second, Label: "second"}}, false)
349		if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
350			t.Fatalf("second=%q skills=%#v errs=%v", second, got, errs)
351		}
352	}
353	got, errs := DiscoverSources([]Source{{Root: root, Prefix: "one:"}, {Root: root, Prefix: "two:"}}, false)
354	if len(got) != 2 || len(errs) != 0 || got[0].Name != "one:review" || got[1].Name != "two:review" {
355		t.Fatalf("namespaced skills=%#v errs=%v", got, errs)
356	}
357}
358
359func TestDiscoverSourcesSameFileSymlink(t *testing.T) {
360	root := t.TempDir()
361	path := filepath.Join(root, "SKILL.md")
362	if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
363		t.Fatal(err)
364	}
365	alias := filepath.Join(t.TempDir(), "SKILL.md")
366	if err := os.Symlink(path, alias); err != nil {
367		if runtime.GOOS == "windows" {
368			t.Skipf("symlinks unavailable: %v", err)
369		}
370		t.Fatal(err)
371	}
372	got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: alias}}, false)
373	if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
374		t.Fatalf("skills=%#v errs=%v", got, errs)
375	}
376}
377
378func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
379	a, b := t.TempDir(), t.TempDir()
380	for _, root := range []string{a, b} {
381		dir := filepath.Join(root, "review")
382		if err := os.MkdirAll(dir, 0o755); err != nil {
383			t.Fatal(err)
384		}
385		if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
386			t.Fatal(err)
387		}
388	}
389	got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
390	if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
391		t.Fatalf("got=%#v errs=%v", got, errs)
392	}
393}
394
395func TestVisibleSkillsHidesBuiltins(t *testing.T) {
396	in := []*Skill{
397		{Name: "user-one"},
398		{Name: "built-one", Builtin: true},
399		{Name: "user-two"},
400	}
401	out := VisibleSkills(in)
402	if len(out) != 2 {
403		t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
404	}
405	for _, s := range out {
406		if s.Builtin {
407			t.Errorf("built-in %q leaked into visible set", s.Name)
408		}
409	}
410}
411
412func TestSystemPromptAddendum(t *testing.T) {
413	skills := []*Skill{
414		{Name: "built-a", Description: "Do A.", Builtin: true},
415		{Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
416		{Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
417	}
418	out := SystemPromptAddendum(skills)
419	if !contains(out, "- built-a [builtin]: Do A.\n") {
420		t.Errorf("builtin entry missing or wrong:\n%s", out)
421	}
422	if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
423		t.Errorf("user entry missing path pointer:\n%s", out)
424	}
425	if contains(out, "manual-c") {
426		t.Errorf("manual skill leaked into system prompt:\n%s", out)
427	}
428}
429
430func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
431	out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
432	if out != "" {
433		t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
434	}
435}
436
437func contains(s, sub string) bool {
438	return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
439}
440
441func stringIndex(s, sub string) int {
442	for i := 0; i+len(sub) <= len(s); i++ {
443		if s[i:i+len(sub)] == sub {
444			return i
445		}
446	}
447	return -1
448}

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