| 1 | package skill |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/frontmatter" |
| 11 | ) |
| 12 | |
| 13 | var reservedSubagentSlashNames = map[string]bool{ |
| 14 | "new": true, "clear": true, "compact": true, "model": true, "provider": true, |
| 15 | "effort": true, "memory": true, "migrate": true, "migration": true, |
| 16 | "goal": true, "remember": true, "mcp": true, "hooks": true, "plugin": true, "plugins": true, |
| 17 | "theme": true, "skill": true, "skills": true, "reload-cmd": true, "tree": true, |
| 18 | "branch": true, "switch": true, "rewind": true, "plan-exec": true, "prometheus": true, |
| 19 | "resume": true, "rename": true, "todo": true, "verbose": true, "mouse": true, |
| 20 | "sandbox": true, "work-mode": true, "profile": true, |
| 21 | "reasoning-language": true, "paste-image": true, "output-style": true, |
| 22 | "output-styles": true, "diff-fold": true, "language": true, "help": true, |
| 23 | "quit": true, "exit": true, "copy": true, "export": true, "forget": true, |
| 24 | } |
| 25 | |
| 26 | // ValidateSubagentProfileName protects the shared slash namespace used by |
| 27 | // built-in verbs, custom commands, MCP prompts, Skills, and Subagents. |
| 28 | func ValidateSubagentProfileName(name string, occupied []string) error { |
| 29 | if !IsValidName(name) { |
| 30 | return fmt.Errorf("invalid name %q — use letters, digits, '_', '-', '.'", name) |
| 31 | } |
| 32 | key := config.SkillNameKey(name) |
| 33 | if reservedSubagentSlashNames[key] || strings.HasPrefix(key, "mcp__") { |
| 34 | return fmt.Errorf("%q is reserved by the slash command namespace", name) |
| 35 | } |
| 36 | for _, existing := range occupied { |
| 37 | if config.SkillNameKey(existing) == key { |
| 38 | return fmt.Errorf("%q already exists in the slash command namespace", name) |
| 39 | } |
| 40 | } |
| 41 | return nil |
| 42 | } |
| 43 | |
| 44 | // subagentProfileManagedKeys are the frontmatter keys the desktop and CLI |
| 45 | // profile editors can round-trip without changing execution semantics. |
| 46 | var subagentProfileManagedKeys = map[string]bool{ |
| 47 | "name": true, "description": true, "color": true, "invocation": true, |
| 48 | "runas": true, "model": true, "effort": true, "read-only": true, "allowed-tools": true, |
| 49 | } |
| 50 | |
| 51 | // ValidateEditableSubagentProfile verifies that a loaded skill is a manual |
| 52 | // subagent profile whose backing file can be losslessly rewritten by the |
| 53 | // profile editors. Rich hand-authored skills remain owned by the Skills |
| 54 | // workflow so read-only, routing, references, and scripts are never dropped. |
| 55 | func ValidateEditableSubagentProfile(sk Skill) error { |
| 56 | if sk.RunAs != RunSubagent { |
| 57 | return fmt.Errorf("%q is not a subagent profile (runAs is not \"subagent\") — manage it as a skill file instead", sk.Name) |
| 58 | } |
| 59 | if sk.Invocation != "manual" { |
| 60 | return fmt.Errorf("%q was not created by a subagent profile editor (invocation is not \"manual\") — manage it as a skill file instead", sk.Name) |
| 61 | } |
| 62 | if sk.Scope != ScopeProject && sk.Scope != ScopeGlobal { |
| 63 | return fmt.Errorf("%q is scope %q and cannot be edited as a project/global subagent profile", sk.Name, sk.Scope) |
| 64 | } |
| 65 | if sk.Path == "" || sk.Path == "(builtin)" { |
| 66 | return fmt.Errorf("%q has no editable file", sk.Name) |
| 67 | } |
| 68 | raw, err := os.ReadFile(sk.Path) |
| 69 | if err != nil { |
| 70 | return err |
| 71 | } |
| 72 | fm, _ := frontmatter.Split(string(raw)) |
| 73 | for key := range fm { |
| 74 | if !subagentProfileManagedKeys[key] { |
| 75 | return fmt.Errorf("%q carries frontmatter this editor does not manage (%s) and would silently drop — edit it as a skill file instead", sk.Name, key) |
| 76 | } |
| 77 | } |
| 78 | if filepath.Base(sk.Path) == SkillFile { |
| 79 | dir := filepath.Dir(sk.Path) |
| 80 | for _, sibling := range []string{"references", "scripts"} { |
| 81 | if info, err := os.Stat(filepath.Join(dir, sibling)); err == nil && info.IsDir() { |
| 82 | return fmt.Errorf("%q has a %s/ directory whose content is folded into the body at load time — editing here would bake it into the main file; edit it as a skill file instead", sk.Name, sibling) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | return nil |
| 87 | } |
| 88 |