diff --git a/internal/agent/args.go b/internal/agent/args.go index 40fdd0e..b9b886c 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -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 diff --git a/internal/agent/build.go b/internal/agent/build.go index 3d1afed..e6fe7ba 100644 --- a/internal/agent/build.go +++ b/internal/agent/build.go @@ -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 diff --git a/internal/agent/cli.go b/internal/agent/cli.go index fdf0c7f..cf5d4e5 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -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) { diff --git a/internal/skills/skills.go b/internal/skills/skills.go index a779d41..857862d 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -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//. -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 diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 62485ca..9aca524 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -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) }