| 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 |