detached fbad128

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 {
5656 // extra context biasing the model.
5757 NoSkill bool
5858
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+
5967 ListModels bool
6068 Help bool
6169 Version bool
@@ -151,6 +159,8 @@ func ParseArgs(in []string) (Args, error) {
151159 a.NoExt = true
152160 case "--no-skill", "--no-skills":
153161 a.NoSkill = true
162+ case "--with-skills", "--with-skill":
163+ a.WithSkills = true
154164 case "--reasoning":
155165 v, err := want(&i, arg)
156166 if err != nil {
@@ -259,6 +269,10 @@ flags:
259269 --no-skill skip skill discovery for this run,
260270 including built-in skills (no skill
261271 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
262276
263277 --max-steps N agent loop iteration cap (default 50)
264278 --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 bool57 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 bool67 ListModels bool
60 Help bool68 Help bool
61 Version bool69 Version bool
@@ -151,6 +159,8 @@ func ParseArgs(in []string) (Args, error) {
151 a.NoExt = true159 a.NoExt = true
152 case "--no-skill", "--no-skills":160 case "--no-skill", "--no-skills":
153 a.NoSkill = true161 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 skill270 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 exit278 --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) {
194194 )
195195 if !args.NoSkill {
196196 homeDir, _ := os.UserHomeDir()
197- discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir)
197+ discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
198198 if len(discovered) > 0 {
199199 skillTool = skills.NewTool(discovered)
200200 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()] = skillTool200 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 {
417417 // out built-in skills — they're hidden from user-facing
418418 // surfaces because they're implementation detail; the
419419 // 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.
421423 userHome, _ := os.UserHomeDir()
422- list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
424+ list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
423425 return skills.VisibleSkills(list)
424426 },
425427 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-facing417 // out built-in skills — they're hidden from user-facing
418 // surfaces because they're implementation detail; the418 // surfaces because they're implementation detail; the
419 // model still sees them through the system-prompt419 // 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 {
8383 return out
8484 }
8585
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.
9190 //
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) {
97100 var errs []error
98101 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
99126 for _, loc := range searchDirs(zotHome, cwd, userHome) {
100127 entries, err := os.ReadDir(loc.dir)
101128 if err != nil {
@@ -122,20 +149,7 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
122149 seen[s.Name] = s
123150 }
124151 }
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
139153 }
140154
141155 // SystemPromptAddendum returns the text to append to the system
@@ -83,19 +83,46 @@ func VisibleSkills(in []*Skill) []*Skill {
83 return out83 return out
84 }84 }
85 85
86-// Discover walks every supported location, parses each SKILL.md, and86+// Discover returns the merged skill set. By default this is just
87-// returns the merged skill set. First-match-wins per name; the order87+// the built-in skills compiled into the zot binary; user-installed
88-// matches the priority list in the package doc. Errors per skill are88+// SKILL.md files are NOT loaded unless includeUser is true. Users
89-// returned alongside the partial result so a single broken file89+// 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 so91+// 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 own93+// 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 []error100 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] = s149 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 system155 // 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) {
6363 // Unique skill in global only.
6464 mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
6565
66- skills, errs := Discover(zotHome, cwd, "")
66+ skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
6767 if len(errs) > 0 {
6868 t.Fatalf("errs: %v", errs)
6969 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close