返回 DeepSeek-Reasonix
skill.go
1 package installsource
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12
13 "reasonix/internal/config"
14 fileencoding "reasonix/internal/fileutil/encoding"
15 "reasonix/internal/frontmatter"
16 "reasonix/internal/skill"
17 )
18
19 const (
20 maxSkillScanDepth = 3
21 maxSkillScanCount = 200
22 maxSkillCopyBytes = 20 << 20
23 )
24
25 // skillAction builds the DTO for a single-skill install (copy or link).
26 func (t *installSourceTool) skillAction(req request, cand skillCandidate, mode string) action {
27 scope := t.installScope(req, "skill", cand.SourcePath)
28 actionName := "copy_skill"
29 if mode == "link" {
30 actionName = "link_skill"
31 }
32 canonical, _ := t.skillCanonicalPath(cand.Name, scope)
33 root, _ := t.skillInstallRoot(scope)
34 a := action{
35 Kind: "skill",
36 Action: actionName,
37 Name: cand.Name,
38 Source: cand.SourcePath,
39 Target: canonical,
40 Scope: scope,
41 Mode: mode,
42 ConfigPath: t.configPath(scope),
43 Skills: []string{cand.Name},
44 SkillCount: 1,
45 Layout: "canonical_dir",
46 InstallRoot: root,
47 CanonicalPath: canonical,
48 skill: cand,
49 }
50 a.RiskLevel, a.RiskReasons = skillActionRisk(mode, cand)
51 if mode == "link" && !isLinkTargetSafe(cand.SourcePath, t.home, t.root) {
52 a.RiskLevel = RiskHigh
53 a.RiskReasons = append(a.RiskReasons, "link target is an absolute path outside the project or home root")
54 }
55 return a
56 }
57
58 // skillActionRisk explains the risk budget for a skill install. The model
59 // uses this to decide whether to call apply=true directly or to ask first.
60 func skillActionRisk(mode string, cand skillCandidate) (RiskLevel, []string) {
61 reasons := []string{}
62 level := RiskLow
63 if mode == "link" {
64 // Link installs a pointer into a foreign tree; an untrusted source
65 // could expose anything at runtime, so we always classify as medium
66 // at minimum.
67 level = RiskMedium
68 reasons = append(reasons, "symlink to a foreign path")
69 }
70 if cand.IsDir && mode == "copy" {
71 if level == RiskLow {
72 level = RiskMedium
73 }
74 reasons = append(reasons, "copy of a directory")
75 }
76 return level, reasons
77 }
78
79 // skillRootAction builds the DTO for registering a whole skill directory.
80 func (t *installSourceTool) skillRootAction(req request, path string, names []string) action {
81 scope := t.installScope(req, "skill", path)
82 return action{
83 Kind: "skill",
84 Action: "register_skill_root",
85 Name: "",
86 Source: path,
87 Target: path,
88 ConfigPath: t.configPath(scope),
89 Scope: scope,
90 Mode: "register",
91 Skills: names,
92 SkillCount: len(names),
93 Layout: "registered_root",
94 InstallRoot: path,
95 RiskLevel: RiskMedium,
96 RiskReasons: []string{"adds a new skill root to the active config"},
97 }
98 }
99
100 func (t *installSourceTool) skillInstallRoot(scope string) (string, error) {
101 if scope == "global" {
102 if t.reasonixHome == "" {
103 return "", newErr(ErrSourceUnreadable, "global skill install requires a Reasonix home directory")
104 }
105 return filepath.Join(t.reasonixHome, skill.SkillsDirname), nil
106 }
107 return filepath.Join(t.root, ".reasonix", skill.SkillsDirname), nil
108 }
109
110 // skillCanonicalPath computes the canonical install destination:
111 // <scope>/skills/<skill-name>/SKILL.md. Flat <name>.md remains readable for
112 // backward compatibility, but the installer no longer writes it by default.
113 func (t *installSourceTool) skillCanonicalPath(name, scope string) (string, error) {
114 if !config.IsValidSkillName(name) {
115 return "", newErr(ErrInvalidManifest, "invalid skill name %q", name)
116 }
117 root, err := t.skillInstallRoot(scope)
118 if err != nil {
119 return "", err
120 }
121 return filepath.Join(root, name, skill.SkillFile), nil
122 }
123
124 // verifySkill confirms the installed skill is reachable through a freshly
125 // built Store. It is the post-install guard against partial failures.
126 func (t *installSourceTool) verifySkill(scope, name string, act *action) error {
127 custom := []string(nil)
128 if scope == "project" {
129 cfg := config.LoadForEdit(filepath.Join(t.root, "reasonix.toml"))
130 custom = cfg.SkillCustomPaths()
131 } else {
132 cfg := config.LoadForEdit(t.configPath(scope))
133 custom = cfg.SkillCustomPaths()
134 }
135 var stderr bytes.Buffer
136 store := skill.New(skill.Options{HomeDir: t.home, ReasonixHomeDir: t.reasonixHome, ProjectRoot: t.root, CustomPaths: custom, DisableBuiltins: true, Stderr: &stderr})
137 sk, ok := store.Read(name)
138 if !ok {
139 return newErr(ErrSourceUnreadable, "skill %q is installed but not discoverable", name)
140 }
141 act.Discoverable = true
142 act.CanonicalPath = sk.Path
143 for _, listed := range store.List() {
144 if listed.Name == name {
145 act.Indexed = true
146 break
147 }
148 }
149 if strings.TrimSpace(sk.Description) == "" {
150 act.Warnings = append(act.Warnings, fmt.Sprintf("skill %q has no description frontmatter; it is installed but the skills index will use a placeholder", name))
151 }
152 if msg := strings.TrimSpace(stderr.String()); msg != "" {
153 act.Warnings = append(act.Warnings, msg)
154 }
155 return nil
156 }
157
158 // skillConflictTargets returns every existing layout that would collide with
159 // installing name: the canonical directory, its SKILL.md, and the legacy flat
160 // file. The apply step checks all of them so new canonical installs don't
161 // silently shadow older <name>.md installs.
162 func (t *installSourceTool) skillConflictTargets(name, scope string) ([]string, error) {
163 canonical, err := t.skillCanonicalPath(name, scope)
164 if err != nil {
165 return nil, err
166 }
167 dir := filepath.Dir(canonical)
168 return []string{dir, canonical, filepath.Join(filepath.Dir(dir), name+".md")}, nil
169 }
170
171 // readSkillFile reads and validates a single skill file. The fallback name
172 // is used when the frontmatter does not declare one.
173 func readSkillFile(path, fallbackName string, strict bool) (skillCandidate, error) {
174 b, err := fileencoding.ReadFileUTF8(path)
175 if err != nil {
176 return skillCandidate{}, err
177 }
178 cand, err := parseSkillContent(string(b), fallbackName, path, strict)
179 if err != nil {
180 return skillCandidate{}, err
181 }
182 cand.SourcePath = path
183 return cand, nil
184 }
185
186 // parseSkillContent validates the YAML frontmatter of a skill file. With
187 // strict=true (the default) we require a `name` and a `description`; with
188 // strict=false a missing description is allowed and the body may be empty —
189 // useful for installing raw files the user already trusts.
190 func parseSkillContent(content, fallbackName, source string, strict bool) (skillCandidate, error) {
191 bom := "\uFEFF"
192 content = strings.TrimPrefix(strings.ReplaceAll(content, "\r\n", "\n"), bom)
193 var meta struct {
194 Name string `yaml:"name"`
195 Description string `yaml:"description"`
196 }
197 body, err := frontmatter.Decode(content, &meta, frontmatter.DecodeOptions{})
198 if err != nil {
199 return skillCandidate{}, newErr(ErrInvalidManifest, "skill frontmatter at %s is invalid YAML: %v", source, err)
200 }
201 fm, _ := frontmatter.Split(content)
202 name := strings.TrimSpace(fallbackName)
203 if v := strings.TrimSpace(meta.Name); v != "" {
204 name = v
205 } else if v := strings.TrimSpace(fm["name"]); v != "" {
206 name = v
207 }
208 if !config.IsValidSkillName(name) {
209 return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an invalid name", name, source)
210 }
211 desc := collapseSpaces(meta.Description)
212 if desc == "" {
213 desc = collapseSpaces(fm["description"])
214 }
215 if strict {
216 if desc == "" {
217 return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s is missing description frontmatter", name, source)
218 }
219 if strings.TrimSpace(body) == "" {
220 return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an empty body", name, source)
221 }
222 }
223 return skillCandidate{Name: name, Description: desc, SourcePath: source, Content: content}, nil
224 }
225
226 // scanSkillRoot enumerates skills under a directory with bounded recursion:
227 // any <name>/SKILL.md is a directory-layout skill, and any <name>.md is a
228 // flat compatibility skill. RootPath records the containing directory that must
229 // be registered for the runtime Store to discover that candidate.
230 func scanSkillRoot(root string, strict bool) ([]skillCandidate, error) {
231 var out []skillCandidate
232 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
233 if err != nil {
234 return err
235 }
236 if path == root {
237 return nil
238 }
239 rel, err := filepath.Rel(root, path)
240 if err != nil {
241 return err
242 }
243 depth := pathDepth(rel)
244 if d.IsDir() {
245 if depth > maxSkillScanDepth {
246 return filepath.SkipDir
247 }
248 if strings.EqualFold(d.Name(), ".git") {
249 return filepath.SkipDir
250 }
251 return nil
252 }
253 if len(out) >= maxSkillScanCount {
254 return newErr(ErrInvalidManifest, "too many skills under %s; limit is %d", root, maxSkillScanCount)
255 }
256 if !d.Type().IsRegular() || !strings.EqualFold(filepath.Ext(d.Name()), ".md") {
257 return nil
258 }
259 parent := filepath.Dir(path)
260 if strings.EqualFold(d.Name(), skill.SkillFile) {
261 containerDepth := pathDepth(mustRel(root, parent))
262 if containerDepth > maxSkillScanDepth {
263 return nil
264 }
265 if parent == root {
266 return nil
267 }
268 cand, err := readSkillFile(path, filepath.Base(parent), strict)
269 if err == nil {
270 cand.IsDir = true
271 cand.SourcePath = parent
272 cand.RootPath = filepath.Dir(parent)
273 out = append(out, cand)
274 }
275 return nil
276 }
277 containerDepth := pathDepth(mustRel(root, parent))
278 if containerDepth > maxSkillScanDepth {
279 return nil
280 }
281 stem := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
282 cand, err := readSkillFile(path, stem, strict)
283 if err == nil {
284 cand.RootPath = parent
285 out = append(out, cand)
286 }
287 return nil
288 })
289 if err != nil {
290 return nil, err
291 }
292 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
293 return out, nil
294 }
295
296 func pathDepth(rel string) int {
297 if rel == "." || rel == "" {
298 return 0
299 }
300 return len(strings.Split(filepath.Clean(rel), string(filepath.Separator)))
301 }
302
303 func mustRel(base, path string) string {
304 rel, err := filepath.Rel(base, path)
305 if err != nil {
306 return "."
307 }
308 return rel
309 }
310
311 // copyDir walks src and writes a parallel tree under dst. O_EXCL refuses to
312 // overwrite a leaf; a leftover partial tree is left on disk for the user to
313 // inspect (we never rm -rf). A symlink is materialized as its resolved
314 // content only when it points at a regular file INSIDE src — discovery
315 // (skills and commands) follows links, so dropping an in-tree alias would
316 // install less than the plan counted. Everything else a link can be —
317 // escaping the tree, broken, or a directory — is skipped, never followed:
318 // the plugin-package apply path verifies capability counts after the copy,
319 // so a skipped link fails the install closed instead of silently shrinking it.
320 func copyDir(src, dst string) error {
321 var copied int64
322 srcRoot := src
323 if resolved, err := filepath.EvalSymlinks(src); err == nil {
324 srcRoot = resolved
325 }
326 return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
327 if err != nil {
328 return err
329 }
330 rel, err := filepath.Rel(src, path)
331 if err != nil {
332 return err
333 }
334 target := filepath.Join(dst, rel)
335 if d.IsDir() {
336 if strings.EqualFold(d.Name(), ".git") {
337 return filepath.SkipDir
338 }
339 return os.MkdirAll(target, 0o755)
340 }
341 source := path
342 if !d.Type().IsRegular() {
343 if d.Type()&os.ModeSymlink == 0 {
344 return nil
345 }
346 resolved, err := filepath.EvalSymlinks(path)
347 if err != nil {
348 return nil // broken link
349 }
350 relToRoot, err := filepath.Rel(srcRoot, resolved)
351 if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
352 return nil // escapes the tree — never follow
353 }
354 info, err := os.Stat(resolved)
355 if err != nil || !info.Mode().IsRegular() {
356 return nil // directory or otherwise unmaterializable link
357 }
358 source = resolved
359 }
360 info, err := os.Stat(source)
361 if err != nil {
362 return err
363 }
364 copied += info.Size()
365 if copied > maxSkillCopyBytes {
366 return newErr(ErrInvalidManifest, "skill directory exceeds %d bytes", maxSkillCopyBytes)
367 }
368 in, err := os.Open(source)
369 if err != nil {
370 return err
371 }
372 defer in.Close()
373 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
374 return err
375 }
376 mode := os.FileMode(0o644)
377 if info.Mode().Perm()&0o111 != 0 {
378 // Plugin hook binaries and scripts must remain executable after copy.
379 // Normalize to ordinary executable permissions instead of carrying
380 // special source mode bits such as setuid/setgid into the install.
381 mode = 0o755
382 }
383 out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
384 if err != nil {
385 return err
386 }
387 defer out.Close()
388 _, err = io.Copy(out, in)
389 return err
390 })
391 }
392
393 func writeNewFile(path string, content []byte) error {
394 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
395 if err != nil {
396 return err
397 }
398 defer f.Close()
399 if _, err := f.Write(content); err != nil {
400 return err
401 }
402 return nil
403 }
404
404 lines GO