package installsource import ( "bytes" "fmt" "io" "io/fs" "os" "path/filepath" "sort" "strings" "reasonix/internal/config" fileencoding "reasonix/internal/fileutil/encoding" "reasonix/internal/frontmatter" "reasonix/internal/skill" ) const ( maxSkillScanDepth = 3 maxSkillScanCount = 200 maxSkillCopyBytes = 20 << 20 ) // skillAction builds the DTO for a single-skill install (copy or link). func (t *installSourceTool) skillAction(req request, cand skillCandidate, mode string) action { scope := t.installScope(req, "skill", cand.SourcePath) actionName := "copy_skill" if mode == "link" { actionName = "link_skill" } canonical, _ := t.skillCanonicalPath(cand.Name, scope) root, _ := t.skillInstallRoot(scope) a := action{ Kind: "skill", Action: actionName, Name: cand.Name, Source: cand.SourcePath, Target: canonical, Scope: scope, Mode: mode, ConfigPath: t.configPath(scope), Skills: []string{cand.Name}, SkillCount: 1, Layout: "canonical_dir", InstallRoot: root, CanonicalPath: canonical, skill: cand, } a.RiskLevel, a.RiskReasons = skillActionRisk(mode, cand) if mode == "link" && !isLinkTargetSafe(cand.SourcePath, t.home, t.root) { a.RiskLevel = RiskHigh a.RiskReasons = append(a.RiskReasons, "link target is an absolute path outside the project or home root") } return a } // skillActionRisk explains the risk budget for a skill install. The model // uses this to decide whether to call apply=true directly or to ask first. func skillActionRisk(mode string, cand skillCandidate) (RiskLevel, []string) { reasons := []string{} level := RiskLow if mode == "link" { // Link installs a pointer into a foreign tree; an untrusted source // could expose anything at runtime, so we always classify as medium // at minimum. level = RiskMedium reasons = append(reasons, "symlink to a foreign path") } if cand.IsDir && mode == "copy" { if level == RiskLow { level = RiskMedium } reasons = append(reasons, "copy of a directory") } return level, reasons } // skillRootAction builds the DTO for registering a whole skill directory. func (t *installSourceTool) skillRootAction(req request, path string, names []string) action { scope := t.installScope(req, "skill", path) return action{ Kind: "skill", Action: "register_skill_root", Name: "", Source: path, Target: path, ConfigPath: t.configPath(scope), Scope: scope, Mode: "register", Skills: names, SkillCount: len(names), Layout: "registered_root", InstallRoot: path, RiskLevel: RiskMedium, RiskReasons: []string{"adds a new skill root to the active config"}, } } func (t *installSourceTool) skillInstallRoot(scope string) (string, error) { if scope == "global" { if t.reasonixHome == "" { return "", newErr(ErrSourceUnreadable, "global skill install requires a Reasonix home directory") } return filepath.Join(t.reasonixHome, skill.SkillsDirname), nil } return filepath.Join(t.root, ".reasonix", skill.SkillsDirname), nil } // skillCanonicalPath computes the canonical install destination: // /skills//SKILL.md. Flat .md remains readable for // backward compatibility, but the installer no longer writes it by default. func (t *installSourceTool) skillCanonicalPath(name, scope string) (string, error) { if !config.IsValidSkillName(name) { return "", newErr(ErrInvalidManifest, "invalid skill name %q", name) } root, err := t.skillInstallRoot(scope) if err != nil { return "", err } return filepath.Join(root, name, skill.SkillFile), nil } // verifySkill confirms the installed skill is reachable through a freshly // built Store. It is the post-install guard against partial failures. func (t *installSourceTool) verifySkill(scope, name string, act *action) error { custom := []string(nil) if scope == "project" { cfg := config.LoadForEdit(filepath.Join(t.root, "reasonix.toml")) custom = cfg.SkillCustomPaths() } else { cfg := config.LoadForEdit(t.configPath(scope)) custom = cfg.SkillCustomPaths() } var stderr bytes.Buffer store := skill.New(skill.Options{HomeDir: t.home, ReasonixHomeDir: t.reasonixHome, ProjectRoot: t.root, CustomPaths: custom, DisableBuiltins: true, Stderr: &stderr}) sk, ok := store.Read(name) if !ok { return newErr(ErrSourceUnreadable, "skill %q is installed but not discoverable", name) } act.Discoverable = true act.CanonicalPath = sk.Path for _, listed := range store.List() { if listed.Name == name { act.Indexed = true break } } if strings.TrimSpace(sk.Description) == "" { 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)) } if msg := strings.TrimSpace(stderr.String()); msg != "" { act.Warnings = append(act.Warnings, msg) } return nil } // skillConflictTargets returns every existing layout that would collide with // installing name: the canonical directory, its SKILL.md, and the legacy flat // file. The apply step checks all of them so new canonical installs don't // silently shadow older .md installs. func (t *installSourceTool) skillConflictTargets(name, scope string) ([]string, error) { canonical, err := t.skillCanonicalPath(name, scope) if err != nil { return nil, err } dir := filepath.Dir(canonical) return []string{dir, canonical, filepath.Join(filepath.Dir(dir), name+".md")}, nil } // readSkillFile reads and validates a single skill file. The fallback name // is used when the frontmatter does not declare one. func readSkillFile(path, fallbackName string, strict bool) (skillCandidate, error) { b, err := fileencoding.ReadFileUTF8(path) if err != nil { return skillCandidate{}, err } cand, err := parseSkillContent(string(b), fallbackName, path, strict) if err != nil { return skillCandidate{}, err } cand.SourcePath = path return cand, nil } // parseSkillContent validates the YAML frontmatter of a skill file. With // strict=true (the default) we require a `name` and a `description`; with // strict=false a missing description is allowed and the body may be empty — // useful for installing raw files the user already trusts. func parseSkillContent(content, fallbackName, source string, strict bool) (skillCandidate, error) { bom := "\uFEFF" content = strings.TrimPrefix(strings.ReplaceAll(content, "\r\n", "\n"), bom) var meta struct { Name string `yaml:"name"` Description string `yaml:"description"` } body, err := frontmatter.Decode(content, &meta, frontmatter.DecodeOptions{}) if err != nil { return skillCandidate{}, newErr(ErrInvalidManifest, "skill frontmatter at %s is invalid YAML: %v", source, err) } fm, _ := frontmatter.Split(content) name := strings.TrimSpace(fallbackName) if v := strings.TrimSpace(meta.Name); v != "" { name = v } else if v := strings.TrimSpace(fm["name"]); v != "" { name = v } if !config.IsValidSkillName(name) { return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an invalid name", name, source) } desc := collapseSpaces(meta.Description) if desc == "" { desc = collapseSpaces(fm["description"]) } if strict { if desc == "" { return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s is missing description frontmatter", name, source) } if strings.TrimSpace(body) == "" { return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an empty body", name, source) } } return skillCandidate{Name: name, Description: desc, SourcePath: source, Content: content}, nil } // scanSkillRoot enumerates skills under a directory with bounded recursion: // any /SKILL.md is a directory-layout skill, and any .md is a // flat compatibility skill. RootPath records the containing directory that must // be registered for the runtime Store to discover that candidate. func scanSkillRoot(root string, strict bool) ([]skillCandidate, error) { var out []skillCandidate err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if path == root { return nil } rel, err := filepath.Rel(root, path) if err != nil { return err } depth := pathDepth(rel) if d.IsDir() { if depth > maxSkillScanDepth { return filepath.SkipDir } if strings.EqualFold(d.Name(), ".git") { return filepath.SkipDir } return nil } if len(out) >= maxSkillScanCount { return newErr(ErrInvalidManifest, "too many skills under %s; limit is %d", root, maxSkillScanCount) } if !d.Type().IsRegular() || !strings.EqualFold(filepath.Ext(d.Name()), ".md") { return nil } parent := filepath.Dir(path) if strings.EqualFold(d.Name(), skill.SkillFile) { containerDepth := pathDepth(mustRel(root, parent)) if containerDepth > maxSkillScanDepth { return nil } if parent == root { return nil } cand, err := readSkillFile(path, filepath.Base(parent), strict) if err == nil { cand.IsDir = true cand.SourcePath = parent cand.RootPath = filepath.Dir(parent) out = append(out, cand) } return nil } containerDepth := pathDepth(mustRel(root, parent)) if containerDepth > maxSkillScanDepth { return nil } stem := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())) cand, err := readSkillFile(path, stem, strict) if err == nil { cand.RootPath = parent out = append(out, cand) } return nil }) if err != nil { return nil, err } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } func pathDepth(rel string) int { if rel == "." || rel == "" { return 0 } return len(strings.Split(filepath.Clean(rel), string(filepath.Separator))) } func mustRel(base, path string) string { rel, err := filepath.Rel(base, path) if err != nil { return "." } return rel } // copyDir walks src and writes a parallel tree under dst. O_EXCL refuses to // overwrite a leaf; a leftover partial tree is left on disk for the user to // inspect (we never rm -rf). A symlink is materialized as its resolved // content only when it points at a regular file INSIDE src — discovery // (skills and commands) follows links, so dropping an in-tree alias would // install less than the plan counted. Everything else a link can be — // escaping the tree, broken, or a directory — is skipped, never followed: // the plugin-package apply path verifies capability counts after the copy, // so a skipped link fails the install closed instead of silently shrinking it. func copyDir(src, dst string) error { var copied int64 srcRoot := src if resolved, err := filepath.EvalSymlinks(src); err == nil { srcRoot = resolved } return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if d.IsDir() { if strings.EqualFold(d.Name(), ".git") { return filepath.SkipDir } return os.MkdirAll(target, 0o755) } source := path if !d.Type().IsRegular() { if d.Type()&os.ModeSymlink == 0 { return nil } resolved, err := filepath.EvalSymlinks(path) if err != nil { return nil // broken link } relToRoot, err := filepath.Rel(srcRoot, resolved) if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) { return nil // escapes the tree — never follow } info, err := os.Stat(resolved) if err != nil || !info.Mode().IsRegular() { return nil // directory or otherwise unmaterializable link } source = resolved } info, err := os.Stat(source) if err != nil { return err } copied += info.Size() if copied > maxSkillCopyBytes { return newErr(ErrInvalidManifest, "skill directory exceeds %d bytes", maxSkillCopyBytes) } in, err := os.Open(source) if err != nil { return err } defer in.Close() if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }) } func writeNewFile(path string, content []byte) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { return err } defer f.Close() if _, err := f.Write(content); err != nil { return err } return nil }