v0.51.0 a9d674e

symlink_test.go RawHistoryFolder

1package skills
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"runtime"
8	"strings"
9	"testing"
10)
11
12func writeSkill(t *testing.T, dir, name, desc string) {
13	t.Helper()
14	if err := os.MkdirAll(dir, 0o755); err != nil {
15		t.Fatal(err)
16	}
17	body := "---\nname: " + name + "\ndescription: " + desc + "\n---\nbody of " + name + "\n"
18	if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
19		t.Fatal(err)
20	}
21}
22
23func symlinkOrSkip(t *testing.T, target, link string) {
24	t.Helper()
25	if err := os.Symlink(target, link); err != nil {
26		t.Skipf("symlink unavailable: %v", err)
27	}
28}
29
30func TestDiscoverFollowsSymlinkedSkillDirectories(t *testing.T) {
31	t.Setenv("ZUT_AGENT_SKILLS", "")
32	tmp := t.TempDir()
33	home := filepath.Join(tmp, "home")
34	shared := filepath.Join(tmp, "shared")
35	writeSkill(t, filepath.Join(shared, "linked"), "linked", "from checkout")
36	writeSkill(t, filepath.Join(shared, "group", "inner"), "inner", "nested")
37	root := filepath.Join(home, ".agents", "skills")
38	if err := os.MkdirAll(root, 0o755); err != nil {
39		t.Fatal(err)
40	}
41	symlinkOrSkip(t, filepath.Join(shared, "linked"), filepath.Join(root, "linked"))
42	symlinkOrSkip(t, filepath.Join(shared, "group"), filepath.Join(root, "bundle"))
43	symlinkOrSkip(t, filepath.Join(tmp, "missing"), filepath.Join(root, "dangling"))
44
45	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
46	if len(errs) != 0 {
47		t.Fatalf("errors = %v", errs)
48	}
49	linked := FindByName(list, "linked")
50	if linked == nil {
51		t.Fatalf("linked skill missing: %v", list)
52	}
53	// The reported path is the installed (link) location.
54	if want := filepath.Join(root, "linked", "SKILL.md"); linked.Path != want {
55		t.Fatalf("path = %q, want %q", linked.Path, want)
56	}
57	inner := FindByName(list, "inner")
58	if inner == nil {
59		t.Fatal("skill under linked parent missing")
60	}
61	// Fork slash aliases follow the walked path and keep the frontmatter name.
62	if FindByName(list, "bundle/inner") != inner {
63		t.Fatalf("alias bundle/inner not registered: %#v", inner.Aliases)
64	}
65}
66
67func TestDiscoverSymlinkedUserRoot(t *testing.T) {
68	t.Setenv("ZUT_AGENT_SKILLS", "")
69	tmp := t.TempDir()
70	real := filepath.Join(tmp, "real-skills")
71	writeSkill(t, filepath.Join(real, "one"), "one", "d")
72	home := filepath.Join(tmp, "home")
73	if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
74		t.Fatal(err)
75	}
76	symlinkOrSkip(t, real, filepath.Join(home, ".claude", "skills"))
77	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
78	if len(errs) != 0 || FindByName(list, "one") == nil {
79		t.Fatalf("skills=%v errs=%v", list, errs)
80	}
81}
82
83func TestDiscoverSymlinkCycleTerminates(t *testing.T) {
84	t.Setenv("ZUT_AGENT_SKILLS", "")
85	tmp := t.TempDir()
86	home := filepath.Join(tmp, "home")
87	root := filepath.Join(home, ".agents", "skills")
88	writeSkill(t, filepath.Join(root, "a"), "a", "d")
89	symlinkOrSkip(t, root, filepath.Join(root, "a", "loop"))
90	symlinkOrSkip(t, filepath.Join(root, "self"), filepath.Join(root, "self")) // ELOOP
91	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
92	if len(errs) != 0 {
93		t.Fatalf("errors = %v", errs)
94	}
95	n := 0
96	for _, s := range list {
97		if !s.Builtin {
98			n++
99		}
100	}
101	if n != 1 {
102		t.Fatalf("user skills = %d, want 1: %v", n, list)
103	}
104}
105
106func TestDiscoverPrefersRealDirectoryOverLink(t *testing.T) {
107	t.Setenv("ZUT_AGENT_SKILLS", "")
108	tmp := t.TempDir()
109	home := filepath.Join(tmp, "home")
110	root := filepath.Join(home, ".agents", "skills")
111	// "aaa" sorts before "real" but is a link to it; the real path must win.
112	writeSkill(t, filepath.Join(root, "real"), "real", "d")
113	symlinkOrSkip(t, filepath.Join(root, "real"), filepath.Join(root, "aaa"))
114	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
115	if len(errs) != 0 {
116		t.Fatalf("errors = %v", errs)
117	}
118	s := FindByName(list, "real")
119	if s == nil || s.Path != filepath.Join(root, "real", "SKILL.md") {
120		t.Fatalf("skill = %#v", s)
121	}
122}
123
124func TestBundledSymlinkedEntriesStayConfinedToRoot(t *testing.T) {
125	tmp := t.TempDir()
126	root := filepath.Join(tmp, "ext")
127	bundle := filepath.Join(root, "skills")
128	writeSkill(t, filepath.Join(root, "shared", "inside"), "inside", "in root")
129	writeSkill(t, filepath.Join(tmp, "outside", "evil"), "evil", "outside root")
130	if err := os.MkdirAll(bundle, 0o755); err != nil {
131		t.Fatal(err)
132	}
133	symlinkOrSkip(t, filepath.Join(root, "shared", "inside"), filepath.Join(bundle, "inside"))
134	symlinkOrSkip(t, filepath.Join(tmp, "outside", "evil"), filepath.Join(bundle, "evil"))
135	symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
136
137	got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
138	if FindByName(got, "inside") == nil {
139		t.Fatalf("in-root link not followed: %v (errs %v)", got, errs)
140	}
141	if FindByName(got, "evil") != nil {
142		t.Fatal("link escaped extension root")
143	}
144	if len(errs) == 0 {
145		t.Fatal("expected escape diagnostic")
146	}
147}
148
149func TestBundledSymlinkCycleAndDanglingAreSilentButRealDirsWin(t *testing.T) {
150	tmp := t.TempDir()
151	root := filepath.Join(tmp, "ext")
152	bundle := filepath.Join(root, "skills")
153	// "aaa" sorts before "zzz" and links to it. Both carry the same
154	// frontmatter name, so whichever loads first owns it: the real directory.
155	realDir := filepath.Join(bundle, "zzz")
156	writeSkill(t, realDir, "shared", "real")
157	symlinkOrSkip(t, realDir, filepath.Join(bundle, "aaa"))
158	symlinkOrSkip(t, filepath.Join(bundle, "loop"), filepath.Join(bundle, "loop"))
159	symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
160	got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
161	if len(errs) != 0 {
162		t.Fatalf("dangling/cyclic links must be skipped silently: %v", errs)
163	}
164	s := FindByName(got, "shared")
165	if s == nil || s.Path != filepath.Join(realDir, "SKILL.md") {
166		t.Fatalf("real directory did not win: %#v", s)
167	}
168	if len(got) != 1 {
169		t.Fatalf("skills = %v", got)
170	}
171}
172
173func TestBundledSymlinkPermissionErrorIsReported(t *testing.T) {
174	if runtime.GOOS == "windows" || os.Geteuid() == 0 {
175		t.Skip("permission bits are not enforced here")
176	}
177	tmp := t.TempDir()
178	root := filepath.Join(tmp, "ext")
179	bundle := filepath.Join(root, "skills")
180	locked := filepath.Join(root, "locked")
181	writeSkill(t, filepath.Join(locked, "hidden"), "hidden", "d")
182	if err := os.MkdirAll(bundle, 0o755); err != nil {
183		t.Fatal(err)
184	}
185	symlinkOrSkip(t, filepath.Join(locked, "hidden"), filepath.Join(bundle, "hidden"))
186	if err := os.Chmod(locked, 0); err != nil {
187		t.Fatal(err)
188	}
189	t.Cleanup(func() { os.Chmod(locked, 0o755) })
190	got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
191	if len(got) != 0 || len(errs) == 0 {
192		t.Fatalf("permission failure was swallowed: skills=%v errs=%v", got, errs)
193	}
194}
195
196func TestDiscoverReportsUnreadableLinkTarget(t *testing.T) {
197	if runtime.GOOS == "windows" || os.Geteuid() == 0 {
198		t.Skip("permission bits are not enforced here")
199	}
200	t.Setenv("ZUT_AGENT_SKILLS", "")
201	tmp := t.TempDir()
202	home := filepath.Join(tmp, "home")
203	root := filepath.Join(home, ".agents", "skills")
204	locked := filepath.Join(tmp, "locked")
205	writeSkill(t, filepath.Join(locked, "x"), "x", "d")
206	if err := os.MkdirAll(root, 0o755); err != nil {
207		t.Fatal(err)
208	}
209	symlinkOrSkip(t, filepath.Join(locked, "x"), filepath.Join(root, "x"))
210	if err := os.Chmod(locked, 0); err != nil {
211		t.Fatal(err)
212	}
213	t.Cleanup(func() { os.Chmod(locked, 0o755) })
214	_, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
215	if len(errs) == 0 {
216		t.Fatal("permission failure on a linked directory was swallowed")
217	}
218}
219
220func userSkillCount(list []*Skill) int {
221	n := 0
222	for _, s := range list {
223		if !s.Builtin {
224			n++
225		}
226	}
227	return n
228}
229
230func TestDiscoverIgnoresLinkToAncestorOfRoot(t *testing.T) {
231	t.Setenv("ZUT_AGENT_SKILLS", "")
232	tmp := t.TempDir()
233	home := filepath.Join(tmp, "home")
234	root := filepath.Join(home, ".agents", "skills")
235	writeSkill(t, filepath.Join(root, "mine"), "mine", "d")
236	// A sibling tree that must never be pulled in through an ancestor link.
237	writeSkill(t, filepath.Join(home, "unrelated", "stray"), "stray", "d")
238	symlinkOrSkip(t, home, filepath.Join(root, "up-home"))
239	symlinkOrSkip(t, filepath.Join(home, ".agents"), filepath.Join(root, "up-agents"))
240	symlinkOrSkip(t, root, filepath.Join(root, "self"))
241	// On Windows a bare separator depends on the process drive, which can
242	// differ from the temporary directory's drive. Use this tree's own root.
243	symlinkOrSkip(t, filepath.VolumeName(root)+string(filepath.Separator), filepath.Join(root, "up-fs-root"))
244	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
245	if len(errs) != 0 {
246		t.Fatalf("errors = %v", errs)
247	}
248	if FindByName(list, "mine") == nil || FindByName(list, "stray") != nil || userSkillCount(list) != 1 {
249		t.Fatalf("skills = %v", list)
250	}
251}
252
253func TestDiscoverKeepsExternalSharedLinkThatDoesNotContainRoot(t *testing.T) {
254	t.Setenv("ZUT_AGENT_SKILLS", "")
255	tmp := t.TempDir()
256	home := filepath.Join(tmp, "home")
257	root := filepath.Join(home, ".agents", "skills")
258	shared := filepath.Join(tmp, "dev", "skills-checkout")
259	writeSkill(t, filepath.Join(shared, "team", "review"), "review", "d")
260	if err := os.MkdirAll(root, 0o755); err != nil {
261		t.Fatal(err)
262	}
263	symlinkOrSkip(t, shared, filepath.Join(root, "shared"))
264	list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
265	if len(errs) != 0 || FindByName(list, "review") == nil {
266		t.Fatalf("skills=%v errs=%v", list, errs)
267	}
268}
269
270func TestScanUserSkillDirBoundsDepthWithDiagnostic(t *testing.T) {
271	tmp := t.TempDir()
272	root := filepath.Join(tmp, "skills")
273	writeSkill(t, filepath.Join(root, "top"), "top", "d")
274	deep := root
275	for i := 0; i < maxSkillScanDepth+5; i++ {
276		deep = filepath.Join(deep, "d")
277	}
278	writeSkill(t, deep, "too-deep", "d")
279	seen := map[string]*Skill{}
280	errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
281	if seen["top"] == nil || seen["too-deep"] != nil {
282		t.Fatalf("seen = %v", seen)
283	}
284	if len(errs) != 1 || !strings.Contains(errs[0].Error(), "skill scan stopped early") {
285		t.Fatalf("errs = %v", errs)
286	}
287}
288
289func TestScanUserSkillDirBoundsDirectoryCount(t *testing.T) {
290	tmp := t.TempDir()
291	root := filepath.Join(tmp, "skills")
292	writeSkill(t, filepath.Join(root, "a-first"), "first", "d")
293	for i := 0; i < maxSkillScanDirs+50; i++ {
294		if err := os.MkdirAll(filepath.Join(root, "z-bulk", fmt.Sprintf("d%05d", i)), 0o755); err != nil {
295			t.Fatal(err)
296		}
297	}
298	seen := map[string]*Skill{}
299	errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
300	if seen["first"] == nil {
301		t.Fatal("skills found before the cap were dropped")
302	}
303	if len(errs) != 1 || !strings.Contains(errs[0].Error(), "more than") {
304		t.Fatalf("errs = %v", errs)
305	}
306}
307
308func TestBundledRealAndAliasUnnamedSkillLoadsOnce(t *testing.T) {
309	tmp := t.TempDir()
310	root := filepath.Join(tmp, "ext")
311	bundle := filepath.Join(root, "skills")
312	bar := filepath.Join(bundle, "bar")
313	if err := os.MkdirAll(bar, 0o755); err != nil {
314		t.Fatal(err)
315	}
316	// No frontmatter name: the directory name is the fallback.
317	if err := os.WriteFile(filepath.Join(bar, "SKILL.md"), []byte("---\ndescription: d\n---\nbody\n"), 0o644); err != nil {
318		t.Fatal(err)
319	}
320	symlinkOrSkip(t, bar, filepath.Join(bundle, "foo")) // sorts after "bar" only by luck; also test the reverse
321	symlinkOrSkip(t, bar, filepath.Join(bundle, "aaa"))
322	got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
323	if len(errs) != 0 {
324		t.Fatalf("errs = %v", errs)
325	}
326	if len(got) != 1 || got[0].Name != "bar" || FindByName(got, "foo") != nil || FindByName(got, "aaa") != nil {
327		t.Fatalf("alias duplicated the skill: %v", got)
328	}
329}

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 476

↑↓ moveenter openesc close