397 lines
12 KiB
Go
397 lines
12 KiB
Go
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:
|
|
// <scope>/skills/<skill-name>/SKILL.md. Flat <name>.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 <name>.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 <name>/SKILL.md is a directory-layout skill, and any <name>.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
|
|
}
|