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