返回 DeepSeek-Reasonix
skill_test.go
根目录 / internal / skill / skill_test.go
1 package skill
2
3 import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "runtime"
8 "slices"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 fileencoding "reasonix/internal/fileutil/encoding"
15 )
16
17 func writeSkill(t *testing.T, base, rel, content string) string {
18 t.Helper()
19 full := filepath.Join(base, rel)
20 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
21 t.Fatal(err)
22 }
23 if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
24 t.Fatal(err)
25 }
26 return full
27 }
28
29 func writeSkillBytes(t *testing.T, base, rel string, content []byte) string {
30 t.Helper()
31 full := filepath.Join(base, rel)
32 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
33 t.Fatal(err)
34 }
35 if err := os.WriteFile(full, content, 0o644); err != nil {
36 t.Fatal(err)
37 }
38 return full
39 }
40
41 // writeScript creates a file at base/rel with the given content.
42 func writeScript(t *testing.T, base, rel, content string) string {
43 t.Helper()
44 full := filepath.Join(base, rel)
45 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
46 t.Fatal(err)
47 }
48 if err := os.WriteFile(full, []byte(content), 0o755); err != nil {
49 t.Fatal(err)
50 }
51 return full
52 }
53
54 func find(skills []Skill, name string) (Skill, bool) {
55 for _, s := range skills {
56 if s.Name == name {
57 return s, true
58 }
59 }
60 return Skill{}, false
61 }
62
63 func TestDisableDiscoveryReturnsEmptyStore(t *testing.T) {
64 home := t.TempDir()
65 project := t.TempDir()
66 custom := t.TempDir()
67 writeSkill(t, home, ".reasonix/skills/global.md", "---\ndescription: global\n---\nbody")
68 writeSkill(t, project, ".reasonix/skills/project.md", "---\ndescription: project\n---\nbody")
69 writeSkill(t, custom, "custom.md", "---\ndescription: custom\n---\nbody")
70
71 store := New(Options{
72 HomeDir: home,
73 ProjectRoot: project,
74 CustomPaths: []string{custom},
75 DisableDiscovery: true,
76 })
77
78 if roots := store.Roots(); len(roots) != 0 {
79 t.Fatalf("disabled store roots = %+v, want none", roots)
80 }
81 if skills := store.List(); len(skills) != 0 {
82 t.Fatalf("disabled store skills = %+v, want none", skills)
83 }
84 if skills := store.SlashList(); len(skills) != 0 {
85 t.Fatalf("disabled store slash skills = %+v, want none", skills)
86 }
87 if inspection := store.Inspect(); len(inspection.Roots) != 0 || len(inspection.Candidates) != 0 {
88 t.Fatalf("disabled store inspection = %+v, want empty", inspection)
89 }
90 if _, ok := store.Read("project"); ok {
91 t.Fatal("disabled store read discovered a skill")
92 }
93 }
94
95 func TestListPrecedenceProjectOverGlobal(t *testing.T) {
96 home := t.TempDir()
97 proj := t.TempDir()
98 writeSkill(t, proj, ".reasonix/skills/greet.md", "---\nname: greet\ndescription: project greet\n---\nproject body")
99 writeSkill(t, home, ".reasonix/skills/greet.md", "---\ndescription: global greet\n---\nglobal body")
100 writeSkill(t, home, ".reasonix/skills/onlyglobal.md", "---\ndescription: only global\n---\nbody")
101
102 st := New(Options{HomeDir: home, ProjectRoot: proj, DisableBuiltins: true})
103 list := st.List()
104
105 greet, ok := find(list, "greet")
106 if !ok {
107 t.Fatal("greet not found")
108 }
109 if greet.Scope != ScopeProject || greet.Description != "project greet" {
110 t.Fatalf("project skill should win: got scope=%s desc=%q", greet.Scope, greet.Description)
111 }
112 if _, ok := find(list, "onlyglobal"); !ok {
113 t.Fatal("global-only skill should be discovered")
114 }
115 }
116
117 func TestPluginClaudeAgentLoadsAsManualSubagent(t *testing.T) {
118 home := t.TempDir()
119 agentRoot := filepath.Join(t.TempDir(), "agents")
120 writeSkill(t, agentRoot, "reviewer.md", "---\ndescription: Review changes\nmodel: sonnet\ntools: [Read, Grep, \"mcp__*__search\"]\n---\nReview carefully.")
121 key := config.CanonicalSkillPath(agentRoot)
122 st := New(Options{HomeDir: home, CustomPaths: []string{agentRoot}, PluginPaths: map[string][]string{key: {"legal"}}, PluginAgentPaths: map[string][]string{key: {"legal"}}, DisableBuiltins: true})
123 sk, ok := st.Read("reviewer")
124 if !ok {
125 t.Fatal("Claude agent was not discoverable")
126 }
127 if sk.RunAs != RunSubagent || sk.Invocation != "manual" || sk.Model != "" {
128 t.Fatalf("agent profile = %+v", sk)
129 }
130 if sk.SlashName() != "legal:agent:reviewer" {
131 t.Fatalf("agent slash name = %q", sk.SlashName())
132 }
133 want := []string{"read_file", "grep", "mcp__*__search"}
134 if !slices.Equal(sk.AllowedTools, want) {
135 t.Fatalf("allowed tools = %v, want %v", sk.AllowedTools, want)
136 }
137 }
138
139 func TestPluginAgentAndSkillWithSameNameHaveDistinctQualifiedInvocations(t *testing.T) {
140 home := t.TempDir()
141 skillRoot := filepath.Join(t.TempDir(), "skills")
142 agentRoot := filepath.Join(t.TempDir(), "agents")
143 writeSkill(t, skillRoot, "leave-tracker/SKILL.md", "---\ndescription: Track leave\n---\nSkill body")
144 writeSkill(t, agentRoot, "leave-tracker.md", "---\ndescription: Monitor leave\n---\nAgent body")
145 skillKey := config.CanonicalSkillPath(skillRoot)
146 agentKey := config.CanonicalSkillPath(agentRoot)
147 st := New(Options{
148 HomeDir: home, CustomPaths: []string{skillRoot, agentRoot},
149 PluginPaths: map[string][]string{skillKey: {"employment-legal"}, agentKey: {"employment-legal"}},
150 PluginAgentPaths: map[string][]string{agentKey: {"employment-legal"}}, DisableBuiltins: true,
151 })
152
153 inline, ok := st.ReadSlash("employment-legal:leave-tracker")
154 if !ok || inline.RunAs != RunInline {
155 t.Fatalf("inline skill = %+v, found=%v", inline, ok)
156 }
157 agent, ok := st.ReadSlash("employment-legal:agent:leave-tracker")
158 if !ok || agent.RunAs != RunSubagent || agent.Invocation != "manual" {
159 t.Fatalf("agent profile = %+v, found=%v", agent, ok)
160 }
161 }
162
163 func TestPluginSkillsUseQualifiedSlashNamesWithoutChangingModelIndex(t *testing.T) {
164 home := t.TempDir()
165 alpha := t.TempDir()
166 beta := t.TempDir()
167 writeSkill(t, alpha, "plan/SKILL.md", "---\ndescription: alpha plan\n---\nALPHA")
168 writeSkill(t, beta, "plan/SKILL.md", "---\ndescription: beta plan\n---\nBETA")
169 writeSkill(t, beta, "review/SKILL.md", "---\ndescription: beta review\n---\nREVIEW")
170
171 st := New(Options{
172 HomeDir: home,
173 CustomPaths: []string{alpha, beta},
174 PluginPaths: map[string][]string{config.CanonicalSkillPath(alpha): {"alpha"}, config.CanonicalSkillPath(beta): {"beta"}},
175 DisableBuiltins: true,
176 })
177
178 modelSkills := st.List()
179 if len(modelSkills) != 2 || modelSkills[0].Name != "plan" || modelSkills[1].Name != "review" {
180 t.Fatalf("model skills = %+v", modelSkills)
181 }
182 if got := IndexBlock(modelSkills); strings.Contains(got, "alpha:plan") || strings.Contains(got, "beta:plan") {
183 t.Fatalf("model index must keep bare run_skill identifiers:\n%s", got)
184 }
185 withoutPluginMetadata := append([]Skill(nil), modelSkills...)
186 for i := range withoutPluginMetadata {
187 withoutPluginMetadata[i].Plugin = ""
188 }
189 if got, want := IndexBlock(modelSkills), IndexBlock(withoutPluginMetadata); got != want {
190 t.Fatalf("plugin ownership changed cache-stable model index:\ngot:\n%s\nwant:\n%s", got, want)
191 }
192
193 slashSkills := st.SlashList()
194 gotNames := make([]string, 0, len(slashSkills))
195 for _, sk := range slashSkills {
196 gotNames = append(gotNames, sk.SlashName())
197 }
198 wantNames := []string{"alpha:plan", "beta:plan", "beta:review"}
199 if !slices.Equal(gotNames, wantNames) {
200 t.Fatalf("slash skills = %v, want %v", gotNames, wantNames)
201 }
202 if _, ok := st.ReadSlash("plan"); ok {
203 t.Fatal("ambiguous short plugin skill must not resolve")
204 }
205 if sk, ok := st.ReadSlash("/beta:plan"); !ok || sk.Body != "BETA" || sk.Name != "plan" {
206 t.Fatalf("qualified beta skill = %+v, %v", sk, ok)
207 }
208 if sk, ok := st.Read("plan"); !ok || sk.Body != "ALPHA" || sk.Name != "plan" {
209 t.Fatalf("run_skill bare winner changed = %+v, %v", sk, ok)
210 }
211 }
212
213 func TestPluginSkillShortAliasIsHiddenAndProjectSkillKeepsShortName(t *testing.T) {
214 home := t.TempDir()
215 project := t.TempDir()
216 pluginRoot := t.TempDir()
217 writeSkill(t, pluginRoot, "plan/SKILL.md", "---\ndescription: plugin plan\n---\nPLUGIN")
218
219 st := New(Options{
220 HomeDir: home,
221 ProjectRoot: project,
222 CustomPaths: []string{pluginRoot},
223 PluginPaths: map[string][]string{config.CanonicalSkillPath(pluginRoot): {"superpowers"}},
224 DisableBuiltins: true,
225 })
226 if sk, ok := st.ReadSlash("plan"); !ok || sk.Body != "PLUGIN" {
227 t.Fatalf("unambiguous short compatibility alias = %+v, %v", sk, ok)
228 }
229 if got := st.SlashList(); len(got) != 1 || got[0].SlashName() != "superpowers:plan" {
230 t.Fatalf("visible plugin skills = %+v", got)
231 }
232
233 writeSkill(t, project, ".reasonix/skills/plan/SKILL.md", "---\ndescription: project plan\n---\nPROJECT")
234 if sk, ok := st.ReadSlash("plan"); !ok || sk.Body != "PROJECT" || sk.Plugin != "" {
235 t.Fatalf("project short skill = %+v, %v", sk, ok)
236 }
237 if sk, ok := st.ReadSlash("superpowers:plan"); !ok || sk.Body != "PLUGIN" {
238 t.Fatalf("qualified plugin skill beside project winner = %+v, %v", sk, ok)
239 }
240 }
241
242 func TestListDecodesGB18030SkillFile(t *testing.T) {
243 home := t.TempDir()
244 root := t.TempDir()
245 body := "---\ndescription: 中文技能\n---\n用中文处理任务。"
246 writeSkillBytes(t, root, filepath.Join("cn", SkillFile), fileencoding.Encode(body, fileencoding.GB18030))
247
248 st := New(Options{HomeDir: home, CustomPaths: []string{root}, DisableBuiltins: true})
249 skills := st.List()
250 if len(skills) != 1 || skills[0].Description != "中文技能" || skills[0].Body != "" {
251 t.Fatalf("decoded skills = %+v", skills)
252 }
253 if loaded, ok := st.Read("cn"); !ok || !strings.Contains(loaded.Body, "用中文处理任务") {
254 t.Fatalf("selected skill body = %+v found=%v", loaded, ok)
255 }
256 }
257
258 func TestFlatAndDirLayout(t *testing.T) {
259 home := t.TempDir()
260 writeSkill(t, home, ".reasonix/skills/flat.md", "---\ndescription: flat\n---\nflat body")
261 writeSkill(t, home, ".reasonix/skills/dir/SKILL.md", "---\ndescription: dir\n---\ndir body")
262
263 st := New(Options{HomeDir: home, DisableBuiltins: true})
264 list := st.List()
265 if _, ok := find(list, "flat"); !ok {
266 t.Error("flat <name>.md skill not discovered")
267 }
268 if _, ok := find(list, "dir"); !ok {
269 t.Error("dir/SKILL.md skill not discovered")
270 }
271 }
272
273 func TestNestedSkillsDiscoveredByDefault(t *testing.T) {
274 home := t.TempDir()
275 writeSkill(t, home, ".reasonix/skills/superpower/skill-a.md", "---\ndescription: nested flat\n---\nflat body")
276 writeSkill(t, home, ".reasonix/skills/superpower/tool-a/SKILL.md", "---\ndescription: nested dir\n---\ndir body")
277 writeSkill(t, home, ".reasonix/skills/superpower/references/notes.md", "---\ndescription: not a skill\n---\nnotes")
278
279 st := New(Options{HomeDir: home, DisableBuiltins: true})
280 list := st.List()
281 if _, ok := find(list, "skill-a"); !ok {
282 t.Fatal("default max depth should discover nested flat skills")
283 }
284 if _, ok := find(list, "tool-a"); !ok {
285 t.Fatal("default max depth should discover nested directory skills")
286 }
287 if _, ok := find(list, "notes"); ok {
288 t.Fatal("references directories should not be scanned as skill roots")
289 }
290 if sk, ok := st.Read("skill-a"); !ok || sk.Description != "nested flat" || !strings.Contains(sk.Body, "flat body") {
291 t.Fatalf("Read should resolve nested skills from the same discovery path: %+v ok=%v", sk, ok)
292 }
293 }
294
295 func TestMaxDepthOnePreservesRootOnlyDiscovery(t *testing.T) {
296 home := t.TempDir()
297 writeSkill(t, home, ".reasonix/skills/superpower/skill-a.md", "---\ndescription: nested flat\n---\nflat body")
298 writeSkill(t, home, ".reasonix/skills/superpower/tool-a/SKILL.md", "---\ndescription: nested dir\n---\ndir body")
299
300 st := New(Options{HomeDir: home, MaxDepth: 1, DisableBuiltins: true})
301 if _, ok := find(st.List(), "skill-a"); ok {
302 t.Fatal("max depth 1 should not discover nested flat skills")
303 }
304 if _, ok := find(st.List(), "tool-a"); ok {
305 t.Fatal("max depth 1 should not discover nested directory skills")
306 }
307 }
308
309 func TestNestedSkillsRequireDescription(t *testing.T) {
310 home := t.TempDir()
311 writeSkill(t, home, ".reasonix/skills/root.md", "---\n---\nroot body")
312 writeSkill(t, home, ".reasonix/skills/superpower/draft.md", "---\n---\ndraft body")
313 writeSkill(t, home, ".reasonix/skills/superpower/tool/SKILL.md", "---\n---\ntool body")
314
315 st := New(Options{HomeDir: home, DisableBuiltins: true})
316 list := st.List()
317 if _, ok := find(list, "root"); !ok {
318 t.Fatal("root-level skills without description should keep legacy discovery behavior")
319 }
320 if _, ok := find(list, "draft"); ok {
321 t.Fatal("nested flat skills without description should be ignored")
322 }
323 if _, ok := find(list, "tool"); ok {
324 t.Fatal("nested directory skills without description should be ignored")
325 }
326 if _, ok := st.Read("draft"); ok {
327 t.Fatal("Read should not resolve nested skills filtered for missing description")
328 }
329 }
330
331 func TestNestedDirectorySkillStopsTraversal(t *testing.T) {
332 home := t.TempDir()
333 writeSkill(t, home, ".reasonix/skills/pack/SKILL.md", "---\ndescription: pack\n---\npack body")
334 writeSkill(t, home, ".reasonix/skills/pack/child.md", "---\ndescription: child\n---\nchild body")
335
336 st := New(Options{HomeDir: home, MaxDepth: 3, DisableBuiltins: true})
337 if _, ok := find(st.List(), "pack"); !ok {
338 t.Fatal("directory-layout skill should be discovered")
339 }
340 if _, ok := find(st.List(), "child"); ok {
341 t.Fatal("directory-layout skill packages should not be scanned for child skills")
342 }
343 }
344
345 func TestConventionDirsDiscovered(t *testing.T) {
346 proj := t.TempDir()
347 writeSkill(t, proj, ".claude/skills/fromclaude.md", "---\ndescription: c\n---\nb")
348 writeSkill(t, proj, ".agents/skills/fromagents.md", "---\ndescription: a\n---\nb")
349 writeSkill(t, proj, ".agent/skills/fromagent.md", "---\ndescription: s\n---\nb")
350 st := New(Options{HomeDir: t.TempDir(), ProjectRoot: proj, DisableBuiltins: true})
351 list := st.List()
352 for _, name := range []string{"fromclaude", "fromagents", "fromagent"} {
353 if _, ok := find(list, name); !ok {
354 t.Errorf("convention dir for %q not scanned", name)
355 }
356 }
357 }
358
359 func TestReasonixHomeDirOverridesGlobalReasonixSkills(t *testing.T) {
360 home := t.TempDir()
361 reasonixHome := filepath.Join(t.TempDir(), "rx-home")
362 writeSkill(t, home, ".reasonix/skills/old.md", "---\ndescription: old\n---\nold")
363 writeSkill(t, home, ".reasonix/skills/current.md", "---\ndescription: old current\n---\nold current")
364 currentPath := writeSkill(t, reasonixHome, "skills/current.md", "---\ndescription: current\n---\ncurrent")
365
366 st := New(Options{HomeDir: home, ReasonixHomeDir: reasonixHome, DisableBuiltins: true})
367 list := st.List()
368 current, ok := find(list, "current")
369 if !ok {
370 t.Fatal("Reasonix home skill should be discovered")
371 }
372 if current.Path != currentPath {
373 t.Fatalf("current skill path = %q, want Reasonix home path %q", current.Path, currentPath)
374 }
375 if _, ok := find(list, "old"); !ok {
376 t.Fatal("legacy ~/.reasonix skill should remain discoverable")
377 }
378
379 path, err := st.Create("created", ScopeGlobal)
380 if err != nil {
381 t.Fatalf("Create: %v", err)
382 }
383 want := filepath.Join(reasonixHome, SkillsDirname, "created", SkillFile)
384 if path != want {
385 t.Fatalf("created skill path = %q, want %q", path, want)
386 }
387 }
388
389 func TestNonSkillMarkdownInClaudeSkillRootsIgnored(t *testing.T) {
390 proj := t.TempDir()
391 writeSkill(t, proj, ".claude/skills/guide.md", "# Skill notes\n\nThis is documentation, not a skill.")
392 writeSkill(t, proj, ".claude/skills/notes.md", "---\ntitle: Notes\n---\n# Notes")
393 writeSkill(t, proj, ".claude/skills/real.md", "---\ndescription: real skill\n---\nbody")
394
395 var stderr bytes.Buffer
396 st := New(Options{HomeDir: t.TempDir(), ProjectRoot: proj, DisableBuiltins: true, Stderr: &stderr})
397 list := st.List()
398 if _, ok := find(list, "real"); !ok {
399 t.Fatal("real skill should be discovered")
400 }
401 for _, name := range []string{"guide", "notes"} {
402 if _, ok := find(list, name); ok {
403 t.Errorf("non-skill markdown %q should not be listed", name)
404 }
405 }
406 if got := stderr.String(); got != "" {
407 t.Fatalf("non-skill markdown should not warn during List, got %q", got)
408 }
409
410 for _, name := range []string{"guide", "notes"} {
411 stderr.Reset()
412 if _, ok := st.Read(name); ok {
413 t.Errorf("non-skill markdown %q should not be readable as a skill", name)
414 }
415 if got := stderr.String(); got != "" {
416 t.Errorf("non-skill markdown %q should not warn during Read, got %q", name, got)
417 }
418 }
419 }
420
421 func TestSkillLikeFlatClaudeMarkdownWithoutDescriptionWarns(t *testing.T) {
422 home := t.TempDir()
423 writeSkill(t, home, ".claude/skills/named.md", "---\nname: renamed\n---\nbody")
424
425 var stderr bytes.Buffer
426 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
427 list := st.List()
428 if _, ok := find(list, "renamed"); !ok {
429 t.Fatal("skill-like flat Claude markdown should still load")
430 }
431 if got := stderr.String(); !strings.Contains(got, "has no description") {
432 t.Fatalf("skill-like flat Claude markdown without description should warn, got %q", got)
433 }
434 }
435
436 func TestBlankDescriptionFlatClaudeMarkdownIsSkillLike(t *testing.T) {
437 for _, tc := range []struct {
438 name string
439 content string
440 }{
441 {name: "blank", content: "---\ndescription:\n---\nbody"},
442 {name: "quoted", content: "---\ndescription: \"\"\n---\nbody"},
443 } {
444 t.Run(tc.name, func(t *testing.T) {
445 home := t.TempDir()
446 writeSkill(t, home, ".claude/skills/"+tc.name+".md", tc.content)
447
448 var stderr bytes.Buffer
449 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
450 if _, ok := find(st.List(), tc.name); !ok {
451 t.Fatal("blank description marker should still list flat Claude markdown as skill-like")
452 }
453 if got := stderr.String(); !strings.Contains(got, "has no description") {
454 t.Fatalf("blank description listed skill should warn, got %q", got)
455 }
456
457 stderr.Reset()
458 scans := st.DiscoveryScans()
459 sk, ok := st.Read(tc.name)
460 if !ok {
461 t.Fatal("blank description marker should still make flat Claude markdown skill-like")
462 }
463 if sk.Description != "" {
464 t.Fatalf("description should stay empty, got %q", sk.Description)
465 }
466 if got := stderr.String(); got != "" || st.DiscoveryScans() != scans {
467 t.Fatalf("warm cached read rescanned or repeated diagnostics: output=%q scans=%d->%d", got, scans, st.DiscoveryScans())
468 }
469 })
470 }
471 }
472
473 func TestRunAsOnlyFlatClaudeMarkdownIsSkillLike(t *testing.T) {
474 home := t.TempDir()
475 writeSkill(t, home, ".claude/skills/sub.md", "---\nrunAs: subagent\n---\nbody")
476
477 var stderr bytes.Buffer
478 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
479 sk, ok := st.Read("sub")
480 if !ok {
481 t.Fatal("runAs-only Claude markdown should be treated as skill-like")
482 }
483 if sk.RunAs != RunSubagent {
484 t.Fatalf("runAs should be parsed despite frontmatter key casing, got %s", sk.RunAs)
485 }
486 if got := stderr.String(); !strings.Contains(got, "has no description") {
487 t.Fatalf("runAs-only Claude markdown without description should warn, got %q", got)
488 }
489 }
490
491 func TestExcludedPathsHideConventionRoots(t *testing.T) {
492 home := t.TempDir()
493 writeSkill(t, home, ".reasonix/skills/keep.md", "---\ndescription: keep\n---\nb")
494 writeSkill(t, home, ".agents/skills/noisy.md", "---\ndescription: noisy\n---\nb")
495 excluded := filepath.Join(home, ".agents", "skills")
496 st := New(Options{HomeDir: home, ExcludedPaths: []string{excluded}, DisableBuiltins: true})
497
498 if _, ok := find(st.List(), "keep"); !ok {
499 t.Fatal("non-excluded skill should be listed")
500 }
501 if _, ok := find(st.List(), "noisy"); ok {
502 t.Fatal("excluded skill should not be listed")
503 }
504 for _, root := range st.Roots() {
505 if config.CanonicalSkillPath(root.Dir) == config.CanonicalSkillPath(excluded) {
506 t.Fatalf("excluded root should be hidden from Roots: %+v", st.Roots())
507 }
508 }
509 }
510
511 func TestFrontmatterFields(t *testing.T) {
512 home := t.TempDir()
513 writeSkill(t, home, ".reasonix/skills/sub.md",
514 "---\ndescription: a sub\nrunAs: subagent\nallowed-tools: read_file, grep\nmodel: deepseek-pro\nread-only: true\n---\nbody")
515 writeSkill(t, home, ".reasonix/skills/fork.md", "---\ndescription: f\ncontext: fork\n---\nbody")
516 writeSkill(t, home, ".reasonix/skills/plain.md", "---\ndescription: p\n---\nbody")
517
518 st := New(Options{HomeDir: home, DisableBuiltins: true})
519 sub, _ := st.Read("sub")
520 if sub.RunAs != RunSubagent {
521 t.Error("runAs: subagent not parsed")
522 }
523 if len(sub.AllowedTools) != 2 || sub.AllowedTools[0] != "read_file" || sub.AllowedTools[1] != "grep" {
524 t.Errorf("allowed-tools mis-parsed: %v", sub.AllowedTools)
525 }
526 if sub.Model != "deepseek-pro" {
527 t.Errorf("model mis-parsed: %q", sub.Model)
528 }
529 if !sub.ReadOnly {
530 t.Error("read-only: true not parsed")
531 }
532 if fork, _ := st.Read("fork"); fork.RunAs != RunSubagent {
533 t.Error("context: fork should imply subagent")
534 }
535 if plain, _ := st.Read("plain"); plain.RunAs != RunInline {
536 t.Error("default runAs should be inline")
537 }
538 if plain, _ := st.Read("plain"); plain.ReadOnly {
539 t.Error("read-only should default to false when the key is absent")
540 }
541 }
542
543 func TestReferencesInlined(t *testing.T) {
544 home := t.TempDir()
545 writeSkill(t, home, ".reasonix/skills/withrefs/SKILL.md", "---\ndescription: r\n---\nmain body")
546 writeSkill(t, home, ".reasonix/skills/withrefs/references/b.md", "second ref")
547 writeSkill(t, home, ".reasonix/skills/withrefs/references/a.md", "first ref")
548
549 st := New(Options{HomeDir: home, DisableBuiltins: true})
550 sk, ok := st.Read("withrefs")
551 if !ok {
552 t.Fatal("skill not found")
553 }
554 if !strings.Contains(sk.Body, "main body") {
555 t.Error("main body missing")
556 }
557 // references are appended sorted by filename: a before b.
558 ai := strings.Index(sk.Body, "## Reference: a")
559 bi := strings.Index(sk.Body, "## Reference: b")
560 if ai < 0 || bi < 0 || ai > bi {
561 t.Errorf("references not appended in sorted order: a=%d b=%d", ai, bi)
562 }
563 if !strings.Contains(sk.Body, "first ref") || !strings.Contains(sk.Body, "second ref") {
564 t.Error("reference contents missing")
565 }
566 }
567
568 func TestScriptsAppended(t *testing.T) {
569 home := filepath.Join(t.TempDir(), "home with spaces")
570 writeSkill(t, home, ".reasonix/skills/withscripts/SKILL.md", "---\ndescription: r\n---\nmain body")
571 writeScript(t, home, ".reasonix/skills/withscripts/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')")
572 writeScript(t, home, ".reasonix/skills/withscripts/scripts/deploy.sh", "#!/usr/bin/env bash\necho ok")
573
574 st := New(Options{HomeDir: home, DisableBuiltins: true})
575 sk, ok := st.Read("withscripts")
576 if !ok {
577 t.Fatal("skill not found")
578 }
579 if !strings.Contains(sk.Body, "main body") {
580 t.Error("main body missing")
581 }
582 if !strings.Contains(sk.Body, "## Scripts") {
583 t.Error("scripts section missing")
584 }
585 if !strings.Contains(sk.Body, "lint.py") || !strings.Contains(sk.Body, "deploy.sh") {
586 t.Error("script paths missing from body")
587 }
588 if !strings.Contains(sk.Body, "main body\n\n## Scripts") {
589 t.Errorf("scripts section should be separated from the original body:\n%s", sk.Body)
590 }
591 if !strings.Contains(sk.Body, "quote the path if it contains spaces") {
592 t.Error("scripts guidance should mention quoting paths with spaces")
593 }
594 }
595
596 func TestScriptsStayOutOfSkillIndex(t *testing.T) {
597 home := t.TempDir()
598 writeSkill(t, home, ".reasonix/skills/withscripts/SKILL.md", "---\ndescription: cache-safe script skill\n---\nmain body")
599 writeScript(t, home, ".reasonix/skills/withscripts/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')")
600
601 st := New(Options{HomeDir: home, DisableBuiltins: true})
602 sk, ok := st.Read("withscripts")
603 if !ok {
604 t.Fatal("skill not found")
605 }
606 if !strings.Contains(sk.Body, "## Scripts") || !strings.Contains(sk.Body, "lint.py") {
607 t.Fatal("test setup expected scripts in the on-demand skill body")
608 }
609
610 index := ApplyIndex("BASE", []Skill{sk})
611 if !strings.Contains(index, "withscripts") || !strings.Contains(index, "cache-safe script skill") {
612 t.Fatalf("skill index missing name/description:\n%s", index)
613 }
614 for _, forbidden := range []string{"## Scripts", "lint.py", filepath.Join("scripts", "lint.py")} {
615 if strings.Contains(index, forbidden) {
616 t.Fatalf("skill index should not include on-demand script listing %q:\n%s", forbidden, index)
617 }
618 }
619 }
620
621 func TestNoScriptsWhenDirAbsent(t *testing.T) {
622 home := t.TempDir()
623 writeSkill(t, home, ".reasonix/skills/noscripts/SKILL.md", "---\ndescription: r\n---\nmain body")
624 st := New(Options{HomeDir: home, DisableBuiltins: true})
625 sk, ok := st.Read("noscripts")
626 if !ok {
627 t.Fatal("skill not found")
628 }
629 if strings.Contains(sk.Body, "## Scripts") {
630 t.Error("should not have scripts section when scripts/ missing")
631 }
632 }
633
634 func TestFlatSkillNoScripts(t *testing.T) {
635 home := t.TempDir()
636 writeSkill(t, home, ".reasonix/skills/flat.md", "---\ndescription: r\n---\nmain body")
637 st := New(Options{HomeDir: home, DisableBuiltins: true})
638 sk, ok := st.Read("flat")
639 if !ok {
640 t.Fatal("skill not found")
641 }
642 if strings.Contains(sk.Body, "## Scripts") {
643 t.Error("flat skill should not have scripts section")
644 }
645 }
646
647 func TestScriptsFilteredByExt(t *testing.T) {
648 home := t.TempDir()
649 writeSkill(t, home, ".reasonix/skills/scriptscheck/SKILL.md", "---\ndescription: t\n---\nbody")
650 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')\n")
651 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/.hidden.py", "")
652 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/readme.md", "# readme")
653 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/deploy", "#!/bin/sh\necho ok")
654 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/legacy.p", "print 'ok'\n")
655 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/.gitkeep", "")
656
657 st := New(Options{HomeDir: home, DisableBuiltins: true})
658 sk, ok := st.Read("scriptscheck")
659 if !ok {
660 t.Fatal("skill not found")
661 }
662 body := sk.Body
663 // lint.py should be listed (recognized .py extension)
664 if !strings.Contains(body, "lint.py") {
665 t.Error("lint.py should be listed (recognized .py extension)")
666 }
667 // deploy (no extension) should be listed (bare executable)
668 if !strings.Contains(body, "deploy") {
669 t.Error("deploy (no extension) should be listed as bare executable")
670 }
671 // .hidden.py should NOT be listed (hidden file)
672 if strings.Contains(body, ".hidden.py") {
673 t.Error("hidden files should NOT be listed")
674 }
675 // readme.md should NOT be listed (documentation, not a script)
676 if strings.Contains(body, "readme.md") {
677 t.Error("non-script extensions should NOT be listed")
678 }
679 if strings.Contains(body, "legacy.p") {
680 t.Error("partial extension matches should NOT be listed")
681 }
682 // .gitkeep should NOT be listed (hidden file)
683 if strings.Contains(body, ".gitkeep") {
684 t.Error(".gitkeep should NOT be listed")
685 }
686 }
687
688 func TestBuiltinInitIsInlineSkill(t *testing.T) {
689 // /init must resolve to a built-in inline skill (the model-driven AGENTS.md
690 // bootstrap), present even with no project/user skills on disk.
691 st := New(Options{HomeDir: t.TempDir()})
692 sk, ok := st.Read("init")
693 if !ok {
694 t.Fatal("built-in init skill not found")
695 }
696 if sk.Scope != ScopeBuiltin || sk.RunAs != RunInline {
697 t.Errorf("init should be a builtin inline skill, got scope=%s runAs=%s", sk.Scope, sk.RunAs)
698 }
699 if _, listed := find(st.List(), "init"); !listed {
700 t.Error("init should appear in List() so it reaches the slash menu")
701 }
702 }
703
704 func TestBuiltinSubagentSkillsDeclareAllowedTools(t *testing.T) {
705 st := New(Options{HomeDir: t.TempDir()})
706 cases := map[string][]string{
707 "explore": {"read_file", "ls", "glob", "grep", "code_index"},
708 "research": {"read_file", "ls", "glob", "grep", "code_index", "web_fetch"},
709 "review": {"read_file", "ls", "glob", "grep", "code_index", "bash", "use_capability"},
710 "security-review": {"read_file", "ls", "glob", "grep", "code_index", "bash", "use_capability"},
711 }
712 for name, want := range cases {
713 sk, ok := st.Read(name)
714 if !ok {
715 t.Fatalf("built-in %s skill not found", name)
716 }
717 if sk.RunAs != RunSubagent {
718 t.Fatalf("%s RunAs = %s, want subagent", name, sk.RunAs)
719 }
720 if !sameStrings(sk.AllowedTools, want) {
721 t.Errorf("%s AllowedTools = %v, want %v", name, sk.AllowedTools, want)
722 }
723 for _, meta := range []string{"task", "run_skill", "install_skill", "install_source", "explore", "research", "review", "security_review"} {
724 if containsString(sk.AllowedTools, meta) {
725 t.Errorf("%s AllowedTools should not include meta-tool %q: %v", name, meta, sk.AllowedTools)
726 }
727 }
728 }
729 }
730
731 func TestBuiltinsPresentAndOverridable(t *testing.T) {
732 st := New(Options{HomeDir: t.TempDir()})
733 if _, ok := find(st.List(), "explore"); !ok {
734 t.Error("built-in explore should be present")
735 }
736 // A user file named after a built-in overrides it.
737 home := t.TempDir()
738 writeSkill(t, home, ".reasonix/skills/explore.md", "---\ndescription: mine\nrunAs: inline\n---\nbody")
739 st2 := New(Options{HomeDir: home})
740 ex, _ := st2.Read("explore")
741 if ex.Scope == ScopeBuiltin || ex.Description != "mine" {
742 t.Errorf("user explore should override builtin: scope=%s desc=%q", ex.Scope, ex.Description)
743 }
744 }
745
746 func TestInstallCapabilityBuiltinIsInlineWithExpectedMetadata(t *testing.T) {
747 st := New(Options{HomeDir: t.TempDir()})
748 sk, ok := st.Read("install-capability")
749 if !ok {
750 t.Fatal("install-capability builtin skill must be registered")
751 }
752 if sk.Scope != ScopeBuiltin {
753 t.Errorf("install-capability scope = %s, want builtin", sk.Scope)
754 }
755 if sk.RunAs != RunInline {
756 t.Errorf("install-capability runAs = %s, want inline (it folds into the parent turn)", sk.RunAs)
757 }
758 if !strings.Contains(sk.Description, "install_source") {
759 t.Errorf("description should mention install_source, got %q", sk.Description)
760 }
761 if !strings.Contains(sk.Description, "uninstall") {
762 t.Errorf("description should advertise op=uninstall, got %q", sk.Description)
763 }
764 if !strings.Contains(sk.Body, "riskLevel") {
765 t.Error("body should mention the per-action riskLevel field so the model reads it")
766 }
767 if !strings.Contains(sk.Body, "planId") {
768 t.Error("body should mention the planId echo requirement on apply=true")
769 }
770 }
771
772 func TestAutoResearchIsNotSeparateBuiltinSkill(t *testing.T) {
773 st := New(Options{HomeDir: t.TempDir()})
774 if _, listed := find(st.List(), "auto-research"); listed {
775 t.Error("auto-research should be a Goal strategy, not a separate builtin skill")
776 }
777 if _, ok := st.Read("auto-research"); ok {
778 t.Error("auto-research should not be readable as a standalone builtin skill")
779 }
780 }
781
782 func TestDisabledSkillsAreFilteredFromListAndRead(t *testing.T) {
783 home := t.TempDir()
784 writeSkill(t, home, ".reasonix/skills/active.md", "---\ndescription: active\n---\nbody")
785 writeSkill(t, home, ".reasonix/skills/hidden.md", "---\ndescription: hidden\n---\nbody")
786
787 st := New(Options{HomeDir: home, DisabledNames: []string{"hidden", "review"}})
788 if _, ok := find(st.List(), "active"); !ok {
789 t.Fatal("active skill should be listed")
790 }
791 if _, ok := find(st.List(), "hidden"); ok {
792 t.Fatal("disabled file skill should not be listed")
793 }
794 if _, ok := st.Read("hidden"); ok {
795 t.Fatal("disabled file skill should not be readable")
796 }
797 if _, ok := find(st.List(), "review"); ok {
798 t.Fatal("disabled builtin skill should not be listed")
799 }
800 if _, ok := st.Read("review"); ok {
801 t.Fatal("disabled builtin skill should not be readable")
802 }
803 }
804
805 func sameStrings(a, b []string) bool {
806 if len(a) != len(b) {
807 return false
808 }
809 for i := range a {
810 if a[i] != b[i] {
811 return false
812 }
813 }
814 return true
815 }
816
817 func containsString(ss []string, want string) bool {
818 return slices.Contains(ss, want)
819 }
820
821 func TestInvalidNamesSkipped(t *testing.T) {
822 home := t.TempDir()
823 writeSkill(t, home, ".reasonix/skills/bad name.md", "---\ndescription: x\n---\nb") // space → invalid
824 st := New(Options{HomeDir: home, DisableBuiltins: true})
825 if len(st.List()) != 0 {
826 t.Errorf("invalid-named skill should be skipped, got %d", len(st.List()))
827 }
828 }
829
830 func TestSymlinkedDirAndFile(t *testing.T) {
831 if runtime.GOOS == "windows" {
832 t.Skip("symlink creation needs privilege on Windows")
833 }
834 home := t.TempDir()
835 target := t.TempDir()
836 // real skill dir + flat file living outside the skills root
837 writeSkill(t, target, "realdir/SKILL.md", "---\ndescription: linked dir\n---\nb")
838 writeSkill(t, target, "realflat.md", "---\ndescription: linked flat\n---\nb")
839
840 skillsRoot := filepath.Join(home, ".reasonix", "skills")
841 if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
842 t.Fatal(err)
843 }
844 if err := os.Symlink(filepath.Join(target, "realdir"), filepath.Join(skillsRoot, "linkeddir")); err != nil {
845 t.Fatal(err)
846 }
847 if err := os.Symlink(filepath.Join(target, "realflat.md"), filepath.Join(skillsRoot, "linkedflat.md")); err != nil {
848 t.Fatal(err)
849 }
850
851 st := New(Options{HomeDir: home, DisableBuiltins: true})
852 list := st.List()
853 if _, ok := find(list, "linkeddir"); !ok {
854 t.Error("symlinked skill directory not discovered")
855 }
856 if _, ok := find(list, "linkedflat"); !ok {
857 t.Error("symlinked flat skill file not discovered")
858 }
859 // broken symlink is skipped, not fatal.
860 if err := os.Symlink(filepath.Join(target, "does-not-exist"), filepath.Join(skillsRoot, "broken")); err != nil {
861 t.Fatal(err)
862 }
863 if _, ok := find(st.List(), "broken"); ok {
864 t.Error("broken symlink should not yield a skill")
865 }
866 }
867
868 type fakeDirEntry struct {
869 name string
870 isDir bool
871 typ os.FileMode
872 }
873
874 func (f fakeDirEntry) Name() string { return f.name }
875 func (f fakeDirEntry) IsDir() bool { return f.isDir }
876 func (f fakeDirEntry) Type() os.FileMode { return f.typ }
877 func (f fakeDirEntry) Info() (os.FileInfo, error) {
878 return fakeFileInfo{name: f.name, mode: f.typ}, nil
879 }
880
881 type fakeFileInfo struct {
882 name string
883 mode os.FileMode
884 }
885
886 func (f fakeFileInfo) Name() string { return f.name }
887 func (f fakeFileInfo) Size() int64 { return 0 }
888 func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
889 func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
890 func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() }
891 func (f fakeFileInfo) Sys() any { return nil }
892
893 func TestIrregularDirectoryEntryFollowsTarget(t *testing.T) {
894 home := t.TempDir()
895 root := filepath.Join(home, ".agents", "skills")
896 writeSkill(t, root, "linkedpack/SKILL.md", "---\ndescription: linked pack\n---\nbody")
897 writeSkill(t, root, "collection/nested.md", "---\ndescription: nested\n---\nbody")
898
899 st := New(Options{HomeDir: home, DisableBuiltins: true})
900 linkedPack := fakeDirEntry{name: "linkedpack", typ: os.ModeIrregular}
901 if sk, ok := st.readEntry(root, ScopeGlobal, false, linkedPack); !ok || sk.Name != "linkedpack" {
902 t.Fatalf("irregular directory-layout entry should follow target, got %+v ok=%v", sk, ok)
903 }
904 collection := fakeDirEntry{name: "collection", typ: os.ModeIrregular}
905 if !st.canScanChildDir(root, collection) {
906 t.Fatal("irregular directory entry should be scannable when its target is a directory")
907 }
908 }
909
910 func TestApplyIndex(t *testing.T) {
911 if got := ApplyIndex("BASE", nil); got != "BASE" {
912 t.Errorf("empty skills should leave base unchanged, got %q", got)
913 }
914 skills := []Skill{
915 {Name: "alpha", Description: "the alpha", RunAs: RunInline},
916 {Name: "beta", Description: "the beta", RunAs: RunSubagent},
917 }
918 out := ApplyIndex("BASE", skills)
919 if !strings.HasPrefix(out, "BASE\n\n# Skills") {
920 t.Error("index should append after the base")
921 }
922 if !strings.Contains(out, "- alpha — the alpha") {
923 t.Errorf("inline skill line missing: %s", out)
924 }
925 if !strings.Contains(out, "- beta [🧬 subagent] — the beta") {
926 t.Errorf("subagent tag missing: %s", out)
927 }
928 }
929
930 func TestReadOnlyIndexBlockPointsAtReadOnlySkill(t *testing.T) {
931 out := ReadOnlyIndexBlock([]Skill{{Name: "beta", Description: "the beta", RunAs: RunSubagent}})
932 if !strings.Contains(out, "read_only_skill") {
933 t.Fatalf("read-only index should name read_only_skill:\n%s", out)
934 }
935 if strings.Contains(out, "Call `run_skill") {
936 t.Fatalf("read-only index should not tell the model to call run_skill:\n%s", out)
937 }
938 }
939
940 func TestSkillRoutingMetadataParsesButStaysOutOfIndex(t *testing.T) {
941 home := t.TempDir()
942 writeSkill(t, home, ".reasonix/skills/router.md", "---\ndescription: route me\ntriggers: code review, 检查代码\nnegative-triggers: explain only\nauto-use: prefer\nneeds-fresh-data: true\ncost: low\nrequires: mcp-server:github, mcp-tool:github/search_issues\nprofiles: delivery, balanced, economy, invalid\n---\nbody")
943 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("router")
944 if !ok {
945 t.Fatal("skill not loaded")
946 }
947 if got := strings.Join(sk.Triggers, ","); got != "code review,检查代码" {
948 t.Fatalf("Triggers = %q", got)
949 }
950 if got := strings.Join(sk.NegativeTriggers, ","); got != "explain only" {
951 t.Fatalf("NegativeTriggers = %q", got)
952 }
953 if sk.AutoUse != "prefer" || !sk.NeedsFreshData || sk.Cost != "low" {
954 t.Fatalf("routing metadata = auto:%q fresh:%v cost:%q", sk.AutoUse, sk.NeedsFreshData, sk.Cost)
955 }
956 if got := strings.Join(sk.Requires, ","); got != "mcp-server:github,mcp-tool:github/search_issues" {
957 t.Fatalf("Requires = %q", got)
958 }
959 if got := strings.Join(sk.Profiles, ","); got != "delivery,balanced,economy" {
960 t.Fatalf("Profiles = %q (invalid values should be dropped)", got)
961 }
962 if got := strings.Join(sk.InvalidProfiles, ","); got != "invalid" {
963 t.Fatalf("InvalidProfiles = %q (rejected values must be preserved for doctor)", got)
964 }
965 index := IndexBlock([]Skill{sk})
966 for _, forbidden := range []string{"code review", "auto-use", "needs-fresh-data", "mcp-server:github", "profiles"} {
967 if strings.Contains(index, forbidden) {
968 t.Fatalf("routing metadata leaked into index (%q):\n%s", forbidden, index)
969 }
970 }
971 }
972
973 func TestColorFrontmatterParses(t *testing.T) {
974 home := t.TempDir()
975 writeSkill(t, home, ".reasonix/skills/tagged.md", "---\ndescription: has a color\ncolor: amber\n---\nbody")
976 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("tagged")
977 if !ok {
978 t.Fatal("skill not loaded")
979 }
980 if sk.Color != "amber" {
981 t.Fatalf("Color = %q, want amber", sk.Color)
982 }
983 }
984
985 func TestInvocationDefaultsToAutoForExistingSkills(t *testing.T) {
986 home := t.TempDir()
987 writeSkill(t, home, ".reasonix/skills/plain.md", "---\ndescription: no invocation field\n---\nbody")
988 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("plain")
989 if !ok {
990 t.Fatal("skill not loaded")
991 }
992 if sk.Invocation != "auto" {
993 t.Fatalf("Invocation = %q, want auto (default)", sk.Invocation)
994 }
995 if sk.Color != "" {
996 t.Fatalf("Color = %q, want empty for a file with no color: key", sk.Color)
997 }
998 }
999
1000 func TestManualInvocationSkillExcludedFromIndex(t *testing.T) {
1001 home := t.TempDir()
1002 writeSkill(t, home, ".reasonix/skills/private-agent.md", "---\ndescription: my private subagent\nrunAs: subagent\ninvocation: manual\n---\nbody")
1003 writeSkill(t, home, ".reasonix/skills/public-agent.md", "---\ndescription: a discoverable subagent\nrunAs: subagent\n---\nbody")
1004 store := New(Options{HomeDir: home, DisableBuiltins: true})
1005 private, ok := store.Read("private-agent")
1006 if !ok {
1007 t.Fatal("private-agent not loaded")
1008 }
1009 if private.Invocation != "manual" {
1010 t.Fatalf("Invocation = %q, want manual", private.Invocation)
1011 }
1012 public, ok := store.Read("public-agent")
1013 if !ok {
1014 t.Fatal("public-agent not loaded")
1015 }
1016
1017 index := IndexBlock([]Skill{private, public})
1018 if strings.Contains(index, "private-agent") {
1019 t.Fatalf("manual-invocation skill leaked into index:\n%s", index)
1020 }
1021 if !strings.Contains(index, "public-agent") {
1022 t.Fatalf("auto-invocation skill missing from index:\n%s", index)
1023 }
1024
1025 // A read-only index built from only manual-invocation skills must render
1026 // as empty, not a header wrapped around nothing.
1027 if got := IndexBlock([]Skill{private}); got != "" {
1028 t.Fatalf("IndexBlock of only manual-invocation skills = %q, want empty", got)
1029 }
1030 }
1031
1032 func TestIndexLineClipsGraphemeClusters(t *testing.T) {
1033 cluster := "👨‍👩‍👧‍👦"
1034 got := clipRunes("a"+cluster+"bc", 3)
1035 want := "a" + cluster + "…"
1036 if got != want {
1037 t.Fatalf("clipRunes() = %q, want %q", got, want)
1038 }
1039 }
1040
1041 func TestCreateRefusesOverwrite(t *testing.T) {
1042 home := t.TempDir()
1043 st := New(Options{HomeDir: home, DisableBuiltins: true})
1044 path, err := st.Create("mine", ScopeGlobal)
1045 if err != nil {
1046 t.Fatalf("create: %v", err)
1047 }
1048 if !strings.HasSuffix(path, filepath.Join(".reasonix", "skills", "mine", SkillFile)) {
1049 t.Errorf("unexpected path %q", path)
1050 }
1051 if _, err := st.Create("mine", ScopeGlobal); err == nil {
1052 t.Error("second create should refuse to overwrite")
1053 }
1054
1055 writeSkill(t, home, ".reasonix/skills/legacy.md", "---\ndescription: legacy\n---\nbody")
1056 if _, err := st.Create("legacy", ScopeGlobal); err == nil {
1057 t.Error("create should refuse to shadow an existing legacy flat skill")
1058 }
1059 }
1060
1060 lines GO