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
This commit is contained in:
patriceckhart 2026-04-19 16:03:26 +02:00
parent e9ffc74442
commit fbad128c4c
5 changed files with 58 additions and 28 deletions

View file

@ -56,6 +56,14 @@ type Args struct {
// extra context biasing the model.
NoSkill bool
// WithSkills opts into loading user-installed skills from
// $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, and
// .agents/skills/. Without this flag only the built-in skills
// shipped with the zot binary are available, so a fresh install
// has a deterministic skill set regardless of what's lying
// around in the user's home directory.
WithSkills bool
ListModels bool
Help bool
Version bool
@ -151,6 +159,8 @@ func ParseArgs(in []string) (Args, error) {
a.NoExt = true
case "--no-skill", "--no-skills":
a.NoSkill = true
case "--with-skills", "--with-skill":
a.WithSkills = true
case "--reasoning":
v, err := want(&i, arg)
if err != nil {
@ -259,6 +269,10 @@ flags:
--no-skill skip skill discovery for this run,
including built-in skills (no skill
tool, no Available skills manifest)
--with-skills load user-installed skills from
$ZOT_HOME/skills/ + .zot/skills/ +
.claude/skills/ + .agents/skills/.
default: only built-in skills load
--max-steps N agent loop iteration cap (default 50)
--list-models print known models and exit

View file

@ -194,7 +194,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
)
if !args.NoSkill {
homeDir, _ := os.UserHomeDir()
discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir)
discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
if len(discovered) > 0 {
skillTool = skills.NewTool(discovered)
reg[skillTool.Name()] = skillTool

View file

@ -417,9 +417,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
// out built-in skills — they're hidden from user-facing
// surfaces because they're implementation detail; the
// model still sees them through the system-prompt
// manifest and the skill tool.
// manifest and the skill tool. User skills only appear
// when --with-skills is set; without it the picker shows
// nothing but the model still has the built-ins.
userHome, _ := os.UserHomeDir()
list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
return skills.VisibleSkills(list)
},
PersistModel: func(providerName, model string) {

View file

@ -83,19 +83,46 @@ func VisibleSkills(in []*Skill) []*Skill {
return out
}
// Discover walks every supported location, parses each SKILL.md, and
// returns the merged skill set. First-match-wins per name; the order
// matches the priority list in the package doc. Errors per skill are
// returned alongside the partial result so a single broken file
// doesn't suppress the rest.
// Discover returns the merged skill set. By default this is just
// the built-in skills compiled into the zot binary; user-installed
// SKILL.md files are NOT loaded unless includeUser is true. Users
// opt in via the `--with-skills` flag.
//
// Built-in skills (compiled into the zot binary) are added LAST so
// any user-installed skill with the same name shadows the built-in.
// That lets users customise the help text by dropping their own
// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
// First-match-wins per name; the order matches the priority list
// in the package doc (project-local before global before claude-
// compat before agents-compat, all before built-ins). That means a
// user-installed skill with the same name as a built-in shadows
// the built-in once includeUser is true.
//
// Errors per skill are returned alongside the partial result so a
// single broken file doesn't suppress the rest.
func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
var errs []error
seen := map[string]*Skill{}
if includeUser {
errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)
}
// Built-ins fill in any name the user didn't already provide
// (or every name, when includeUser is false).
for _, s := range loadBuiltins() {
if _, dup := seen[s.Name]; dup {
continue
}
seen[s.Name] = s
}
out := make([]*Skill, 0, len(seen))
for _, s := range seen {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, errs
}
// scanUserSkills walks the user-skill search dirs and populates
// `seen` with first-match-wins per name. Split out so Discover's
// includeUser=false path doesn't have to skip over a giant block.
func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error {
var errs []error
for _, loc := range searchDirs(zotHome, cwd, userHome) {
entries, err := os.ReadDir(loc.dir)
if err != nil {
@ -122,20 +149,7 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
seen[s.Name] = s
}
}
// Built-ins fill in any name the user didn't already provide.
for _, s := range loadBuiltins() {
if _, dup := seen[s.Name]; dup {
continue
}
seen[s.Name] = s
}
out := make([]*Skill, 0, len(seen))
for _, s := range seen {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, errs
return errs
}
// SystemPromptAddendum returns the text to append to the system

View file

@ -63,7 +63,7 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
// Unique skill in global only.
mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
skills, errs := Discover(zotHome, cwd, "")
skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
if len(errs) > 0 {
t.Fatalf("errs: %v", errs)
}