feat(skills): user skills now opt-in via --with-skills
Behaviour change: a fresh zot run now loads only the built-in skills compiled into the binary. User-installed SKILL.md files under $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, or .agents/skills/ stay dormant until the user explicitly opts in with --with-skills. Three discrete modes: zot built-ins only (default) zot --with-skills built-ins + user skills zot --no-skill nothing (no skill tool, no manifest) Rationale: a fresh install should have a deterministic skill set, regardless of what's already lying around in $ZOT_HOME from old experiments. Built-ins ship with the binary so they're auditable; user skills are loaded only when the user explicitly asks for them. Discover() gained an includeUser bool (was 3 args, now 4). The in-tree caller updated; the test that exercised the old signature gets includeUser=true so its existing assertions still hold. scanUserSkills() split out so the includeUser=false path is a cheap no-op. End-to-end verified live: default -> write-zot-extension (only) --with-skills -> write-zot-extension + code-review + test-fix --no-skill -> no skill tool, no manifest at all
- commit
- fbad128c4cda605cfc8261d71b97e3e421b84fe4
- author
- patriceckhart · · 2026-04-19 14:03 UTC
- parent
- e9ffc74
- signature
- unsigned
- refs
- tag: v0.0.32
5 files · +58 -28
M internal/agent/args.go +14 -0
| @@ -56,6 +56,14 @@ type Args struct { | |||
| 56 | 56 | // extra context biasing the model. | |
| 57 | 57 | NoSkill bool | |
| 58 | 58 | ||
| 59 | + | // WithSkills opts into loading user-installed skills from | |
| 60 | + | // $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, and | |
| 61 | + | // .agents/skills/. Without this flag only the built-in skills | |
| 62 | + | // shipped with the zot binary are available, so a fresh install | |
| 63 | + | // has a deterministic skill set regardless of what's lying | |
| 64 | + | // around in the user's home directory. | |
| 65 | + | WithSkills bool | |
| 66 | + | ||
| 59 | 67 | ListModels bool | |
| 60 | 68 | Help bool | |
| 61 | 69 | Version bool | |
| @@ -151,6 +159,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 151 | 159 | a.NoExt = true | |
| 152 | 160 | case "--no-skill", "--no-skills": | |
| 153 | 161 | a.NoSkill = true | |
| 162 | + | case "--with-skills", "--with-skill": | |
| 163 | + | a.WithSkills = true | |
| 154 | 164 | case "--reasoning": | |
| 155 | 165 | v, err := want(&i, arg) | |
| 156 | 166 | if err != nil { | |
| @@ -259,6 +269,10 @@ flags: | |||
| 259 | 269 | --no-skill skip skill discovery for this run, | |
| 260 | 270 | including built-in skills (no skill | |
| 261 | 271 | tool, no Available skills manifest) | |
| 272 | + | --with-skills load user-installed skills from | |
| 273 | + | $ZOT_HOME/skills/ + .zot/skills/ + | |
| 274 | + | .claude/skills/ + .agents/skills/. | |
| 275 | + | default: only built-in skills load | |
| 262 | 276 | ||
| 263 | 277 | --max-steps N agent loop iteration cap (default 50) | |
| 264 | 278 | --list-models print known models and exit | |
| @@ -56,6 +56,14 @@ type Args struct { | |||||
| 56 | // extra context biasing the model. | 56 | // extra context biasing the model. | ||
| 57 | NoSkill bool | 57 | NoSkill bool | ||
| 58 | 58 | ||||
| 59 | + | // WithSkills opts into loading user-installed skills from | |||
| 60 | + | // $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, and | |||
| 61 | + | // .agents/skills/. Without this flag only the built-in skills | |||
| 62 | + | // shipped with the zot binary are available, so a fresh install | |||
| 63 | + | // has a deterministic skill set regardless of what's lying | |||
| 64 | + | // around in the user's home directory. | |||
| 65 | + | WithSkills bool | |||
| 66 | + | ||||
| 59 | ListModels bool | 67 | ListModels bool | ||
| 60 | Help bool | 68 | Help bool | ||
| 61 | Version bool | 69 | Version bool | ||
| @@ -151,6 +159,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 151 | a.NoExt = true | 159 | a.NoExt = true | ||
| 152 | case "--no-skill", "--no-skills": | 160 | case "--no-skill", "--no-skills": | ||
| 153 | a.NoSkill = true | 161 | a.NoSkill = true | ||
| 162 | + | case "--with-skills", "--with-skill": | |||
| 163 | + | a.WithSkills = true | |||
| 154 | case "--reasoning": | 164 | case "--reasoning": | ||
| 155 | v, err := want(&i, arg) | 165 | v, err := want(&i, arg) | ||
| 156 | if err != nil { | 166 | if err != nil { | ||
| @@ -259,6 +269,10 @@ flags: | |||||
| 259 | --no-skill skip skill discovery for this run, | 269 | --no-skill skip skill discovery for this run, | ||
| 260 | including built-in skills (no skill | 270 | including built-in skills (no skill | ||
| 261 | tool, no Available skills manifest) | 271 | tool, no Available skills manifest) | ||
| 272 | + | --with-skills load user-installed skills from | |||
| 273 | + | $ZOT_HOME/skills/ + .zot/skills/ + | |||
| 274 | + | .claude/skills/ + .agents/skills/. | |||
| 275 | + | default: only built-in skills load | |||
| 262 | 276 | ||||
| 263 | --max-steps N agent loop iteration cap (default 50) | 277 | --max-steps N agent loop iteration cap (default 50) | ||
| 264 | --list-models print known models and exit | 278 | --list-models print known models and exit | ||
M internal/agent/build.go +1 -1
| @@ -194,7 +194,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 194 | 194 | ) | |
| 195 | 195 | if !args.NoSkill { | |
| 196 | 196 | homeDir, _ := os.UserHomeDir() | |
| 197 | - | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir) | |
| 197 | + | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) | |
| 198 | 198 | if len(discovered) > 0 { | |
| 199 | 199 | skillTool = skills.NewTool(discovered) | |
| 200 | 200 | reg[skillTool.Name()] = skillTool | |
| @@ -194,7 +194,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 194 | ) | 194 | ) | ||
| 195 | if !args.NoSkill { | 195 | if !args.NoSkill { | ||
| 196 | homeDir, _ := os.UserHomeDir() | 196 | homeDir, _ := os.UserHomeDir() | ||
| 197 | - | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir) | 197 | + | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) |
| 198 | if len(discovered) > 0 { | 198 | if len(discovered) > 0 { | ||
| 199 | skillTool = skills.NewTool(discovered) | 199 | skillTool = skills.NewTool(discovered) | ||
| 200 | reg[skillTool.Name()] = skillTool | 200 | reg[skillTool.Name()] = skillTool | ||
M internal/agent/cli.go +4 -2
| @@ -417,9 +417,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 417 | 417 | // out built-in skills — they're hidden from user-facing | |
| 418 | 418 | // surfaces because they're implementation detail; the | |
| 419 | 419 | // model still sees them through the system-prompt | |
| 420 | - | // manifest and the skill tool. | |
| 420 | + | // manifest and the skill tool. User skills only appear | |
| 421 | + | // when --with-skills is set; without it the picker shows | |
| 422 | + | // nothing but the model still has the built-ins. | |
| 421 | 423 | userHome, _ := os.UserHomeDir() | |
| 422 | - | list, _ := skills.Discover(ZotHome(), r.CWD, userHome) | |
| 424 | + | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) | |
| 423 | 425 | return skills.VisibleSkills(list) | |
| 424 | 426 | }, | |
| 425 | 427 | PersistModel: func(providerName, model string) { | |
| @@ -417,9 +417,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 417 | // out built-in skills — they're hidden from user-facing | 417 | // out built-in skills — they're hidden from user-facing | ||
| 418 | // surfaces because they're implementation detail; the | 418 | // surfaces because they're implementation detail; the | ||
| 419 | // model still sees them through the system-prompt | 419 | // model still sees them through the system-prompt | ||
| 420 | - | // manifest and the skill tool. | 420 | + | // manifest and the skill tool. User skills only appear |
| 421 | + | // when --with-skills is set; without it the picker shows | |||
| 422 | + | // nothing but the model still has the built-ins. | |||
| 421 | userHome, _ := os.UserHomeDir() | 423 | userHome, _ := os.UserHomeDir() | ||
| 422 | - | list, _ := skills.Discover(ZotHome(), r.CWD, userHome) | 424 | + | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) |
| 423 | return skills.VisibleSkills(list) | 425 | return skills.VisibleSkills(list) | ||
| 424 | }, | 426 | }, | ||
| 425 | PersistModel: func(providerName, model string) { | 427 | PersistModel: func(providerName, model string) { | ||
M internal/skills/skills.go +38 -24
| @@ -83,19 +83,46 @@ func VisibleSkills(in []*Skill) []*Skill { | |||
| 83 | 83 | return out | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | - | // Discover walks every supported location, parses each SKILL.md, and | |
| 87 | - | // returns the merged skill set. First-match-wins per name; the order | |
| 88 | - | // matches the priority list in the package doc. Errors per skill are | |
| 89 | - | // returned alongside the partial result so a single broken file | |
| 90 | - | // doesn't suppress the rest. | |
| 86 | + | // Discover returns the merged skill set. By default this is just | |
| 87 | + | // the built-in skills compiled into the zot binary; user-installed | |
| 88 | + | // SKILL.md files are NOT loaded unless includeUser is true. Users | |
| 89 | + | // opt in via the `--with-skills` flag. | |
| 91 | 90 | // | |
| 92 | - | // Built-in skills (compiled into the zot binary) are added LAST so | |
| 93 | - | // any user-installed skill with the same name shadows the built-in. | |
| 94 | - | // That lets users customise the help text by dropping their own | |
| 95 | - | // SKILL.md with the same name into $ZOT_HOME/skills/<name>/. | |
| 96 | - | func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |
| 91 | + | // First-match-wins per name; the order matches the priority list | |
| 92 | + | // in the package doc (project-local before global before claude- | |
| 93 | + | // compat before agents-compat, all before built-ins). That means a | |
| 94 | + | // user-installed skill with the same name as a built-in shadows | |
| 95 | + | // the built-in once includeUser is true. | |
| 96 | + | // | |
| 97 | + | // Errors per skill are returned alongside the partial result so a | |
| 98 | + | // single broken file doesn't suppress the rest. | |
| 99 | + | func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) { | |
| 97 | 100 | var errs []error | |
| 98 | 101 | seen := map[string]*Skill{} | |
| 102 | + | if includeUser { | |
| 103 | + | errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...) | |
| 104 | + | } | |
| 105 | + | // Built-ins fill in any name the user didn't already provide | |
| 106 | + | // (or every name, when includeUser is false). | |
| 107 | + | for _, s := range loadBuiltins() { | |
| 108 | + | if _, dup := seen[s.Name]; dup { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | seen[s.Name] = s | |
| 112 | + | } | |
| 113 | + | out := make([]*Skill, 0, len(seen)) | |
| 114 | + | for _, s := range seen { | |
| 115 | + | out = append(out, s) | |
| 116 | + | } | |
| 117 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 118 | + | return out, errs | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // scanUserSkills walks the user-skill search dirs and populates | |
| 122 | + | // `seen` with first-match-wins per name. Split out so Discover's | |
| 123 | + | // includeUser=false path doesn't have to skip over a giant block. | |
| 124 | + | func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error { | |
| 125 | + | var errs []error | |
| 99 | 126 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | |
| 100 | 127 | entries, err := os.ReadDir(loc.dir) | |
| 101 | 128 | if err != nil { | |
| @@ -122,20 +149,7 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |||
| 122 | 149 | seen[s.Name] = s | |
| 123 | 150 | } | |
| 124 | 151 | } | |
| 125 | - | // Built-ins fill in any name the user didn't already provide. | |
| 126 | - | for _, s := range loadBuiltins() { | |
| 127 | - | if _, dup := seen[s.Name]; dup { | |
| 128 | - | continue | |
| 129 | - | } | |
| 130 | - | seen[s.Name] = s | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | out := make([]*Skill, 0, len(seen)) | |
| 134 | - | for _, s := range seen { | |
| 135 | - | out = append(out, s) | |
| 136 | - | } | |
| 137 | - | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 138 | - | return out, errs | |
| 152 | + | return errs | |
| 139 | 153 | } | |
| 140 | 154 | ||
| 141 | 155 | // SystemPromptAddendum returns the text to append to the system | |
| @@ -83,19 +83,46 @@ func VisibleSkills(in []*Skill) []*Skill { | |||||
| 83 | return out | 83 | return out | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | - | // Discover walks every supported location, parses each SKILL.md, and | 86 | + | // Discover returns the merged skill set. By default this is just |
| 87 | - | // returns the merged skill set. First-match-wins per name; the order | 87 | + | // the built-in skills compiled into the zot binary; user-installed |
| 88 | - | // matches the priority list in the package doc. Errors per skill are | 88 | + | // SKILL.md files are NOT loaded unless includeUser is true. Users |
| 89 | - | // returned alongside the partial result so a single broken file | 89 | + | // opt in via the `--with-skills` flag. |
| 90 | - | // doesn't suppress the rest. | |||
| 91 | // | 90 | // | ||
| 92 | - | // Built-in skills (compiled into the zot binary) are added LAST so | 91 | + | // First-match-wins per name; the order matches the priority list |
| 93 | - | // any user-installed skill with the same name shadows the built-in. | 92 | + | // in the package doc (project-local before global before claude- |
| 94 | - | // That lets users customise the help text by dropping their own | 93 | + | // compat before agents-compat, all before built-ins). That means a |
| 95 | - | // SKILL.md with the same name into $ZOT_HOME/skills/<name>/. | 94 | + | // user-installed skill with the same name as a built-in shadows |
| 96 | - | func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | 95 | + | // the built-in once includeUser is true. |
| 96 | + | // | |||
| 97 | + | // Errors per skill are returned alongside the partial result so a | |||
| 98 | + | // single broken file doesn't suppress the rest. | |||
| 99 | + | func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) { | |||
| 97 | var errs []error | 100 | var errs []error | ||
| 98 | seen := map[string]*Skill{} | 101 | seen := map[string]*Skill{} | ||
| 102 | + | if includeUser { | |||
| 103 | + | errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...) | |||
| 104 | + | } | |||
| 105 | + | // Built-ins fill in any name the user didn't already provide | |||
| 106 | + | // (or every name, when includeUser is false). | |||
| 107 | + | for _, s := range loadBuiltins() { | |||
| 108 | + | if _, dup := seen[s.Name]; dup { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | seen[s.Name] = s | |||
| 112 | + | } | |||
| 113 | + | out := make([]*Skill, 0, len(seen)) | |||
| 114 | + | for _, s := range seen { | |||
| 115 | + | out = append(out, s) | |||
| 116 | + | } | |||
| 117 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |||
| 118 | + | return out, errs | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // scanUserSkills walks the user-skill search dirs and populates | |||
| 122 | + | // `seen` with first-match-wins per name. Split out so Discover's | |||
| 123 | + | // includeUser=false path doesn't have to skip over a giant block. | |||
| 124 | + | func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error { | |||
| 125 | + | var errs []error | |||
| 99 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | 126 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | ||
| 100 | entries, err := os.ReadDir(loc.dir) | 127 | entries, err := os.ReadDir(loc.dir) | ||
| 101 | if err != nil { | 128 | if err != nil { | ||
| @@ -122,20 +149,7 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |||||
| 122 | seen[s.Name] = s | 149 | seen[s.Name] = s | ||
| 123 | } | 150 | } | ||
| 124 | } | 151 | } | ||
| 125 | - | // Built-ins fill in any name the user didn't already provide. | 152 | + | return errs |
| 126 | - | for _, s := range loadBuiltins() { | |||
| 127 | - | if _, dup := seen[s.Name]; dup { | |||
| 128 | - | continue | |||
| 129 | - | } | |||
| 130 | - | seen[s.Name] = s | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | out := make([]*Skill, 0, len(seen)) | |||
| 134 | - | for _, s := range seen { | |||
| 135 | - | out = append(out, s) | |||
| 136 | - | } | |||
| 137 | - | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |||
| 138 | - | return out, errs | |||
| 139 | } | 153 | } | ||
| 140 | 154 | ||||
| 141 | // SystemPromptAddendum returns the text to append to the system | 155 | // SystemPromptAddendum returns the text to append to the system | ||
M internal/skills/skills_test.go +1 -1
| @@ -63,7 +63,7 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||
| 63 | 63 | // Unique skill in global only. | |
| 64 | 64 | mk(filepath.Join(zotHome, "skills"), "global-only", "from global") | |
| 65 | 65 | ||
| 66 | - | skills, errs := Discover(zotHome, cwd, "") | |
| 66 | + | skills, errs := Discover(zotHome, cwd, "", true /* includeUser */) | |
| 67 | 67 | if len(errs) > 0 { | |
| 68 | 68 | t.Fatalf("errs: %v", errs) | |
| 69 | 69 | } | |
| @@ -63,7 +63,7 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||||
| 63 | // Unique skill in global only. | 63 | // Unique skill in global only. | ||
| 64 | mk(filepath.Join(zotHome, "skills"), "global-only", "from global") | 64 | mk(filepath.Join(zotHome, "skills"), "global-only", "from global") | ||
| 65 | 65 | ||||
| 66 | - | skills, errs := Discover(zotHome, cwd, "") | 66 | + | skills, errs := Discover(zotHome, cwd, "", true /* includeUser */) |
| 67 | if len(errs) > 0 { | 67 | if len(errs) > 0 { | ||
| 68 | t.Fatalf("errs: %v", errs) | 68 | t.Fatalf("errs: %v", errs) | ||
| 69 | } | 69 | } | ||