Follow symlinked directories during skill discovery
filepath.WalkDir does not descend into symlinked directories, so a skills directory whose entries link into a shared checkout discovered nothing at all. Discovery now follows directory links, walks each resolved directory once so a link cycle terminates, and skips a link that points at nothing. A skill reached through a link reports the link path, so the location shown by /skills is the installed one.
- commit
- 29f5fcbed507adfb9e8997e0ca0a2419455ed24f
- author
- Mike Smith · · 2026-09-27 17:48 UTC
- parent
- 41873f6
- signature
- unsigned
3 files · +184 -31
M docs/skills.md +4 -0
| @@ -74,6 +74,10 @@ Explicit `--ext` bundles take precedence over environment, project, global, and | |||
| 74 | 74 | compatibility sources. Duplicate names from different files keep the | |
| 75 | 75 | higher-precedence file and report a diagnostic. Rediscovering the same file | |
| 76 | 76 | under the same skill name (including through a symlink) is silently ignored. | |
| 77 | + | Skill directories may themselves be symlinks, so a checkout can be kept in one | |
| 78 | + | place and linked into a skills directory. Link cycles are ignored, a directory | |
| 79 | + | reached twice through links is scanned once, and a link that points at nothing | |
| 80 | + | is skipped. | |
| 77 | 81 | This includes overlapping project and global locations when running from your | |
| 78 | 82 | home directory. `--no-ext` omits implicit extension bundles; | |
| 79 | 83 | `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill` | |
| @@ -74,6 +74,10 @@ Explicit `--ext` bundles take precedence over environment, project, global, and | |||||
| 74 | compatibility sources. Duplicate names from different files keep the | 74 | compatibility sources. Duplicate names from different files keep the | ||
| 75 | higher-precedence file and report a diagnostic. Rediscovering the same file | 75 | higher-precedence file and report a diagnostic. Rediscovering the same file | ||
| 76 | under the same skill name (including through a symlink) is silently ignored. | 76 | under the same skill name (including through a symlink) is silently ignored. | ||
| 77 | + | Skill directories may themselves be symlinks, so a checkout can be kept in one | |||
| 78 | + | place and linked into a skills directory. Link cycles are ignored, a directory | |||
| 79 | + | reached twice through links is scanned once, and a link that points at nothing | |||
| 80 | + | is skipped. | |||
| 77 | This includes overlapping project and global locations when running from your | 81 | This includes overlapping project and global locations when running from your | ||
| 78 | home directory. `--no-ext` omits implicit extension bundles; | 82 | home directory. `--no-ext` omits implicit extension bundles; | ||
| 79 | `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill` | 83 | `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill` | ||
M packages/agent/skills/skills.go +83 -31
| @@ -21,10 +21,16 @@ | |||
| 21 | 21 | // | |
| 22 | 22 | // The compat paths are deliberate: a SKILL.md written for any of | |
| 23 | 23 | // the related ecosystems works in zot unchanged. | |
| 24 | + | // | |
| 25 | + | // Discovery walks each source recursively and follows symlinked | |
| 26 | + | // directories, so a skill can be a link into a shared checkout. A link | |
| 27 | + | // cycle is ignored, and each resolved directory is walked only once. | |
| 24 | 28 | package skills | |
| 25 | 29 | ||
| 26 | 30 | import ( | |
| 31 | + | "errors" | |
| 27 | 32 | "fmt" | |
| 33 | + | "io/fs" | |
| 28 | 34 | "os" | |
| 29 | 35 | "path/filepath" | |
| 30 | 36 | "sort" | |
| @@ -207,50 +213,96 @@ func scanSource(source Source) ([]*Skill, []error) { | |||
| 207 | 213 | if err != nil { | |
| 208 | 214 | return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)} | |
| 209 | 215 | } | |
| 210 | - | err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { | |
| 211 | - | if walkErr != nil { | |
| 212 | - | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) | |
| 213 | - | return nil | |
| 216 | + | // filepath.WalkDir does not descend into symlinked directories, which | |
| 217 | + | // would silently drop a skill directory that links into a shared | |
| 218 | + | // checkout. The walk below follows those links and records every | |
| 219 | + | // resolved directory so a link cycle cannot recurse forever. | |
| 220 | + | visited := map[string]bool{} | |
| 221 | + | var walk func(dir string) | |
| 222 | + | walk = func(dir string) { | |
| 223 | + | resolved, err := filepath.EvalSymlinks(dir) | |
| 224 | + | if err != nil { | |
| 225 | + | errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err)) | |
| 226 | + | return | |
| 214 | 227 | } | |
| 215 | - | if d.IsDir() || d.Name() != "SKILL.md" { | |
| 216 | - | return nil | |
| 228 | + | if visited[resolved] { | |
| 229 | + | return | |
| 217 | 230 | } | |
| 218 | - | s, e := load(path, source.Label) | |
| 219 | - | if e != nil { | |
| 220 | - | errs = append(errs, fmt.Errorf("%s: %w", path, e)) | |
| 221 | - | return nil | |
| 231 | + | visited[resolved] = true | |
| 232 | + | entries, err := os.ReadDir(dir) | |
| 233 | + | if err != nil { | |
| 234 | + | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | |
| 235 | + | return | |
| 222 | 236 | } | |
| 223 | - | if s.Name == "" { | |
| 224 | - | rel, e := filepath.Rel(root, filepath.Dir(path)) | |
| 225 | - | if e != nil { | |
| 226 | - | errs = append(errs, e) | |
| 227 | - | return nil | |
| 237 | + | for _, d := range entries { | |
| 238 | + | path := filepath.Join(dir, d.Name()) | |
| 239 | + | if d.IsDir() { | |
| 240 | + | walk(path) | |
| 241 | + | continue | |
| 228 | 242 | } | |
| 229 | - | parts := []string{} | |
| 230 | - | if rel != "." { | |
| 231 | - | for _, p := range strings.Split(filepath.ToSlash(rel), "/") { | |
| 232 | - | if k := kebabCase(p); k != "" { | |
| 233 | - | parts = append(parts, k) | |
| 243 | + | if d.Type()&fs.ModeSymlink != 0 { | |
| 244 | + | info, statErr := os.Stat(path) // resolves the link | |
| 245 | + | if statErr != nil { | |
| 246 | + | // A dangling link is not a skill. Anything else | |
| 247 | + | // (permissions, I/O) is worth reporting. | |
| 248 | + | if !errors.Is(statErr, fs.ErrNotExist) { | |
| 249 | + | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | |
| 234 | 250 | } | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | if info.IsDir() { | |
| 254 | + | walk(path) | |
| 255 | + | continue | |
| 235 | 256 | } | |
| 236 | 257 | } | |
| 237 | - | if len(parts) == 0 { | |
| 238 | - | parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path)))) | |
| 258 | + | if d.Name() != "SKILL.md" { | |
| 259 | + | continue | |
| 239 | 260 | } | |
| 240 | - | s.Name = strings.Join(parts, "-") | |
| 241 | - | } | |
| 242 | - | if source.Prefix != "" { | |
| 243 | - | s.Name = source.Prefix + s.Name | |
| 261 | + | s, e := load(path, source.Label) | |
| 262 | + | if e != nil { | |
| 263 | + | errs = append(errs, fmt.Errorf("%s: %w", path, e)) | |
| 264 | + | continue | |
| 265 | + | } | |
| 266 | + | if s.Name == "" { | |
| 267 | + | name, e := deriveName(root, path) | |
| 268 | + | if e != nil { | |
| 269 | + | errs = append(errs, e) | |
| 270 | + | continue | |
| 271 | + | } | |
| 272 | + | s.Name = name | |
| 273 | + | } | |
| 274 | + | if source.Prefix != "" { | |
| 275 | + | s.Name = source.Prefix + s.Name | |
| 276 | + | } | |
| 277 | + | out = append(out, s) | |
| 244 | 278 | } | |
| 245 | - | out = append(out, s) | |
| 246 | - | return nil | |
| 247 | - | }) | |
| 248 | - | if err != nil { | |
| 249 | - | errs = append(errs, fmt.Errorf("scan %s: %w", root, err)) | |
| 250 | 279 | } | |
| 280 | + | walk(root) | |
| 251 | 281 | return out, errs | |
| 252 | 282 | } | |
| 253 | 283 | ||
| 284 | + | // deriveName builds a skill name for a SKILL.md file that carries no | |
| 285 | + | // frontmatter `name`, from its directory path relative to the source root. | |
| 286 | + | func deriveName(root, path string) (string, error) { | |
| 287 | + | dir := filepath.Dir(path) | |
| 288 | + | rel, err := filepath.Rel(root, dir) | |
| 289 | + | if err != nil { | |
| 290 | + | return "", fmt.Errorf("%s: %w", path, err) | |
| 291 | + | } | |
| 292 | + | var parts []string | |
| 293 | + | if rel != "." { | |
| 294 | + | for _, p := range strings.Split(filepath.ToSlash(rel), "/") { | |
| 295 | + | if k := kebabCase(p); k != "" { | |
| 296 | + | parts = append(parts, k) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | if len(parts) == 0 { | |
| 301 | + | parts = append(parts, kebabCase(filepath.Base(dir))) | |
| 302 | + | } | |
| 303 | + | return strings.Join(parts, "-"), nil | |
| 304 | + | } | |
| 305 | + | ||
| 254 | 306 | // KebabCase converts a path component or display name to a stable skill name. | |
| 255 | 307 | func KebabCase(s string) string { return kebabCase(s) } | |
| 256 | 308 | ||
| @@ -21,10 +21,16 @@ | |||||
| 21 | // | 21 | // | ||
| 22 | // The compat paths are deliberate: a SKILL.md written for any of | 22 | // The compat paths are deliberate: a SKILL.md written for any of | ||
| 23 | // the related ecosystems works in zot unchanged. | 23 | // the related ecosystems works in zot unchanged. | ||
| 24 | + | // | |||
| 25 | + | // Discovery walks each source recursively and follows symlinked | |||
| 26 | + | // directories, so a skill can be a link into a shared checkout. A link | |||
| 27 | + | // cycle is ignored, and each resolved directory is walked only once. | |||
| 24 | package skills | 28 | package skills | ||
| 25 | 29 | ||||
| 26 | import ( | 30 | import ( | ||
| 31 | + | "errors" | |||
| 27 | "fmt" | 32 | "fmt" | ||
| 33 | + | "io/fs" | |||
| 28 | "os" | 34 | "os" | ||
| 29 | "path/filepath" | 35 | "path/filepath" | ||
| 30 | "sort" | 36 | "sort" | ||
| @@ -207,50 +213,96 @@ func scanSource(source Source) ([]*Skill, []error) { | |||||
| 207 | if err != nil { | 213 | if err != nil { | ||
| 208 | return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)} | 214 | return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)} | ||
| 209 | } | 215 | } | ||
| 210 | - | err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { | 216 | + | // filepath.WalkDir does not descend into symlinked directories, which |
| 211 | - | if walkErr != nil { | 217 | + | // would silently drop a skill directory that links into a shared |
| 212 | - | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) | 218 | + | // checkout. The walk below follows those links and records every |
| 213 | - | return nil | 219 | + | // resolved directory so a link cycle cannot recurse forever. |
| 220 | + | visited := map[string]bool{} | |||
| 221 | + | var walk func(dir string) | |||
| 222 | + | walk = func(dir string) { | |||
| 223 | + | resolved, err := filepath.EvalSymlinks(dir) | |||
| 224 | + | if err != nil { | |||
| 225 | + | errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err)) | |||
| 226 | + | return | |||
| 214 | } | 227 | } | ||
| 215 | - | if d.IsDir() || d.Name() != "SKILL.md" { | 228 | + | if visited[resolved] { |
| 216 | - | return nil | 229 | + | return |
| 217 | } | 230 | } | ||
| 218 | - | s, e := load(path, source.Label) | 231 | + | visited[resolved] = true |
| 219 | - | if e != nil { | 232 | + | entries, err := os.ReadDir(dir) |
| 220 | - | errs = append(errs, fmt.Errorf("%s: %w", path, e)) | 233 | + | if err != nil { |
| 221 | - | return nil | 234 | + | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) |
| 235 | + | return | |||
| 222 | } | 236 | } | ||
| 223 | - | if s.Name == "" { | 237 | + | for _, d := range entries { |
| 224 | - | rel, e := filepath.Rel(root, filepath.Dir(path)) | 238 | + | path := filepath.Join(dir, d.Name()) |
| 225 | - | if e != nil { | 239 | + | if d.IsDir() { |
| 226 | - | errs = append(errs, e) | 240 | + | walk(path) |
| 227 | - | return nil | 241 | + | continue |
| 228 | } | 242 | } | ||
| 229 | - | parts := []string{} | 243 | + | if d.Type()&fs.ModeSymlink != 0 { |
| 230 | - | if rel != "." { | 244 | + | info, statErr := os.Stat(path) // resolves the link |
| 231 | - | for _, p := range strings.Split(filepath.ToSlash(rel), "/") { | 245 | + | if statErr != nil { |
| 232 | - | if k := kebabCase(p); k != "" { | 246 | + | // A dangling link is not a skill. Anything else |
| 233 | - | parts = append(parts, k) | 247 | + | // (permissions, I/O) is worth reporting. |
| 248 | + | if !errors.Is(statErr, fs.ErrNotExist) { | |||
| 249 | + | errs = append(errs, fmt.Errorf("%s: %w", path, statErr)) | |||
| 234 | } | 250 | } | ||
| 251 | + | continue | |||
| 252 | + | } | |||
| 253 | + | if info.IsDir() { | |||
| 254 | + | walk(path) | |||
| 255 | + | continue | |||
| 235 | } | 256 | } | ||
| 236 | } | 257 | } | ||
| 237 | - | if len(parts) == 0 { | 258 | + | if d.Name() != "SKILL.md" { |
| 238 | - | parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path)))) | 259 | + | continue |
| 239 | } | 260 | } | ||
| 240 | - | s.Name = strings.Join(parts, "-") | 261 | + | s, e := load(path, source.Label) |
| 241 | - | } | 262 | + | if e != nil { |
| 242 | - | if source.Prefix != "" { | 263 | + | errs = append(errs, fmt.Errorf("%s: %w", path, e)) |
| 243 | - | s.Name = source.Prefix + s.Name | 264 | + | continue |
| 265 | + | } | |||
| 266 | + | if s.Name == "" { | |||
| 267 | + | name, e := deriveName(root, path) | |||
| 268 | + | if e != nil { | |||
| 269 | + | errs = append(errs, e) | |||
| 270 | + | continue | |||
| 271 | + | } | |||
| 272 | + | s.Name = name | |||
| 273 | + | } | |||
| 274 | + | if source.Prefix != "" { | |||
| 275 | + | s.Name = source.Prefix + s.Name | |||
| 276 | + | } | |||
| 277 | + | out = append(out, s) | |||
| 244 | } | 278 | } | ||
| 245 | - | out = append(out, s) | |||
| 246 | - | return nil | |||
| 247 | - | }) | |||
| 248 | - | if err != nil { | |||
| 249 | - | errs = append(errs, fmt.Errorf("scan %s: %w", root, err)) | |||
| 250 | } | 279 | } | ||
| 280 | + | walk(root) | |||
| 251 | return out, errs | 281 | return out, errs | ||
| 252 | } | 282 | } | ||
| 253 | 283 | ||||
| 284 | + | // deriveName builds a skill name for a SKILL.md file that carries no | |||
| 285 | + | // frontmatter `name`, from its directory path relative to the source root. | |||
| 286 | + | func deriveName(root, path string) (string, error) { | |||
| 287 | + | dir := filepath.Dir(path) | |||
| 288 | + | rel, err := filepath.Rel(root, dir) | |||
| 289 | + | if err != nil { | |||
| 290 | + | return "", fmt.Errorf("%s: %w", path, err) | |||
| 291 | + | } | |||
| 292 | + | var parts []string | |||
| 293 | + | if rel != "." { | |||
| 294 | + | for _, p := range strings.Split(filepath.ToSlash(rel), "/") { | |||
| 295 | + | if k := kebabCase(p); k != "" { | |||
| 296 | + | parts = append(parts, k) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | if len(parts) == 0 { | |||
| 301 | + | parts = append(parts, kebabCase(filepath.Base(dir))) | |||
| 302 | + | } | |||
| 303 | + | return strings.Join(parts, "-"), nil | |||
| 304 | + | } | |||
| 305 | + | ||||
| 254 | // KebabCase converts a path component or display name to a stable skill name. | 306 | // KebabCase converts a path component or display name to a stable skill name. | ||
| 255 | func KebabCase(s string) string { return kebabCase(s) } | 307 | func KebabCase(s string) string { return kebabCase(s) } | ||
| 256 | 308 | ||||
M packages/agent/skills/skills_test.go +97 -0
| @@ -147,6 +147,103 @@ func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) { | |||
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) { | |
| 151 | + | shared := t.TempDir() | |
| 152 | + | writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body") | |
| 153 | + | root := t.TempDir() | |
| 154 | + | linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd")) | |
| 155 | + | ||
| 156 | + | got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false) | |
| 157 | + | if len(errs) != 0 || len(got) != 1 { | |
| 158 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |
| 159 | + | } | |
| 160 | + | if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" { | |
| 161 | + | t.Errorf("skill = %#v", got[0]) | |
| 162 | + | } | |
| 163 | + | if want := filepath.Join(root, "tdd", "SKILL.md"); got[0].Path != want { | |
| 164 | + | t.Errorf("path = %q, want the link location %q", got[0].Path, want) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) { | |
| 169 | + | shared := t.TempDir() | |
| 170 | + | writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body") | |
| 171 | + | writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body") | |
| 172 | + | root := t.TempDir() | |
| 173 | + | linkDir(t, shared, filepath.Join(root, "engineering")) | |
| 174 | + | ||
| 175 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |
| 176 | + | if len(errs) != 0 || len(got) != 2 { | |
| 177 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |
| 178 | + | } | |
| 179 | + | if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" { | |
| 180 | + | t.Errorf("names = %q, %q", got[0].Name, got[1].Name) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) { | |
| 185 | + | root := t.TempDir() | |
| 186 | + | writeSkill(t, filepath.Join(root, "review"), "name: review", "body") | |
| 187 | + | linkDir(t, root, filepath.Join(root, "loop")) | |
| 188 | + | linkDir(t, root, filepath.Join(root, "alias")) | |
| 189 | + | ||
| 190 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |
| 191 | + | if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" { | |
| 192 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) { | |
| 197 | + | root := t.TempDir() | |
| 198 | + | writeSkill(t, filepath.Join(root, "review"), "name: review", "body") | |
| 199 | + | missing := filepath.Join(t.TempDir(), "gone") | |
| 200 | + | linkDir(t, missing, filepath.Join(root, "gone")) | |
| 201 | + | ||
| 202 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |
| 203 | + | if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" { | |
| 204 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) { | |
| 209 | + | root := t.TempDir() | |
| 210 | + | real := filepath.Join(root, "shared") | |
| 211 | + | writeSkill(t, real, "name: review", "body") | |
| 212 | + | linkDir(t, real, filepath.Join(root, "alias")) | |
| 213 | + | ||
| 214 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |
| 215 | + | if len(errs) != 0 || len(got) != 1 { | |
| 216 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |
| 217 | + | } | |
| 218 | + | if want := filepath.Join(root, "alias", "SKILL.md"); got[0].Path != want { | |
| 219 | + | t.Errorf("path = %q, want %q", got[0].Path, want) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func writeSkill(t *testing.T, dir, front, body string) { | |
| 224 | + | t.Helper() | |
| 225 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 226 | + | t.Fatal(err) | |
| 227 | + | } | |
| 228 | + | frontmatter := "---\ndescription: test\n---\n" | |
| 229 | + | if front != "" { | |
| 230 | + | frontmatter = "---\n" + front + "\ndescription: test\n---\n" | |
| 231 | + | } | |
| 232 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func linkDir(t *testing.T, target, path string) { | |
| 238 | + | t.Helper() | |
| 239 | + | if err := os.Symlink(target, path); err != nil { | |
| 240 | + | if runtime.GOOS == "windows" { | |
| 241 | + | t.Skipf("symlinks unavailable: %v", err) | |
| 242 | + | } | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 150 | 247 | func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) { | |
| 151 | 248 | root := t.TempDir() | |
| 152 | 249 | path := filepath.Join(root, "Something", "Writing Git Commits") | |
| @@ -147,6 +147,103 @@ func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) { | |||||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | + | func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) { | |||
| 151 | + | shared := t.TempDir() | |||
| 152 | + | writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body") | |||
| 153 | + | root := t.TempDir() | |||
| 154 | + | linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd")) | |||
| 155 | + | ||||
| 156 | + | got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false) | |||
| 157 | + | if len(errs) != 0 || len(got) != 1 { | |||
| 158 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |||
| 159 | + | } | |||
| 160 | + | if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" { | |||
| 161 | + | t.Errorf("skill = %#v", got[0]) | |||
| 162 | + | } | |||
| 163 | + | if want := filepath.Join(root, "tdd", "SKILL.md"); got[0].Path != want { | |||
| 164 | + | t.Errorf("path = %q, want the link location %q", got[0].Path, want) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) { | |||
| 169 | + | shared := t.TempDir() | |||
| 170 | + | writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body") | |||
| 171 | + | writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body") | |||
| 172 | + | root := t.TempDir() | |||
| 173 | + | linkDir(t, shared, filepath.Join(root, "engineering")) | |||
| 174 | + | ||||
| 175 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |||
| 176 | + | if len(errs) != 0 || len(got) != 2 { | |||
| 177 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |||
| 178 | + | } | |||
| 179 | + | if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" { | |||
| 180 | + | t.Errorf("names = %q, %q", got[0].Name, got[1].Name) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) { | |||
| 185 | + | root := t.TempDir() | |||
| 186 | + | writeSkill(t, filepath.Join(root, "review"), "name: review", "body") | |||
| 187 | + | linkDir(t, root, filepath.Join(root, "loop")) | |||
| 188 | + | linkDir(t, root, filepath.Join(root, "alias")) | |||
| 189 | + | ||||
| 190 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |||
| 191 | + | if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" { | |||
| 192 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) { | |||
| 197 | + | root := t.TempDir() | |||
| 198 | + | writeSkill(t, filepath.Join(root, "review"), "name: review", "body") | |||
| 199 | + | missing := filepath.Join(t.TempDir(), "gone") | |||
| 200 | + | linkDir(t, missing, filepath.Join(root, "gone")) | |||
| 201 | + | ||||
| 202 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |||
| 203 | + | if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" { | |||
| 204 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) { | |||
| 209 | + | root := t.TempDir() | |||
| 210 | + | real := filepath.Join(root, "shared") | |||
| 211 | + | writeSkill(t, real, "name: review", "body") | |||
| 212 | + | linkDir(t, real, filepath.Join(root, "alias")) | |||
| 213 | + | ||||
| 214 | + | got, errs := DiscoverSources([]Source{{Root: root}}, false) | |||
| 215 | + | if len(errs) != 0 || len(got) != 1 { | |||
| 216 | + | t.Fatalf("skills=%#v errs=%v", got, errs) | |||
| 217 | + | } | |||
| 218 | + | if want := filepath.Join(root, "alias", "SKILL.md"); got[0].Path != want { | |||
| 219 | + | t.Errorf("path = %q, want %q", got[0].Path, want) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func writeSkill(t *testing.T, dir, front, body string) { | |||
| 224 | + | t.Helper() | |||
| 225 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 226 | + | t.Fatal(err) | |||
| 227 | + | } | |||
| 228 | + | frontmatter := "---\ndescription: test\n---\n" | |||
| 229 | + | if front != "" { | |||
| 230 | + | frontmatter = "---\n" + front + "\ndescription: test\n---\n" | |||
| 231 | + | } | |||
| 232 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func linkDir(t *testing.T, target, path string) { | |||
| 238 | + | t.Helper() | |||
| 239 | + | if err := os.Symlink(target, path); err != nil { | |||
| 240 | + | if runtime.GOOS == "windows" { | |||
| 241 | + | t.Skipf("symlinks unavailable: %v", err) | |||
| 242 | + | } | |||
| 243 | + | t.Fatal(err) | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 150 | func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) { | 247 | func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) { | ||
| 151 | root := t.TempDir() | 248 | root := t.TempDir() | ||
| 152 | path := filepath.Join(root, "Something", "Writing Git Commits") | 249 | path := filepath.Join(root, "Something", "Writing Git Commits") | ||