Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ tagged. Until then, source builds report the version `dev`.
## [Unreleased]

### Added
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
Expand Down
18 changes: 14 additions & 4 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,24 @@ The full format spec (frontmatter fields, tool scopes, prompt conventions) is in

Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.

Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
Discovery roots (earlier wins on name collisions):

1. **Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)

A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.

```text
~/.local/share/zero/skills/
~/.local/share/zero/skills/ # primary (install/remove/lock target)
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md

~/.agents/skills/ # optional shared multi-agent root
shared-review/
SKILL.md
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

`SKILL.md` format:
Expand All @@ -126,9 +136,9 @@ description: Run the project's benchmark suite and summarize the deltas.
3. Report any regression > 5% with the function name and the previous value.
```

Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.

The `skill` core tool lets the agent load any discovered skill by name.
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).

## 4. Hooks

Expand Down
7 changes: 4 additions & 3 deletions docs/HOW_ZERO_WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -733,9 +733,10 @@ flowchart TD
- **Skills** are instruction packs the model can load on demand.
- **Specialists** are sub-agents callable through the `Task` tool.
- **MCP servers** contribute external tools.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap merges plugin skill
roots into the runtime skill tool and prompt-visible skill list; the base
`internal/skills` scanner still scans one root at a time.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
- **Hooks** can observe or block tool lifecycle events.

## End-to-End Data Flow
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/distribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
if name == "" {
return writeExecUsageError(stderr, "usage: zero skill info <name> [--json]")
}
info, ok := skills.Info(dir, name)
// Resolve across primary + ~/.agents/skills; lock metadata only from primary.
info, ok := skills.InfoFromRoots(dir, skills.GlobalRoots(dir), name)
if !ok {
return writeAppError(stderr, fmt.Sprintf("skill %q not found", name), exitUsage)
}
Expand Down
30 changes: 15 additions & 15 deletions internal/cli/plugin_activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (

// pluginActivation holds what plugin activation contributed to a bootstrap so the
// later dispatcher + skill wiring can consume it: the plugin hook definitions and
// the plugin skill search roots. The zero value (no plugins) is inert — the
// dispatcher gets no extra hooks and the skill tool keeps only the default dir.
// The embedded trustSkip reports whether the project plugin layer was dropped for
// an untrusted workspace, so the caller can fold it into the single combined
// trust notice.
// the plugin skill search roots. The zero value (no plugins) still overlays the
// multi-root skill tool (primary + ~/.agents/skills) so discovery is identical
// with or without plugins. The embedded trustSkip reports whether the project
// plugin layer was dropped for an untrusted workspace, so the caller can fold it
// into the single combined trust notice.
type pluginActivation struct {
hooks []hooks.Definition
skillRoots []string
Expand Down Expand Up @@ -52,6 +52,9 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
if err != nil {
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
// Still overlay multi-root discovery (primary + agents) so a plugin load
// failure cannot hide shared skills.
registry.Register(plugins.NewSkillTool(deps.skillsDir(), nil))
return pluginActivation{trustSkip: skip}
}

Expand All @@ -67,14 +70,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, warning)
}

// Re-register the skill tool to also resolve plugin-declared skills. The core
// skill tool only reads the default skills dir; the plugin-aware replacement
// merges the default dir with the plugin skill roots (default dir wins a name
// clash), so plugin skills appear in the agent's skill list. With no plugin
// skill roots this is byte-equivalent to the default skills surface.
if len(result.SkillRoots) > 0 {
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
}
// Always overlay the multi-root skill tool so discovery is identical with or
// without plugins: primary DefaultDir + optional ~/.agents/skills + plugin
// roots (earlier wins). With empty SkillRoots and no agents dir this is
// byte-equivalent to the core single-dir skill surface.
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))

return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots, trustSkip: skip}
}
Expand All @@ -91,8 +91,8 @@ func projectPluginsDirExists(workspaceRoot string) bool {
}

// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// primary dir + optional ~/.agents/skills + plugin skill roots, the same set the
// multi-root skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/plugin_activate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
}

func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
// Isolate host ~/.agents/skills so it cannot interfere with this test.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

pluginDir := t.TempDir()
skillDir := filepath.Join(pluginDir, "skills", "demo-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
Expand Down Expand Up @@ -108,6 +113,42 @@ func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
}
}

func TestActivatePluginsAlwaysRegistersMultiRootSkillTool(t *testing.T) {
// Even with zero plugins / zero plugin skill roots, activation must overlay
// the multi-root skill tool so ~/.agents/skills is loadable on bare runs.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agents, 0o755); err != nil {
t.Fatal(err)
}
skillDir := filepath.Join(agents, "agents-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: agents-skill\n---\nagents body"), 0o644); err != nil {
t.Fatal(err)
}

registry := tools.NewRegistry()
registry.Register(tools.NewSkillTool(t.TempDir())) // core single-dir tool
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, nil), &stderr, workspace)
if len(activation.skillRoots) != 0 {
t.Fatalf("expected no plugin skill roots, got %#v", activation.skillRoots)
}
skillTool, ok := registry.Get("skill")
if !ok {
t.Fatal("skill tool missing after activation")
}
res := skillTool.Run(context.Background(), map[string]any{"name": "agents-skill"})
if res.Status != tools.StatusOK || !bytes.Contains([]byte(res.Output), []byte("agents body")) {
t.Fatalf("agents skill must load with zero plugin roots: %q (%s)", res.Status, res.Output)
}
}

func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
// Load succeeds (no top-level error) but reports a per-plugin diagnostic for a
// plugin it had to skip. activatePlugins must forward that diagnostic to stderr
Expand Down
43 changes: 21 additions & 22 deletions internal/cli/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,21 @@ func runSkills(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}

func runSkillsList(dir string, options skillListOptions, stdout io.Writer, stderr io.Writer) int {
discovered, err := skills.List(dir)
// Management CLI lists global roots only (primary + ~/.agents/skills); plugin
// skills stay out of `zero skills list` and surface via the agent skill tool.
roots := skills.GlobalRoots(dir)
discovered, dups, err := skills.ListFromRoots(roots)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
// Surface name collisions that List silently resolved (first directory wins),
// so a shadowed same-named skill is reported instead of just disappearing.
// Warnings go to stderr, keeping stdout (including --json) clean.
if dups, derr := skills.Duplicates(dir); derr == nil {
for _, dup := range dups {
fmt.Fprintf(stderr, "warning: duplicate skill %q: using %s, ignoring %s\n",
redaction.RedactString(dup.Name, redaction.Options{}),
redaction.RedactString(dup.Winner, redaction.Options{}),
redaction.RedactString(dup.Loser, redaction.Options{}))
}
} else {
// Don't silently swallow a scan failure: "no warnings" would then be
// ambiguous (no duplicates vs. detection broke). Surface it on stderr.
fmt.Fprintf(stderr, "warning: could not check for duplicate skills: %s\n",
redaction.ErrorMessage(derr, redaction.Options{}))
// Surface name collisions that ListFromRoots silently resolved (earlier root
// wins), so a shadowed same-named skill is reported instead of just
// disappearing. Warnings go to stderr, keeping stdout (including --json) clean.
for _, dup := range dups {
fmt.Fprintf(stderr, "warning: duplicate skill %q: using %s, ignoring %s\n",
redaction.RedactString(dup.Name, redaction.Options{}),
redaction.RedactString(dup.Winner, redaction.Options{}),
redaction.RedactString(dup.Loser, redaction.Options{}))
}
if options.json {
payload := struct {
Expand All @@ -88,16 +84,16 @@ func runSkillsList(dir string, options skillListOptions, stdout io.Writer, stder
}
return exitSuccess
}
output := redaction.RedactString(formatSkillList(discovered, dir), redaction.Options{})
output := redaction.RedactString(formatSkillList(discovered), redaction.Options{})
if _, err := fmt.Fprintln(stdout, output); err != nil {
return exitCrash
}
return exitSuccess
}

func formatSkillList(discovered []skills.Skill, dir string) string {
func formatSkillList(discovered []skills.Skill) string {
if len(discovered) == 0 {
return fmt.Sprintf("No Zero skills found in %s.", dir)
return "No skills found."
}
lines := []string{"Zero Skills:"}
for _, skill := range discovered {
Expand Down Expand Up @@ -131,10 +127,13 @@ func writeSkillsHelp(w io.Writer) error {
zero skills <command>

Commands:
list List discovered Zero skills
add <git-url|path> Install a skill (checksum-pinned in skills.lock)
list List discovered skills (Zero dir + ~/.agents/skills)
add <git-url|path> Install a skill into the Zero skills dir (checksum-pinned in skills.lock)
info <name> Show a skill's frontmatter, source, and pinned hash
remove <name> Remove an installed skill and its lockfile entry
remove <name> Remove an installed skill and its lockfile entry from the Zero skills dir

list and info also search ~/.agents/skills when present (read-only).
add and remove always target the Zero-specific skills directory.
`)
return err
}
Expand Down
92 changes: 91 additions & 1 deletion internal/cli/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ func writeSkillFixture(t *testing.T, dir string, name string, content string) {
}
}

func isolateCLIAgentsHome(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
}

func TestRunSkillsListText(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "confirmation-policy", "---\nname: confirmation-policy\ndescription: Ask before risky actions.\n---\nbody")

Expand All @@ -43,6 +51,7 @@ func TestRunSkillsListText(t *testing.T) {
}

func TestRunSkillsListWarnsOnDuplicateNames(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
// Two directories declare the same frontmatter name; List keeps one and the
// other is shadowed. The command must warn instead of silently dropping it.
Expand All @@ -62,6 +71,7 @@ func TestRunSkillsListWarnsOnDuplicateNames(t *testing.T) {
}

func TestRunSkillsDefaultsToList(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "demo", "body")

Expand All @@ -78,6 +88,7 @@ func TestRunSkillsDefaultsToList(t *testing.T) {
}

func TestRunSkillsListJSON(t *testing.T) {
isolateCLIAgentsHome(t)
dir := t.TempDir()
writeSkillFixture(t, dir, "demo", "---\nname: demo\ndescription: a demo\n---\nbody")

Expand Down Expand Up @@ -111,18 +122,97 @@ func TestRunSkillsListJSON(t *testing.T) {
}

func TestRunSkillsEmptyDir(t *testing.T) {
isolateCLIAgentsHome(t)
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return filepath.Join(t.TempDir(), "missing") },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(strings.ToLower(stdout.String()), "no") {
if !strings.Contains(strings.ToLower(stdout.String()), "no skills found") {
t.Fatalf("expected a no-skills message, got:\n%s", stdout.String())
}
}

func TestRunSkillsListIncludesAgentsOnlySkill(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
writeSkillFixture(t, agents, "agents-only", "---\nname: agents-only\ndescription: Shared multi-agent skill.\n---\nbody")

primary := t.TempDir()
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "agents-only") {
t.Fatalf("list should include agents-only skill:\n%s", out)
}
if !strings.Contains(out, agents) {
t.Fatalf("list should show agents path:\n%s", out)
}
}

func TestRunSkillsListPrimaryShadowsAgents(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
primary := t.TempDir()
writeSkillFixture(t, primary, "shared", "---\nname: shared\ndescription: From primary.\n---\nbody")
writeSkillFixture(t, agents, "shared", "---\nname: shared\ndescription: From agents.\n---\nbody")

var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skills", "list"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "From primary") {
t.Fatalf("primary should win list description:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), primary) {
t.Fatalf("primary path should be shown:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), `duplicate skill "shared"`) {
t.Fatalf("expected cross-root duplicate warning, got: %q", stderr.String())
}
}

func TestRunSkillInfoResolvesAgentsOnly(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
agents := filepath.Join(home, ".agents", "skills")
writeSkillFixture(t, agents, "agents-info", "---\nname: agents-info\ndescription: Agents info.\n---\nbody")

primary := t.TempDir()
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{"skill", "info", "agents-info"}, &stdout, &stderr, appDeps{
skillsDir: func() string { return primary },
})
if exit != 0 {
t.Fatalf("exit = %d, stderr = %s", exit, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "agents-info") || !strings.Contains(out, "Agents info.") {
t.Fatalf("info missing agents skill:\n%s", out)
}
if strings.Contains(out, "source:") || strings.Contains(out, "hash:") {
t.Fatalf("agents-only info must not invent lock metadata:\n%s", out)
}
if !strings.Contains(out, agents) {
t.Fatalf("info should show agents path:\n%s", out)
}
}

func TestRunSkillsDoesNotLaunchTUI(t *testing.T) {
var stdout, stderr bytes.Buffer
launchCalled := false
Expand Down
Loading
Loading