返回 DeepSeek-Reasonix
mutations.go
根目录 / internal / skill / mutations.go
1 package skill
2
3 import (
4 "fmt"
5
6 "os"
7
8 "path/filepath"
9
10 "strings"
11
12 "reasonix/internal/fileutil"
13 )
14
15 func (s *Store) Create(name string, scope Scope) (string, error) {
16 return s.CreateWithContent(name, scope, stubBody(name))
17 }
18
19 // CreateWithContent writes caller-supplied file contents as a canonical
20 // <name>/SKILL.md skill, refusing to clobber an existing directory-layout or
21 // legacy flat skill of the same name. Returns the written path.
22 func (s *Store) CreateWithContent(name string, scope Scope, content string) (string, error) {
23 if !IsValidName(name) {
24 return "", fmt.Errorf("invalid skill name %q — use letters, digits, '_', '-', '.'", name)
25 }
26 var root string
27 switch scope {
28 case ScopeProject:
29 if s.projectRoot == "" {
30 return "", fmt.Errorf("project scope requires a workspace — run from a project directory, or use global scope")
31 }
32 root = filepath.Join(s.projectRoot, ".reasonix", SkillsDirname)
33 default:
34 root = s.globalSkillsRoot()
35 }
36 flat := filepath.Join(root, name+".md")
37 folder := filepath.Join(root, name, SkillFile)
38 if _, err := os.Stat(flat); err == nil {
39 return "", fmt.Errorf("skill %q already exists at %s", name, flat)
40 }
41 if _, err := os.Stat(folder); err == nil {
42 return "", fmt.Errorf("skill %q already exists at %s", name, folder)
43 }
44 if err := os.MkdirAll(filepath.Dir(folder), 0o755); err != nil {
45 return "", err
46 }
47 // O_EXCL so a concurrent create (or an existing file) is reported, not clobbered.
48 f, err := os.OpenFile(folder, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
49 if err != nil {
50 if os.IsExist(err) {
51 return "", fmt.Errorf("skill %q already exists at %s", name, folder)
52 }
53 return "", err
54 }
55 if _, err := f.WriteString(content); err != nil {
56 _ = f.Close()
57 return "", err
58 }
59 if err := f.Close(); err != nil {
60 return "", err
61 }
62 s.Invalidate("create")
63 return folder, nil
64 }
65
66 // UpdateContent overwrites an existing user-authored skill's file contents in
67 // place. Refuses built-ins and a scope mismatch, mirroring Delete's rules —
68 // see Delete for why a mismatch must refuse rather than silently target the
69 // wrong file.
70 func (s *Store) UpdateContent(name string, scope Scope, content string) error {
71 if scope == ScopeBuiltin {
72 return fmt.Errorf("skill %q is built in and cannot be edited", name)
73 }
74 sk, ok := s.Read(name)
75 if !ok {
76 return fmt.Errorf("skill %q not found", name)
77 }
78 if sk.Scope != scope {
79 return fmt.Errorf("skill %q resolves at scope %q, not %q — refusing to edit a different scope's file", name, sk.Scope, scope)
80 }
81 if sk.Path == "" || sk.Path == "(builtin)" {
82 return fmt.Errorf("skill %q has no file to update", name)
83 }
84 if err := s.validateMutablePath(sk.Path, scope); err != nil {
85 return fmt.Errorf("skill %q cannot be edited: %w", name, err)
86 }
87 info, err := os.Stat(sk.Path)
88 if err != nil {
89 return err
90 }
91 if err := fileutil.AtomicWriteFile(sk.Path, []byte(content), info.Mode().Perm()); err != nil {
92 return err
93 }
94 s.Invalidate("update")
95 return nil
96 }
97
98 // validateMutablePath rejects writes through linked files or directories. Skill
99 // discovery intentionally follows symlinks for read compatibility, but editing
100 // one must never replace content outside the configured scope root.
101 func (s *Store) validateMutablePath(path string, scope Scope) error {
102 absPath, err := filepath.Abs(path)
103 if err != nil {
104 return err
105 }
106 for _, root := range s.roots() {
107 if root.Scope != scope {
108 continue
109 }
110 absRoot, err := filepath.Abs(root.Dir)
111 if err != nil {
112 continue
113 }
114 rel, err := filepath.Rel(absRoot, absPath)
115 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
116 continue
117 }
118 current := absRoot
119 parts := []string{"."}
120 if rel != "." {
121 parts = strings.Split(rel, string(filepath.Separator))
122 }
123 for _, part := range parts {
124 if part != "." {
125 current = filepath.Join(current, part)
126 }
127 info, err := os.Lstat(current)
128 if err != nil {
129 return err
130 }
131 if info.Mode()&os.ModeSymlink != 0 {
132 return fmt.Errorf("path uses symbolic link %s", current)
133 }
134 }
135 realRoot, err := filepath.EvalSymlinks(absRoot)
136 if err != nil {
137 return err
138 }
139 realPath, err := filepath.EvalSymlinks(absPath)
140 if err != nil {
141 return err
142 }
143 realRel, err := filepath.Rel(realRoot, realPath)
144 if err != nil || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
145 return fmt.Errorf("resolved path is outside scope root %s", absRoot)
146 }
147 return nil
148 }
149 return fmt.Errorf("path is outside configured %s skill roots", scope)
150 }
151
152 // Delete removes a user-authored skill. Refuses built-ins (no file backs
153 // them) and refuses when the resolved skill's actual scope doesn't match the
154 // requested one — e.g. a project-scope delete for a name that only resolves
155 // at global scope, which would otherwise silently no-op against the wrong
156 // file while a same-named project-scope shadow kept showing up in List().
157 func (s *Store) Delete(name string, scope Scope) error {
158 if scope == ScopeBuiltin {
159 return fmt.Errorf("skill %q is built in and cannot be deleted", name)
160 }
161 sk, ok := s.Read(name)
162 if !ok {
163 return fmt.Errorf("skill %q not found", name)
164 }
165 if sk.Scope != scope {
166 return fmt.Errorf("skill %q resolves at scope %q, not %q — refusing to delete a different scope's file", name, sk.Scope, scope)
167 }
168 if sk.Path == "" || sk.Path == "(builtin)" {
169 return fmt.Errorf("skill %q has no file to delete", name)
170 }
171 if filepath.Base(sk.Path) == SkillFile {
172 if err := os.RemoveAll(filepath.Dir(sk.Path)); err != nil {
173 return err
174 }
175 s.Invalidate("delete")
176 return nil
177 }
178 if err := os.Remove(sk.Path); err != nil {
179 return err
180 }
181 s.Invalidate("delete")
182 return nil
183 }
184
185 func (s *Store) globalSkillsRoot() string {
186 if s.reasonixHomeDir != "" {
187 return filepath.Join(s.reasonixHomeDir, SkillsDirname)
188 }
189 return filepath.Join(s.homeDir, ".reasonix", SkillsDirname)
190 }
191
192 // loadBodyWithReferences appends a directory-layout skill's sibling
193 // references/*.md files to its body (Anthropic Skills compatibility), so depth
194 // material is available without on-demand resolution. Flat skills have no
195 // references dir and are returned unchanged.
196
196 lines GO