返回 DeepSeek-Reasonix
skill_extra_test.go
根目录 / internal / skill / skill_extra_test.go
1 package skill
2
3 import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/config"
11 )
12
13 // IsValidName
14
15 func TestPluginSkillRootIgnoresDocumentationMarkdown(t *testing.T) {
16 home := t.TempDir()
17 pluginRoot := filepath.Join(t.TempDir(), "skills")
18 writeSkill(t, pluginRoot, "plan/SKILL.md", "---\ndescription: plan work\n---\nbody")
19 writeSkill(t, pluginRoot, "guide.md", "# Plugin documentation, not a skill.")
20 writeSkill(t, pluginRoot, "notes.md", "---\ntitle: Notes\n---\n# Notes")
21
22 var stderr bytes.Buffer
23 st := New(Options{
24 HomeDir: home,
25 CustomPaths: []string{pluginRoot},
26 PluginPaths: map[string][]string{config.CanonicalSkillPath(pluginRoot): {"superpowers"}},
27 DisableBuiltins: true,
28 Stderr: &stderr,
29 })
30 list := st.List()
31 if _, ok := find(list, "plan"); !ok {
32 t.Fatal("real plugin directory skill should be discovered")
33 }
34 for _, name := range []string{"guide", "notes"} {
35 if _, ok := find(list, name); ok {
36 t.Errorf("plugin documentation markdown %q should not be registered as a skill", name)
37 }
38 }
39 slash := st.SlashList()
40 if len(slash) != 1 || slash[0].SlashName() != "superpowers:plan" {
41 t.Fatalf("slash skills = %+v, want only superpowers:plan", slash)
42 }
43 if got := stderr.String(); got != "" {
44 t.Fatalf("documentation markdown should not warn during List, got %q", got)
45 }
46 }
47
48 func TestIsValidName(t *testing.T) {
49 cases := []struct {
50 name string
51 want bool
52 }{
53 {"valid-name", true},
54 {"CamelCase", true},
55 {"with.dot", true},
56 {"with_underscore", true},
57 {"a", true},
58 {"A123", true},
59 {"", false},
60 {"-starts-dash", false},
61 {"has space", false},
62 {"has/slash", false},
63 {strings.Repeat("a", 65), false}, // too long
64 {strings.Repeat("a", 64), true}, // max length
65 }
66 for _, c := range cases {
67 if got := IsValidName(c.name); got != c.want {
68 t.Errorf("IsValidName(%q) = %v, want %v", c.name, got, c.want)
69 }
70 }
71 }
72
73 // splitFrontmatter
74
75 func TestSplitFrontmatterNoFence(t *testing.T) {
76 fm, body := splitFrontmatter("just body")
77 if len(fm) != 0 {
78 t.Errorf("expected empty fm, got %v", fm)
79 }
80 if body != "just body" {
81 t.Errorf("body = %q", body)
82 }
83 }
84
85 func TestSplitFrontmatterUnclosed(t *testing.T) {
86 fm, body := splitFrontmatter("---\nkey: val\n\nno closing")
87 if len(fm) != 0 {
88 t.Errorf("unclosed fence should return empty fm, got %v", fm)
89 }
90 if !strings.Contains(body, "---") {
91 t.Errorf("body should contain original: %q", body)
92 }
93 }
94
95 func TestSplitFrontmatterEmpty(t *testing.T) {
96 fm, body := splitFrontmatter("")
97 if len(fm) != 0 {
98 t.Errorf("empty input should return empty fm, got %v", fm)
99 }
100 if body != "" {
101 t.Errorf("body = %q", body)
102 }
103 }
104
105 func TestSplitFrontmatterQuotedValues(t *testing.T) {
106 fm, _ := splitFrontmatter("---\ndescription: \"quoted\"\n---\n")
107 if fm["description"] != "quoted" {
108 t.Errorf("description = %q", fm["description"])
109 }
110 }
111
112 // parseAllowedTools
113
114 func TestParseAllowedToolsEmpty(t *testing.T) {
115 if got := parseAllowedTools(""); got != nil {
116 t.Errorf("empty = %v, want nil", got)
117 }
118 if got := parseAllowedTools(" "); got != nil {
119 t.Errorf("whitespace = %v, want nil", got)
120 }
121 }
122
123 func TestParseAllowedToolsSingle(t *testing.T) {
124 got := parseAllowedTools("bash")
125 if len(got) != 1 || got[0] != "bash" {
126 t.Errorf("single = %v", got)
127 }
128 }
129
130 func TestParseAllowedToolsMultiple(t *testing.T) {
131 got := parseAllowedTools("read_file, grep, bash")
132 if len(got) != 3 {
133 t.Errorf("count = %d, want 3", len(got))
134 }
135 if got[0] != "read_file" || got[1] != "grep" || got[2] != "bash" {
136 t.Errorf("tools = %v", got)
137 }
138 }
139
140 func TestParseAllowedToolsTrailingComma(t *testing.T) {
141 got := parseAllowedTools("bash,")
142 if len(got) != 1 || got[0] != "bash" {
143 t.Errorf("trailing comma = %v", got)
144 }
145 }
146
147 func TestParseAllowedToolsExtraSpaces(t *testing.T) {
148 got := parseAllowedTools(" bash , grep ")
149 if len(got) != 2 || got[0] != "bash" || got[1] != "grep" {
150 t.Errorf("extra spaces = %v", got)
151 }
152 }
153
154 // parseRunAs
155
156 func TestParseRunAsExplicit(t *testing.T) {
157 if parseRunAs("subagent", "", "") != RunSubagent {
158 t.Error("explicit subagent should return RunSubagent")
159 }
160 if parseRunAs("inline", "", "") != RunInline {
161 t.Error("explicit inline should return RunInline")
162 }
163 }
164
165 func TestParseRunAsContextFork(t *testing.T) {
166 if parseRunAs("", "fork", "") != RunSubagent {
167 t.Error("context: fork should return RunSubagent")
168 }
169 if parseRunAs("", "FORK", "") != RunSubagent {
170 t.Error("context: FORK should return RunSubagent")
171 }
172 }
173
174 func TestParseRunAsAgent(t *testing.T) {
175 if parseRunAs("", "", "some-agent") != RunSubagent {
176 t.Error("non-empty agent should return RunSubagent")
177 }
178 }
179
180 func TestParseRunAsDefault(t *testing.T) {
181 if parseRunAs("", "", "") != RunInline {
182 t.Error("all empty should default to RunInline")
183 }
184 if parseRunAs("unknown", "", "") != RunInline {
185 t.Error("unknown runAs should default to RunInline")
186 }
187 }
188
189 // resolveCustomPaths
190
191 func TestResolveCustomPathsTilde(t *testing.T) {
192 home := t.TempDir()
193 got := resolveCustomPaths([]string{"~/skills"}, "/base", home)
194 if len(got) != 1 || got[0] != filepath.Join(home, "skills") {
195 t.Errorf("tilde expansion = %v", got)
196 }
197 }
198
199 func TestResolveCustomPathsRelative(t *testing.T) {
200 base := t.TempDir()
201 got := resolveCustomPaths([]string{"./my-skills"}, base, "/home")
202 if len(got) != 1 || got[0] != filepath.Join(base, "my-skills") {
203 t.Errorf("relative = %v", got)
204 }
205 }
206
207 func TestResolveCustomPathsAbsolute(t *testing.T) {
208 abs := filepath.Join(t.TempDir(), "absolute", "path")
209 got := resolveCustomPaths([]string{abs}, "/base", "/home")
210 if len(got) != 1 || got[0] != abs {
211 t.Errorf("absolute = %v", got)
212 }
213 }
214
215 func TestResolveCustomPathsEmpty(t *testing.T) {
216 got := resolveCustomPaths([]string{"", " "}, "/base", "/home")
217 if len(got) != 0 {
218 t.Errorf("empty paths should be filtered, got %v", got)
219 }
220 }
221
222 // dedupePaths
223
224 func TestDedupePaths(t *testing.T) {
225 got := dedupePaths([]string{"/a", "/b", "/a", "/c", "/b"})
226 if len(got) != 3 || got[0] != "/a" || got[1] != "/b" || got[2] != "/c" {
227 t.Errorf("deduped = %v", got)
228 }
229 }
230
231 func TestDedupePathsEmpty(t *testing.T) {
232 got := dedupePaths(nil)
233 if len(got) != 0 {
234 t.Errorf("nil = %v", got)
235 }
236 }
237
238 // stubBody
239
240 func TestStubBody(t *testing.T) {
241 body := stubBody("my-skill")
242 if !strings.Contains(body, "name: my-skill") {
243 t.Error("stub should contain the skill name")
244 }
245 if !strings.Contains(body, "description:") {
246 t.Error("stub should contain description field")
247 }
248 if !strings.Contains(body, "# my-skill") {
249 t.Error("stub should contain the skill name as heading")
250 }
251 }
252
253 // Read edge cases
254
255 func TestReadInvalidName(t *testing.T) {
256 home := t.TempDir()
257 st := New(Options{HomeDir: home, DisableBuiltins: true})
258 _, ok := st.Read("invalid name!")
259 if ok {
260 t.Error("invalid name should return ok=false")
261 }
262 }
263
264 func TestReadNotFound(t *testing.T) {
265 home := t.TempDir()
266 st := New(Options{HomeDir: home, DisableBuiltins: true})
267 _, ok := st.Read("nonexistent")
268 if ok {
269 t.Error("nonexistent skill should return ok=false")
270 }
271 }
272
273 // Create edge cases
274
275 func TestCreateInvalidName(t *testing.T) {
276 home := t.TempDir()
277 st := New(Options{HomeDir: home, DisableBuiltins: true})
278 _, err := st.Create("invalid name!", ScopeGlobal)
279 if err == nil {
280 t.Error("invalid name should error")
281 }
282 }
283
284 func TestCreateProjectScopeRequiresRoot(t *testing.T) {
285 home := t.TempDir()
286 st := New(Options{HomeDir: home, DisableBuiltins: true})
287 _, err := st.Create("test", ScopeProject)
288 if err == nil {
289 t.Error("project scope without root should error")
290 }
291 }
292
293 func TestCreateDirectoryLayoutSkill(t *testing.T) {
294 home := t.TempDir()
295 skillsRoot := filepath.Join(home, ".reasonix", "skills", "existing", "SKILL.md")
296 os.MkdirAll(filepath.Dir(skillsRoot), 0o755)
297 os.WriteFile(skillsRoot, []byte("---\ndescription: exists\n---\nbody"), 0o644)
298 st := New(Options{HomeDir: home, DisableBuiltins: true})
299 _, err := st.Create("existing", ScopeGlobal)
300 if err == nil {
301 t.Error("should refuse to overwrite directory-layout skill")
302 }
303 }
304
305 func TestUpdateContentOverwritesExistingSkill(t *testing.T) {
306 home := t.TempDir()
307 st := New(Options{HomeDir: home, DisableBuiltins: true})
308 if _, err := st.CreateWithContent("editable", ScopeGlobal, "---\ndescription: v1\n---\nold body"); err != nil {
309 t.Fatalf("CreateWithContent: %v", err)
310 }
311 if err := st.UpdateContent("editable", ScopeGlobal, "---\ndescription: v2\n---\nnew body"); err != nil {
312 t.Fatalf("UpdateContent: %v", err)
313 }
314 sk, ok := st.Read("editable")
315 if !ok {
316 t.Fatal("skill missing after update")
317 }
318 if sk.Description != "v2" || sk.Body != "new body" {
319 t.Fatalf("update did not apply: description=%q body=%q", sk.Description, sk.Body)
320 }
321 }
322
323 func TestUpdateContentRefusesBuiltin(t *testing.T) {
324 st := New(Options{HomeDir: t.TempDir()})
325 if err := st.UpdateContent("explore", ScopeBuiltin, "---\ndescription: x\n---\nbody"); err == nil {
326 t.Error("updating a builtin should error")
327 }
328 }
329
330 func TestUpdateContentRefusesMissingSkill(t *testing.T) {
331 st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
332 if err := st.UpdateContent("does-not-exist", ScopeGlobal, "---\ndescription: x\n---\nbody"); err == nil {
333 t.Error("updating a nonexistent skill should error")
334 }
335 }
336
337 func TestUpdateContentRefusesScopeMismatch(t *testing.T) {
338 home := t.TempDir()
339 st := New(Options{HomeDir: home, DisableBuiltins: true})
340 if _, err := st.CreateWithContent("scoped2", ScopeGlobal, "---\ndescription: v1\n---\nbody"); err != nil {
341 t.Fatalf("CreateWithContent: %v", err)
342 }
343 if err := st.UpdateContent("scoped2", ScopeProject, "---\ndescription: v2\n---\nbody"); err == nil {
344 t.Error("updating with the wrong scope should error")
345 }
346 sk, ok := st.Read("scoped2")
347 if !ok || sk.Description != "v1" {
348 t.Fatalf("skill should be unchanged after a refused scope-mismatched update, got description=%q ok=%v", sk.Description, ok)
349 }
350 }
351
352 func TestUpdateContentRefusesSymlinkedFlatSkill(t *testing.T) {
353 home := t.TempDir()
354 outside := filepath.Join(t.TempDir(), "outside.md")
355 original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
356 if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
357 t.Fatal(err)
358 }
359 root := filepath.Join(home, ".reasonix", SkillsDirname)
360 if err := os.MkdirAll(root, 0o755); err != nil {
361 t.Fatal(err)
362 }
363 if err := os.Symlink(outside, filepath.Join(root, "linked.md")); err != nil {
364 t.Skipf("symlinks unavailable: %v", err)
365 }
366 st := New(Options{HomeDir: home, DisableBuiltins: true})
367 if _, ok := st.Read("linked"); !ok {
368 t.Fatal("symlinked flat skill should remain readable")
369 }
370 if err := st.UpdateContent("linked", ScopeGlobal, "changed"); err == nil {
371 t.Fatal("updating a symlinked flat skill should fail")
372 }
373 got, err := os.ReadFile(outside)
374 if err != nil || string(got) != original {
375 t.Fatalf("outside target changed: content=%q err=%v", got, err)
376 }
377 }
378
379 func TestUpdateContentRefusesSymlinkedDirectorySkill(t *testing.T) {
380 home := t.TempDir()
381 outsideDir := t.TempDir()
382 outside := filepath.Join(outsideDir, SkillFile)
383 original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
384 if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
385 t.Fatal(err)
386 }
387 root := filepath.Join(home, ".reasonix", SkillsDirname)
388 if err := os.MkdirAll(root, 0o755); err != nil {
389 t.Fatal(err)
390 }
391 if err := os.Symlink(outsideDir, filepath.Join(root, "linked-dir")); err != nil {
392 t.Skipf("symlinks unavailable: %v", err)
393 }
394 st := New(Options{HomeDir: home, DisableBuiltins: true})
395 if _, ok := st.Read("linked-dir"); !ok {
396 t.Fatal("symlinked directory skill should remain readable")
397 }
398 if err := st.UpdateContent("linked-dir", ScopeGlobal, "changed"); err == nil {
399 t.Fatal("updating a symlinked directory skill should fail")
400 }
401 got, err := os.ReadFile(outside)
402 if err != nil || string(got) != original {
403 t.Fatalf("outside target changed: content=%q err=%v", got, err)
404 }
405 }
406
407 func TestDeleteSymlinkedSkillsRemovesLinksNotTargets(t *testing.T) {
408 home := t.TempDir()
409 root := filepath.Join(home, ".reasonix", SkillsDirname)
410 if err := os.MkdirAll(root, 0o755); err != nil {
411 t.Fatal(err)
412 }
413 outsideDir := t.TempDir()
414 flatTarget := filepath.Join(outsideDir, "flat-target.md")
415 dirTarget := filepath.Join(outsideDir, "directory-target")
416 if err := os.MkdirAll(dirTarget, 0o755); err != nil {
417 t.Fatal(err)
418 }
419 content := []byte("---\ndescription: linked\nrunAs: subagent\ninvocation: manual\n---\nbody")
420 if err := os.WriteFile(flatTarget, content, 0o644); err != nil {
421 t.Fatal(err)
422 }
423 if err := os.WriteFile(filepath.Join(dirTarget, SkillFile), content, 0o644); err != nil {
424 t.Fatal(err)
425 }
426 flatLink := filepath.Join(root, "flat-link.md")
427 dirLink := filepath.Join(root, "dir-link")
428 if err := os.Symlink(flatTarget, flatLink); err != nil {
429 t.Skipf("symlinks unavailable: %v", err)
430 }
431 if err := os.Symlink(dirTarget, dirLink); err != nil {
432 t.Skipf("symlinks unavailable: %v", err)
433 }
434 st := New(Options{HomeDir: home, DisableBuiltins: true})
435 for _, name := range []string{"flat-link", "dir-link"} {
436 if err := st.Delete(name, ScopeGlobal); err != nil {
437 t.Fatalf("Delete(%q): %v", name, err)
438 }
439 }
440 if _, err := os.Lstat(flatLink); !os.IsNotExist(err) {
441 t.Fatalf("flat link still exists: %v", err)
442 }
443 if _, err := os.Lstat(dirLink); !os.IsNotExist(err) {
444 t.Fatalf("directory link still exists: %v", err)
445 }
446 if got, err := os.ReadFile(flatTarget); err != nil || string(got) != string(content) {
447 t.Fatalf("flat target changed: content=%q err=%v", got, err)
448 }
449 if got, err := os.ReadFile(filepath.Join(dirTarget, SkillFile)); err != nil || string(got) != string(content) {
450 t.Fatalf("directory target changed: content=%q err=%v", got, err)
451 }
452 }
453
454 func TestDeleteRemovesDirectoryLayoutSkill(t *testing.T) {
455 home := t.TempDir()
456 st := New(Options{HomeDir: home, DisableBuiltins: true})
457 path, err := st.CreateWithContent("throwaway", ScopeGlobal, "---\ndescription: x\n---\nbody")
458 if err != nil {
459 t.Fatalf("CreateWithContent: %v", err)
460 }
461 if err := st.Delete("throwaway", ScopeGlobal); err != nil {
462 t.Fatalf("Delete: %v", err)
463 }
464 if _, ok := st.Read("throwaway"); ok {
465 t.Fatal("skill should be gone after Delete")
466 }
467 if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
468 t.Fatalf("skill directory should be removed, stat err=%v", err)
469 }
470 }
471
472 func TestDeleteRefusesBuiltin(t *testing.T) {
473 st := New(Options{HomeDir: t.TempDir()})
474 if err := st.Delete("explore", ScopeBuiltin); err == nil {
475 t.Error("deleting a builtin should error")
476 }
477 }
478
479 func TestDeleteRefusesMissingSkill(t *testing.T) {
480 st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
481 if err := st.Delete("does-not-exist", ScopeGlobal); err == nil {
482 t.Error("deleting a nonexistent skill should error")
483 }
484 }
485
486 func TestDeleteRefusesScopeMismatch(t *testing.T) {
487 home := t.TempDir()
488 st := New(Options{HomeDir: home, DisableBuiltins: true})
489 if _, err := st.CreateWithContent("scoped", ScopeGlobal, "---\ndescription: x\n---\nbody"); err != nil {
490 t.Fatalf("CreateWithContent: %v", err)
491 }
492 // The skill actually lives at ScopeGlobal; a ScopeProject delete request
493 // for the same name must refuse rather than silently no-op or, worse,
494 // resolve to an unrelated file.
495 if err := st.Delete("scoped", ScopeProject); err == nil {
496 t.Error("deleting with the wrong scope should error")
497 }
498 if _, ok := st.Read("scoped"); !ok {
499 t.Fatal("skill should survive a refused scope-mismatched delete")
500 }
501 }
502
503 // New edge cases
504
505 func TestNewWithCustomPaths(t *testing.T) {
506 custom := t.TempDir()
507 st := New(Options{HomeDir: t.TempDir(), CustomPaths: []string{custom}, DisableBuiltins: true})
508 roots := st.Roots()
509 found := false
510 for _, r := range roots {
511 if r.Dir == custom && r.Scope == ScopeCustom {
512 found = true
513 break
514 }
515 }
516 if !found {
517 t.Error("custom path not in roots")
518 }
519 }
520
521 func TestHasProjectScope(t *testing.T) {
522 st1 := New(Options{HomeDir: t.TempDir(), ProjectRoot: "/some/project"})
523 if !st1.HasProjectScope() {
524 t.Error("with project root should return true")
525 }
526 st2 := New(Options{HomeDir: t.TempDir()})
527 if st2.HasProjectScope() {
528 t.Error("without project root should return false")
529 }
530 }
531
531 lines GO