返回 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 != "中文技能" || !strings.Contains(skills[0].Body, "用中文处理任务") {
251 t.Fatalf("decoded skills = %+v", skills)
252 }
253 }
254
255 func TestFlatAndDirLayout(t *testing.T) {
256 home := t.TempDir()
257 writeSkill(t, home, ".reasonix/skills/flat.md", "---\ndescription: flat\n---\nflat body")
258 writeSkill(t, home, ".reasonix/skills/dir/SKILL.md", "---\ndescription: dir\n---\ndir body")
259
260 st := New(Options{HomeDir: home, DisableBuiltins: true})
261 list := st.List()
262 if _, ok := find(list, "flat"); !ok {
263 t.Error("flat <name>.md skill not discovered")
264 }
265 if _, ok := find(list, "dir"); !ok {
266 t.Error("dir/SKILL.md skill not discovered")
267 }
268 }
269
270 func TestNestedSkillsDiscoveredByDefault(t *testing.T) {
271 home := t.TempDir()
272 writeSkill(t, home, ".reasonix/skills/superpower/skill-a.md", "---\ndescription: nested flat\n---\nflat body")
273 writeSkill(t, home, ".reasonix/skills/superpower/tool-a/SKILL.md", "---\ndescription: nested dir\n---\ndir body")
274 writeSkill(t, home, ".reasonix/skills/superpower/references/notes.md", "---\ndescription: not a skill\n---\nnotes")
275
276 st := New(Options{HomeDir: home, DisableBuiltins: true})
277 list := st.List()
278 if _, ok := find(list, "skill-a"); !ok {
279 t.Fatal("default max depth should discover nested flat skills")
280 }
281 if _, ok := find(list, "tool-a"); !ok {
282 t.Fatal("default max depth should discover nested directory skills")
283 }
284 if _, ok := find(list, "notes"); ok {
285 t.Fatal("references directories should not be scanned as skill roots")
286 }
287 if sk, ok := st.Read("skill-a"); !ok || sk.Description != "nested flat" || !strings.Contains(sk.Body, "flat body") {
288 t.Fatalf("Read should resolve nested skills from the same discovery path: %+v ok=%v", sk, ok)
289 }
290 }
291
292 func TestMaxDepthOnePreservesRootOnlyDiscovery(t *testing.T) {
293 home := t.TempDir()
294 writeSkill(t, home, ".reasonix/skills/superpower/skill-a.md", "---\ndescription: nested flat\n---\nflat body")
295 writeSkill(t, home, ".reasonix/skills/superpower/tool-a/SKILL.md", "---\ndescription: nested dir\n---\ndir body")
296
297 st := New(Options{HomeDir: home, MaxDepth: 1, DisableBuiltins: true})
298 if _, ok := find(st.List(), "skill-a"); ok {
299 t.Fatal("max depth 1 should not discover nested flat skills")
300 }
301 if _, ok := find(st.List(), "tool-a"); ok {
302 t.Fatal("max depth 1 should not discover nested directory skills")
303 }
304 }
305
306 func TestNestedSkillsRequireDescription(t *testing.T) {
307 home := t.TempDir()
308 writeSkill(t, home, ".reasonix/skills/root.md", "---\n---\nroot body")
309 writeSkill(t, home, ".reasonix/skills/superpower/draft.md", "---\n---\ndraft body")
310 writeSkill(t, home, ".reasonix/skills/superpower/tool/SKILL.md", "---\n---\ntool body")
311
312 st := New(Options{HomeDir: home, DisableBuiltins: true})
313 list := st.List()
314 if _, ok := find(list, "root"); !ok {
315 t.Fatal("root-level skills without description should keep legacy discovery behavior")
316 }
317 if _, ok := find(list, "draft"); ok {
318 t.Fatal("nested flat skills without description should be ignored")
319 }
320 if _, ok := find(list, "tool"); ok {
321 t.Fatal("nested directory skills without description should be ignored")
322 }
323 if _, ok := st.Read("draft"); ok {
324 t.Fatal("Read should not resolve nested skills filtered for missing description")
325 }
326 }
327
328 func TestNestedDirectorySkillStopsTraversal(t *testing.T) {
329 home := t.TempDir()
330 writeSkill(t, home, ".reasonix/skills/pack/SKILL.md", "---\ndescription: pack\n---\npack body")
331 writeSkill(t, home, ".reasonix/skills/pack/child.md", "---\ndescription: child\n---\nchild body")
332
333 st := New(Options{HomeDir: home, MaxDepth: 3, DisableBuiltins: true})
334 if _, ok := find(st.List(), "pack"); !ok {
335 t.Fatal("directory-layout skill should be discovered")
336 }
337 if _, ok := find(st.List(), "child"); ok {
338 t.Fatal("directory-layout skill packages should not be scanned for child skills")
339 }
340 }
341
342 func TestConventionDirsDiscovered(t *testing.T) {
343 proj := t.TempDir()
344 writeSkill(t, proj, ".claude/skills/fromclaude.md", "---\ndescription: c\n---\nb")
345 writeSkill(t, proj, ".agents/skills/fromagents.md", "---\ndescription: a\n---\nb")
346 writeSkill(t, proj, ".agent/skills/fromagent.md", "---\ndescription: s\n---\nb")
347 st := New(Options{HomeDir: t.TempDir(), ProjectRoot: proj, DisableBuiltins: true})
348 list := st.List()
349 for _, name := range []string{"fromclaude", "fromagents", "fromagent"} {
350 if _, ok := find(list, name); !ok {
351 t.Errorf("convention dir for %q not scanned", name)
352 }
353 }
354 }
355
356 func TestReasonixHomeDirOverridesGlobalReasonixSkills(t *testing.T) {
357 home := t.TempDir()
358 reasonixHome := filepath.Join(t.TempDir(), "rx-home")
359 writeSkill(t, home, ".reasonix/skills/old.md", "---\ndescription: old\n---\nold")
360 writeSkill(t, home, ".reasonix/skills/current.md", "---\ndescription: old current\n---\nold current")
361 currentPath := writeSkill(t, reasonixHome, "skills/current.md", "---\ndescription: current\n---\ncurrent")
362
363 st := New(Options{HomeDir: home, ReasonixHomeDir: reasonixHome, DisableBuiltins: true})
364 list := st.List()
365 current, ok := find(list, "current")
366 if !ok {
367 t.Fatal("Reasonix home skill should be discovered")
368 }
369 if current.Path != currentPath {
370 t.Fatalf("current skill path = %q, want Reasonix home path %q", current.Path, currentPath)
371 }
372 if _, ok := find(list, "old"); !ok {
373 t.Fatal("legacy ~/.reasonix skill should remain discoverable")
374 }
375
376 path, err := st.Create("created", ScopeGlobal)
377 if err != nil {
378 t.Fatalf("Create: %v", err)
379 }
380 want := filepath.Join(reasonixHome, SkillsDirname, "created", SkillFile)
381 if path != want {
382 t.Fatalf("created skill path = %q, want %q", path, want)
383 }
384 }
385
386 func TestNonSkillMarkdownInClaudeSkillRootsIgnored(t *testing.T) {
387 proj := t.TempDir()
388 writeSkill(t, proj, ".claude/skills/guide.md", "# Skill notes\n\nThis is documentation, not a skill.")
389 writeSkill(t, proj, ".claude/skills/notes.md", "---\ntitle: Notes\n---\n# Notes")
390 writeSkill(t, proj, ".claude/skills/real.md", "---\ndescription: real skill\n---\nbody")
391
392 var stderr bytes.Buffer
393 st := New(Options{HomeDir: t.TempDir(), ProjectRoot: proj, DisableBuiltins: true, Stderr: &stderr})
394 list := st.List()
395 if _, ok := find(list, "real"); !ok {
396 t.Fatal("real skill should be discovered")
397 }
398 for _, name := range []string{"guide", "notes"} {
399 if _, ok := find(list, name); ok {
400 t.Errorf("non-skill markdown %q should not be listed", name)
401 }
402 }
403 if got := stderr.String(); got != "" {
404 t.Fatalf("non-skill markdown should not warn during List, got %q", got)
405 }
406
407 for _, name := range []string{"guide", "notes"} {
408 stderr.Reset()
409 if _, ok := st.Read(name); ok {
410 t.Errorf("non-skill markdown %q should not be readable as a skill", name)
411 }
412 if got := stderr.String(); got != "" {
413 t.Errorf("non-skill markdown %q should not warn during Read, got %q", name, got)
414 }
415 }
416 }
417
418 func TestSkillLikeFlatClaudeMarkdownWithoutDescriptionWarns(t *testing.T) {
419 home := t.TempDir()
420 writeSkill(t, home, ".claude/skills/named.md", "---\nname: renamed\n---\nbody")
421
422 var stderr bytes.Buffer
423 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
424 list := st.List()
425 if _, ok := find(list, "renamed"); !ok {
426 t.Fatal("skill-like flat Claude markdown should still load")
427 }
428 if got := stderr.String(); !strings.Contains(got, "has no description") {
429 t.Fatalf("skill-like flat Claude markdown without description should warn, got %q", got)
430 }
431 }
432
433 func TestBlankDescriptionFlatClaudeMarkdownIsSkillLike(t *testing.T) {
434 for _, tc := range []struct {
435 name string
436 content string
437 }{
438 {name: "blank", content: "---\ndescription:\n---\nbody"},
439 {name: "quoted", content: "---\ndescription: \"\"\n---\nbody"},
440 } {
441 t.Run(tc.name, func(t *testing.T) {
442 home := t.TempDir()
443 writeSkill(t, home, ".claude/skills/"+tc.name+".md", tc.content)
444
445 var stderr bytes.Buffer
446 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
447 if _, ok := find(st.List(), tc.name); !ok {
448 t.Fatal("blank description marker should still list flat Claude markdown as skill-like")
449 }
450 if got := stderr.String(); !strings.Contains(got, "has no description") {
451 t.Fatalf("blank description listed skill should warn, got %q", got)
452 }
453
454 stderr.Reset()
455 sk, ok := st.Read(tc.name)
456 if !ok {
457 t.Fatal("blank description marker should still make flat Claude markdown skill-like")
458 }
459 if sk.Description != "" {
460 t.Fatalf("description should stay empty, got %q", sk.Description)
461 }
462 if got := stderr.String(); !strings.Contains(got, "has no description") {
463 t.Fatalf("blank description skill should warn, got %q", got)
464 }
465 })
466 }
467 }
468
469 func TestRunAsOnlyFlatClaudeMarkdownIsSkillLike(t *testing.T) {
470 home := t.TempDir()
471 writeSkill(t, home, ".claude/skills/sub.md", "---\nrunAs: subagent\n---\nbody")
472
473 var stderr bytes.Buffer
474 st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
475 sk, ok := st.Read("sub")
476 if !ok {
477 t.Fatal("runAs-only Claude markdown should be treated as skill-like")
478 }
479 if sk.RunAs != RunSubagent {
480 t.Fatalf("runAs should be parsed despite frontmatter key casing, got %s", sk.RunAs)
481 }
482 if got := stderr.String(); !strings.Contains(got, "has no description") {
483 t.Fatalf("runAs-only Claude markdown without description should warn, got %q", got)
484 }
485 }
486
487 func TestExcludedPathsHideConventionRoots(t *testing.T) {
488 home := t.TempDir()
489 writeSkill(t, home, ".reasonix/skills/keep.md", "---\ndescription: keep\n---\nb")
490 writeSkill(t, home, ".agents/skills/noisy.md", "---\ndescription: noisy\n---\nb")
491 excluded := filepath.Join(home, ".agents", "skills")
492 st := New(Options{HomeDir: home, ExcludedPaths: []string{excluded}, DisableBuiltins: true})
493
494 if _, ok := find(st.List(), "keep"); !ok {
495 t.Fatal("non-excluded skill should be listed")
496 }
497 if _, ok := find(st.List(), "noisy"); ok {
498 t.Fatal("excluded skill should not be listed")
499 }
500 for _, root := range st.Roots() {
501 if config.CanonicalSkillPath(root.Dir) == config.CanonicalSkillPath(excluded) {
502 t.Fatalf("excluded root should be hidden from Roots: %+v", st.Roots())
503 }
504 }
505 }
506
507 func TestFrontmatterFields(t *testing.T) {
508 home := t.TempDir()
509 writeSkill(t, home, ".reasonix/skills/sub.md",
510 "---\ndescription: a sub\nrunAs: subagent\nallowed-tools: read_file, grep\nmodel: deepseek-pro\nread-only: true\n---\nbody")
511 writeSkill(t, home, ".reasonix/skills/fork.md", "---\ndescription: f\ncontext: fork\n---\nbody")
512 writeSkill(t, home, ".reasonix/skills/plain.md", "---\ndescription: p\n---\nbody")
513
514 st := New(Options{HomeDir: home, DisableBuiltins: true})
515 sub, _ := st.Read("sub")
516 if sub.RunAs != RunSubagent {
517 t.Error("runAs: subagent not parsed")
518 }
519 if len(sub.AllowedTools) != 2 || sub.AllowedTools[0] != "read_file" || sub.AllowedTools[1] != "grep" {
520 t.Errorf("allowed-tools mis-parsed: %v", sub.AllowedTools)
521 }
522 if sub.Model != "deepseek-pro" {
523 t.Errorf("model mis-parsed: %q", sub.Model)
524 }
525 if !sub.ReadOnly {
526 t.Error("read-only: true not parsed")
527 }
528 if fork, _ := st.Read("fork"); fork.RunAs != RunSubagent {
529 t.Error("context: fork should imply subagent")
530 }
531 if plain, _ := st.Read("plain"); plain.RunAs != RunInline {
532 t.Error("default runAs should be inline")
533 }
534 if plain, _ := st.Read("plain"); plain.ReadOnly {
535 t.Error("read-only should default to false when the key is absent")
536 }
537 }
538
539 func TestReferencesInlined(t *testing.T) {
540 home := t.TempDir()
541 writeSkill(t, home, ".reasonix/skills/withrefs/SKILL.md", "---\ndescription: r\n---\nmain body")
542 writeSkill(t, home, ".reasonix/skills/withrefs/references/b.md", "second ref")
543 writeSkill(t, home, ".reasonix/skills/withrefs/references/a.md", "first ref")
544
545 st := New(Options{HomeDir: home, DisableBuiltins: true})
546 sk, ok := st.Read("withrefs")
547 if !ok {
548 t.Fatal("skill not found")
549 }
550 if !strings.Contains(sk.Body, "main body") {
551 t.Error("main body missing")
552 }
553 // references are appended sorted by filename: a before b.
554 ai := strings.Index(sk.Body, "## Reference: a")
555 bi := strings.Index(sk.Body, "## Reference: b")
556 if ai < 0 || bi < 0 || ai > bi {
557 t.Errorf("references not appended in sorted order: a=%d b=%d", ai, bi)
558 }
559 if !strings.Contains(sk.Body, "first ref") || !strings.Contains(sk.Body, "second ref") {
560 t.Error("reference contents missing")
561 }
562 }
563
564 func TestScriptsAppended(t *testing.T) {
565 home := filepath.Join(t.TempDir(), "home with spaces")
566 writeSkill(t, home, ".reasonix/skills/withscripts/SKILL.md", "---\ndescription: r\n---\nmain body")
567 writeScript(t, home, ".reasonix/skills/withscripts/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')")
568 writeScript(t, home, ".reasonix/skills/withscripts/scripts/deploy.sh", "#!/usr/bin/env bash\necho ok")
569
570 st := New(Options{HomeDir: home, DisableBuiltins: true})
571 sk, ok := st.Read("withscripts")
572 if !ok {
573 t.Fatal("skill not found")
574 }
575 if !strings.Contains(sk.Body, "main body") {
576 t.Error("main body missing")
577 }
578 if !strings.Contains(sk.Body, "## Scripts") {
579 t.Error("scripts section missing")
580 }
581 if !strings.Contains(sk.Body, "lint.py") || !strings.Contains(sk.Body, "deploy.sh") {
582 t.Error("script paths missing from body")
583 }
584 if !strings.Contains(sk.Body, "main body\n\n## Scripts") {
585 t.Errorf("scripts section should be separated from the original body:\n%s", sk.Body)
586 }
587 if !strings.Contains(sk.Body, "quote the path if it contains spaces") {
588 t.Error("scripts guidance should mention quoting paths with spaces")
589 }
590 }
591
592 func TestScriptsStayOutOfSkillIndex(t *testing.T) {
593 home := t.TempDir()
594 writeSkill(t, home, ".reasonix/skills/withscripts/SKILL.md", "---\ndescription: cache-safe script skill\n---\nmain body")
595 writeScript(t, home, ".reasonix/skills/withscripts/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')")
596
597 st := New(Options{HomeDir: home, DisableBuiltins: true})
598 sk, ok := st.Read("withscripts")
599 if !ok {
600 t.Fatal("skill not found")
601 }
602 if !strings.Contains(sk.Body, "## Scripts") || !strings.Contains(sk.Body, "lint.py") {
603 t.Fatal("test setup expected scripts in the on-demand skill body")
604 }
605
606 index := ApplyIndex("BASE", []Skill{sk})
607 if !strings.Contains(index, "withscripts") || !strings.Contains(index, "cache-safe script skill") {
608 t.Fatalf("skill index missing name/description:\n%s", index)
609 }
610 for _, forbidden := range []string{"## Scripts", "lint.py", filepath.Join("scripts", "lint.py")} {
611 if strings.Contains(index, forbidden) {
612 t.Fatalf("skill index should not include on-demand script listing %q:\n%s", forbidden, index)
613 }
614 }
615 }
616
617 func TestNoScriptsWhenDirAbsent(t *testing.T) {
618 home := t.TempDir()
619 writeSkill(t, home, ".reasonix/skills/noscripts/SKILL.md", "---\ndescription: r\n---\nmain body")
620 st := New(Options{HomeDir: home, DisableBuiltins: true})
621 sk, ok := st.Read("noscripts")
622 if !ok {
623 t.Fatal("skill not found")
624 }
625 if strings.Contains(sk.Body, "## Scripts") {
626 t.Error("should not have scripts section when scripts/ missing")
627 }
628 }
629
630 func TestFlatSkillNoScripts(t *testing.T) {
631 home := t.TempDir()
632 writeSkill(t, home, ".reasonix/skills/flat.md", "---\ndescription: r\n---\nmain body")
633 st := New(Options{HomeDir: home, DisableBuiltins: true})
634 sk, ok := st.Read("flat")
635 if !ok {
636 t.Fatal("skill not found")
637 }
638 if strings.Contains(sk.Body, "## Scripts") {
639 t.Error("flat skill should not have scripts section")
640 }
641 }
642
643 func TestScriptsFilteredByExt(t *testing.T) {
644 home := t.TempDir()
645 writeSkill(t, home, ".reasonix/skills/scriptscheck/SKILL.md", "---\ndescription: t\n---\nbody")
646 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/lint.py", "#!/usr/bin/env python3\nprint('ok')\n")
647 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/.hidden.py", "")
648 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/readme.md", "# readme")
649 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/deploy", "#!/bin/sh\necho ok")
650 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/legacy.p", "print 'ok'\n")
651 writeScript(t, home, ".reasonix/skills/scriptscheck/scripts/.gitkeep", "")
652
653 st := New(Options{HomeDir: home, DisableBuiltins: true})
654 sk, ok := st.Read("scriptscheck")
655 if !ok {
656 t.Fatal("skill not found")
657 }
658 body := sk.Body
659 // lint.py should be listed (recognized .py extension)
660 if !strings.Contains(body, "lint.py") {
661 t.Error("lint.py should be listed (recognized .py extension)")
662 }
663 // deploy (no extension) should be listed (bare executable)
664 if !strings.Contains(body, "deploy") {
665 t.Error("deploy (no extension) should be listed as bare executable")
666 }
667 // .hidden.py should NOT be listed (hidden file)
668 if strings.Contains(body, ".hidden.py") {
669 t.Error("hidden files should NOT be listed")
670 }
671 // readme.md should NOT be listed (documentation, not a script)
672 if strings.Contains(body, "readme.md") {
673 t.Error("non-script extensions should NOT be listed")
674 }
675 if strings.Contains(body, "legacy.p") {
676 t.Error("partial extension matches should NOT be listed")
677 }
678 // .gitkeep should NOT be listed (hidden file)
679 if strings.Contains(body, ".gitkeep") {
680 t.Error(".gitkeep should NOT be listed")
681 }
682 }
683
684 func TestBuiltinInitIsInlineSkill(t *testing.T) {
685 // /init must resolve to a built-in inline skill (the model-driven AGENTS.md
686 // bootstrap), present even with no project/user skills on disk.
687 st := New(Options{HomeDir: t.TempDir()})
688 sk, ok := st.Read("init")
689 if !ok {
690 t.Fatal("built-in init skill not found")
691 }
692 if sk.Scope != ScopeBuiltin || sk.RunAs != RunInline {
693 t.Errorf("init should be a builtin inline skill, got scope=%s runAs=%s", sk.Scope, sk.RunAs)
694 }
695 if _, listed := find(st.List(), "init"); !listed {
696 t.Error("init should appear in List() so it reaches the slash menu")
697 }
698 }
699
700 func TestBuiltinSubagentSkillsDeclareAllowedTools(t *testing.T) {
701 st := New(Options{HomeDir: t.TempDir()})
702 cases := map[string][]string{
703 "explore": {"read_file", "ls", "glob", "grep", "code_index"},
704 "research": {"read_file", "ls", "glob", "grep", "code_index", "web_fetch"},
705 "review": {"read_file", "ls", "glob", "grep", "code_index", "bash", "use_capability"},
706 "security-review": {"read_file", "ls", "glob", "grep", "code_index", "bash", "use_capability"},
707 }
708 for name, want := range cases {
709 sk, ok := st.Read(name)
710 if !ok {
711 t.Fatalf("built-in %s skill not found", name)
712 }
713 if sk.RunAs != RunSubagent {
714 t.Fatalf("%s RunAs = %s, want subagent", name, sk.RunAs)
715 }
716 if !sameStrings(sk.AllowedTools, want) {
717 t.Errorf("%s AllowedTools = %v, want %v", name, sk.AllowedTools, want)
718 }
719 for _, meta := range []string{"task", "run_skill", "install_skill", "install_source", "explore", "research", "review", "security_review"} {
720 if containsString(sk.AllowedTools, meta) {
721 t.Errorf("%s AllowedTools should not include meta-tool %q: %v", name, meta, sk.AllowedTools)
722 }
723 }
724 }
725 }
726
727 func TestBuiltinsPresentAndOverridable(t *testing.T) {
728 st := New(Options{HomeDir: t.TempDir()})
729 if _, ok := find(st.List(), "explore"); !ok {
730 t.Error("built-in explore should be present")
731 }
732 // A user file named after a built-in overrides it.
733 home := t.TempDir()
734 writeSkill(t, home, ".reasonix/skills/explore.md", "---\ndescription: mine\nrunAs: inline\n---\nbody")
735 st2 := New(Options{HomeDir: home})
736 ex, _ := st2.Read("explore")
737 if ex.Scope == ScopeBuiltin || ex.Description != "mine" {
738 t.Errorf("user explore should override builtin: scope=%s desc=%q", ex.Scope, ex.Description)
739 }
740 }
741
742 func TestInstallCapabilityBuiltinIsInlineWithExpectedMetadata(t *testing.T) {
743 st := New(Options{HomeDir: t.TempDir()})
744 sk, ok := st.Read("install-capability")
745 if !ok {
746 t.Fatal("install-capability builtin skill must be registered")
747 }
748 if sk.Scope != ScopeBuiltin {
749 t.Errorf("install-capability scope = %s, want builtin", sk.Scope)
750 }
751 if sk.RunAs != RunInline {
752 t.Errorf("install-capability runAs = %s, want inline (it folds into the parent turn)", sk.RunAs)
753 }
754 if !strings.Contains(sk.Description, "install_source") {
755 t.Errorf("description should mention install_source, got %q", sk.Description)
756 }
757 if !strings.Contains(sk.Description, "uninstall") {
758 t.Errorf("description should advertise op=uninstall, got %q", sk.Description)
759 }
760 if !strings.Contains(sk.Body, "riskLevel") {
761 t.Error("body should mention the per-action riskLevel field so the model reads it")
762 }
763 if !strings.Contains(sk.Body, "planId") {
764 t.Error("body should mention the planId echo requirement on apply=true")
765 }
766 }
767
768 func TestAutoResearchIsNotSeparateBuiltinSkill(t *testing.T) {
769 st := New(Options{HomeDir: t.TempDir()})
770 if _, listed := find(st.List(), "auto-research"); listed {
771 t.Error("auto-research should be a Goal strategy, not a separate builtin skill")
772 }
773 if _, ok := st.Read("auto-research"); ok {
774 t.Error("auto-research should not be readable as a standalone builtin skill")
775 }
776 }
777
778 func TestDisabledSkillsAreFilteredFromListAndRead(t *testing.T) {
779 home := t.TempDir()
780 writeSkill(t, home, ".reasonix/skills/active.md", "---\ndescription: active\n---\nbody")
781 writeSkill(t, home, ".reasonix/skills/hidden.md", "---\ndescription: hidden\n---\nbody")
782
783 st := New(Options{HomeDir: home, DisabledNames: []string{"hidden", "review"}})
784 if _, ok := find(st.List(), "active"); !ok {
785 t.Fatal("active skill should be listed")
786 }
787 if _, ok := find(st.List(), "hidden"); ok {
788 t.Fatal("disabled file skill should not be listed")
789 }
790 if _, ok := st.Read("hidden"); ok {
791 t.Fatal("disabled file skill should not be readable")
792 }
793 if _, ok := find(st.List(), "review"); ok {
794 t.Fatal("disabled builtin skill should not be listed")
795 }
796 if _, ok := st.Read("review"); ok {
797 t.Fatal("disabled builtin skill should not be readable")
798 }
799 }
800
801 func sameStrings(a, b []string) bool {
802 if len(a) != len(b) {
803 return false
804 }
805 for i := range a {
806 if a[i] != b[i] {
807 return false
808 }
809 }
810 return true
811 }
812
813 func containsString(ss []string, want string) bool {
814 for _, s := range ss {
815 if s == want {
816 return true
817 }
818 }
819 return false
820 }
821
822 func TestInvalidNamesSkipped(t *testing.T) {
823 home := t.TempDir()
824 writeSkill(t, home, ".reasonix/skills/bad name.md", "---\ndescription: x\n---\nb") // space → invalid
825 st := New(Options{HomeDir: home, DisableBuiltins: true})
826 if len(st.List()) != 0 {
827 t.Errorf("invalid-named skill should be skipped, got %d", len(st.List()))
828 }
829 }
830
831 func TestSymlinkedDirAndFile(t *testing.T) {
832 if runtime.GOOS == "windows" {
833 t.Skip("symlink creation needs privilege on Windows")
834 }
835 home := t.TempDir()
836 target := t.TempDir()
837 // real skill dir + flat file living outside the skills root
838 writeSkill(t, target, "realdir/SKILL.md", "---\ndescription: linked dir\n---\nb")
839 writeSkill(t, target, "realflat.md", "---\ndescription: linked flat\n---\nb")
840
841 skillsRoot := filepath.Join(home, ".reasonix", "skills")
842 if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
843 t.Fatal(err)
844 }
845 if err := os.Symlink(filepath.Join(target, "realdir"), filepath.Join(skillsRoot, "linkeddir")); err != nil {
846 t.Fatal(err)
847 }
848 if err := os.Symlink(filepath.Join(target, "realflat.md"), filepath.Join(skillsRoot, "linkedflat.md")); err != nil {
849 t.Fatal(err)
850 }
851
852 st := New(Options{HomeDir: home, DisableBuiltins: true})
853 list := st.List()
854 if _, ok := find(list, "linkeddir"); !ok {
855 t.Error("symlinked skill directory not discovered")
856 }
857 if _, ok := find(list, "linkedflat"); !ok {
858 t.Error("symlinked flat skill file not discovered")
859 }
860 // broken symlink is skipped, not fatal.
861 if err := os.Symlink(filepath.Join(target, "does-not-exist"), filepath.Join(skillsRoot, "broken")); err != nil {
862 t.Fatal(err)
863 }
864 if _, ok := find(st.List(), "broken"); ok {
865 t.Error("broken symlink should not yield a skill")
866 }
867 }
868
869 type fakeDirEntry struct {
870 name string
871 isDir bool
872 typ os.FileMode
873 }
874
875 func (f fakeDirEntry) Name() string { return f.name }
876 func (f fakeDirEntry) IsDir() bool { return f.isDir }
877 func (f fakeDirEntry) Type() os.FileMode { return f.typ }
878 func (f fakeDirEntry) Info() (os.FileInfo, error) {
879 return fakeFileInfo{name: f.name, mode: f.typ}, nil
880 }
881
882 type fakeFileInfo struct {
883 name string
884 mode os.FileMode
885 }
886
887 func (f fakeFileInfo) Name() string { return f.name }
888 func (f fakeFileInfo) Size() int64 { return 0 }
889 func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
890 func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
891 func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() }
892 func (f fakeFileInfo) Sys() any { return nil }
893
894 func TestIrregularDirectoryEntryFollowsTarget(t *testing.T) {
895 home := t.TempDir()
896 root := filepath.Join(home, ".agents", "skills")
897 writeSkill(t, root, "linkedpack/SKILL.md", "---\ndescription: linked pack\n---\nbody")
898 writeSkill(t, root, "collection/nested.md", "---\ndescription: nested\n---\nbody")
899
900 st := New(Options{HomeDir: home, DisableBuiltins: true})
901 linkedPack := fakeDirEntry{name: "linkedpack", typ: os.ModeIrregular}
902 if sk, ok := st.readEntry(root, ScopeGlobal, false, linkedPack); !ok || sk.Name != "linkedpack" {
903 t.Fatalf("irregular directory-layout entry should follow target, got %+v ok=%v", sk, ok)
904 }
905 collection := fakeDirEntry{name: "collection", typ: os.ModeIrregular}
906 if !st.canScanChildDir(root, collection) {
907 t.Fatal("irregular directory entry should be scannable when its target is a directory")
908 }
909 }
910
911 func TestApplyIndex(t *testing.T) {
912 if got := ApplyIndex("BASE", nil); got != "BASE" {
913 t.Errorf("empty skills should leave base unchanged, got %q", got)
914 }
915 skills := []Skill{
916 {Name: "alpha", Description: "the alpha", RunAs: RunInline},
917 {Name: "beta", Description: "the beta", RunAs: RunSubagent},
918 }
919 out := ApplyIndex("BASE", skills)
920 if !strings.HasPrefix(out, "BASE\n\n# Skills") {
921 t.Error("index should append after the base")
922 }
923 if !strings.Contains(out, "- alpha — the alpha") {
924 t.Errorf("inline skill line missing: %s", out)
925 }
926 if !strings.Contains(out, "- beta [🧬 subagent] — the beta") {
927 t.Errorf("subagent tag missing: %s", out)
928 }
929 }
930
931 func TestApplyIndexMandatesInlineButRestrainsSubagent(t *testing.T) {
932 out := ApplyIndex("BASE", []Skill{{Name: "alpha", Description: "the alpha", RunAs: RunInline}})
933
934 if !strings.Contains(out, "inline) skill is even plausibly relevant") ||
935 !strings.Contains(out, "invoke it before continuing") {
936 t.Errorf("inline skills should be mandatory on plausible relevance:\n%s", out)
937 }
938 if !strings.Contains(out, "not on weak relevance") {
939 t.Errorf("subagent skills should stay judgment-based, not mandatory:\n%s", out)
940 }
941 }
942
943 func TestReadOnlyIndexBlockPointsAtReadOnlySkill(t *testing.T) {
944 out := ReadOnlyIndexBlock([]Skill{{Name: "beta", Description: "the beta", RunAs: RunSubagent}})
945 if !strings.Contains(out, "read_only_skill") {
946 t.Fatalf("read-only index should name read_only_skill:\n%s", out)
947 }
948 if strings.Contains(out, "Call `run_skill") {
949 t.Fatalf("read-only index should not tell the model to call run_skill:\n%s", out)
950 }
951 }
952
953 func TestSkillRoutingMetadataParsesButStaysOutOfIndex(t *testing.T) {
954 home := t.TempDir()
955 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")
956 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("router")
957 if !ok {
958 t.Fatal("skill not loaded")
959 }
960 if got := strings.Join(sk.Triggers, ","); got != "code review,检查代码" {
961 t.Fatalf("Triggers = %q", got)
962 }
963 if got := strings.Join(sk.NegativeTriggers, ","); got != "explain only" {
964 t.Fatalf("NegativeTriggers = %q", got)
965 }
966 if sk.AutoUse != "prefer" || !sk.NeedsFreshData || sk.Cost != "low" {
967 t.Fatalf("routing metadata = auto:%q fresh:%v cost:%q", sk.AutoUse, sk.NeedsFreshData, sk.Cost)
968 }
969 if got := strings.Join(sk.Requires, ","); got != "mcp-server:github,mcp-tool:github/search_issues" {
970 t.Fatalf("Requires = %q", got)
971 }
972 if got := strings.Join(sk.Profiles, ","); got != "delivery,balanced,economy" {
973 t.Fatalf("Profiles = %q (invalid values should be dropped)", got)
974 }
975 if got := strings.Join(sk.InvalidProfiles, ","); got != "invalid" {
976 t.Fatalf("InvalidProfiles = %q (rejected values must be preserved for doctor)", got)
977 }
978 index := IndexBlock([]Skill{sk})
979 for _, forbidden := range []string{"code review", "auto-use", "needs-fresh-data", "mcp-server:github", "profiles"} {
980 if strings.Contains(index, forbidden) {
981 t.Fatalf("routing metadata leaked into index (%q):\n%s", forbidden, index)
982 }
983 }
984 }
985
986 func TestColorFrontmatterParses(t *testing.T) {
987 home := t.TempDir()
988 writeSkill(t, home, ".reasonix/skills/tagged.md", "---\ndescription: has a color\ncolor: amber\n---\nbody")
989 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("tagged")
990 if !ok {
991 t.Fatal("skill not loaded")
992 }
993 if sk.Color != "amber" {
994 t.Fatalf("Color = %q, want amber", sk.Color)
995 }
996 }
997
998 func TestInvocationDefaultsToAutoForExistingSkills(t *testing.T) {
999 home := t.TempDir()
1000 writeSkill(t, home, ".reasonix/skills/plain.md", "---\ndescription: no invocation field\n---\nbody")
1001 sk, ok := New(Options{HomeDir: home, DisableBuiltins: true}).Read("plain")
1002 if !ok {
1003 t.Fatal("skill not loaded")
1004 }
1005 if sk.Invocation != "auto" {
1006 t.Fatalf("Invocation = %q, want auto (default)", sk.Invocation)
1007 }
1008 if sk.Color != "" {
1009 t.Fatalf("Color = %q, want empty for a file with no color: key", sk.Color)
1010 }
1011 }
1012
1013 func TestManualInvocationSkillExcludedFromIndex(t *testing.T) {
1014 home := t.TempDir()
1015 writeSkill(t, home, ".reasonix/skills/private-agent.md", "---\ndescription: my private subagent\nrunAs: subagent\ninvocation: manual\n---\nbody")
1016 writeSkill(t, home, ".reasonix/skills/public-agent.md", "---\ndescription: a discoverable subagent\nrunAs: subagent\n---\nbody")
1017 store := New(Options{HomeDir: home, DisableBuiltins: true})
1018 private, ok := store.Read("private-agent")
1019 if !ok {
1020 t.Fatal("private-agent not loaded")
1021 }
1022 if private.Invocation != "manual" {
1023 t.Fatalf("Invocation = %q, want manual", private.Invocation)
1024 }
1025 public, ok := store.Read("public-agent")
1026 if !ok {
1027 t.Fatal("public-agent not loaded")
1028 }
1029
1030 index := IndexBlock([]Skill{private, public})
1031 if strings.Contains(index, "private-agent") {
1032 t.Fatalf("manual-invocation skill leaked into index:\n%s", index)
1033 }
1034 if !strings.Contains(index, "public-agent") {
1035 t.Fatalf("auto-invocation skill missing from index:\n%s", index)
1036 }
1037
1038 // A read-only index built from only manual-invocation skills must render
1039 // as empty, not a header wrapped around nothing.
1040 if got := IndexBlock([]Skill{private}); got != "" {
1041 t.Fatalf("IndexBlock of only manual-invocation skills = %q, want empty", got)
1042 }
1043 }
1044
1045 func TestApplyIndexTruncates(t *testing.T) {
1046 var skills []Skill
1047 for i := 0; i < 200; i++ {
1048 skills = append(skills, Skill{Name: "skill" + strings.Repeat("x", 20), Description: strings.Repeat("d", 50)})
1049 }
1050 out := ApplyIndex("BASE", skills)
1051 if !strings.Contains(out, "truncated") {
1052 t.Error("oversized index should be truncated")
1053 }
1054 }
1055
1056 func TestIndexLineClipsGraphemeClusters(t *testing.T) {
1057 cluster := "👨‍👩‍👧‍👦"
1058 got := clipRunes("a"+cluster+"bc", 3)
1059 want := "a" + cluster + "…"
1060 if got != want {
1061 t.Fatalf("clipRunes() = %q, want %q", got, want)
1062 }
1063 }
1064
1065 func TestCreateRefusesOverwrite(t *testing.T) {
1066 home := t.TempDir()
1067 st := New(Options{HomeDir: home, DisableBuiltins: true})
1068 path, err := st.Create("mine", ScopeGlobal)
1069 if err != nil {
1070 t.Fatalf("create: %v", err)
1071 }
1072 if !strings.HasSuffix(path, filepath.Join(".reasonix", "skills", "mine", SkillFile)) {
1073 t.Errorf("unexpected path %q", path)
1074 }
1075 if _, err := st.Create("mine", ScopeGlobal); err == nil {
1076 t.Error("second create should refuse to overwrite")
1077 }
1078
1079 writeSkill(t, home, ".reasonix/skills/legacy.md", "---\ndescription: legacy\n---\nbody")
1080 if _, err := st.Create("legacy", ScopeGlobal); err == nil {
1081 t.Error("create should refuse to shadow an existing legacy flat skill")
1082 }
1083 }
1084
1084 lines GO