Files
zero/internal/cli/plugin_activate_test.go
Leonardo Faoro 7d579996b4 feat(skills): discover shared ~/.agents/skills with multi-root skill loading (#696)
* feat(skills): discover shared ~/.agents/skills with multi-root skill loading

Add ~/.agents/skills as a read-only global discovery root after the
primary
Zero skills dir and before plugin roots. Unify runtime and CLI discovery
on
one multi-root path (DiscoveryRoots / LoadFromRoots / GlobalRoots) and
always
register the multi-root skill tool so bare runs and plugin runs share
the
same load surface.
Install/remove/lock still target only the primary Zero skills directory.
Fixes #610

* docs(skills): note lock stays on the primary write root

Document that install/remove/lock target only the primary Zero skills
directory, matching multi-root discovery write-root isolation.

* fix(skills): bubble primary root load errors from LoadFromRoots

Return non-missing failures from the first non-empty skills root so a
broken primary dir is not reported as an empty skill list. Keep fail-open
behavior for optional roots (~/.agents/skills, plugins).

* fix(skills): treat non-directory skills root as error on Windows

Windows maps ENOTDIR to ErrNotExist, so ReadDir on a regular file looked
like a missing skills dir. Reclassify existing non-directories and assert
the portable load behavior in tests.
2026-07-16 21:59:20 +08:00

265 lines
8.9 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
)
// fakePluginDeps builds appDeps whose loadPlugins returns the supplied plugins and
// whose skillsDir points at an empty dir, so activatePlugins can be exercised
// without touching the real plugin roots.
func fakePluginDeps(t *testing.T, loaded []plugins.LoadedPlugin) appDeps {
t.Helper()
skillsDir := t.TempDir()
return appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
return plugins.LoadResult{Plugins: loaded}, nil
},
skillsDir: func() string { return skillsDir },
}
}
func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
pluginDir := t.TempDir()
loaded := []plugins.LoadedPlugin{{
ID: "zero.demo",
Name: "Demo",
Enabled: true,
Source: plugins.SourceProject,
PluginDir: pluginDir,
Tools: []plugins.ToolExtension{{
Name: "demo_lookup",
Description: "lookup",
Command: "true",
Permission: plugins.PermissionPrompt,
}},
Hooks: []plugins.HookExtension{{
Name: "pre",
Event: plugins.HookBeforeTool,
Command: "guard.sh",
}},
}}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
if _, ok := registry.Get("demo_lookup"); !ok {
t.Fatalf("plugin tool not registered into the bootstrap registry")
}
if len(activation.hooks) != 1 || activation.hooks[0].Event != hooks.EventBeforeTool {
t.Fatalf("expected one beforeTool plugin hook, got %#v", activation.hooks)
}
if activation.hooks[0].ID != "zero.demo.pre" {
t.Fatalf("plugin hook id = %q, want namespaced zero.demo.pre", activation.hooks[0].ID)
}
if stderr.Len() != 0 {
t.Fatalf("unexpected stderr for a clean activation: %s", stderr.String())
}
}
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 {
t.Fatal(err)
}
skillMd := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillMd, []byte("---\nname: demo-skill\n---\nplugin skill body"), 0o644); err != nil {
t.Fatal(err)
}
loaded := []plugins.LoadedPlugin{{
ID: "zero.skills",
Name: "Skills",
Enabled: true,
Source: plugins.SourceProject,
PluginDir: pluginDir,
Skills: []plugins.PathExtension{{Name: "demo-skill", Path: skillMd}},
}}
registry := tools.NewRegistry()
registry.Register(tools.NewSkillTool(t.TempDir())) // core skill tool, like the real bootstrap
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
if len(activation.skillRoots) != 1 {
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
}
skillTool, ok := registry.Get("skill")
if !ok {
t.Fatal("skill tool missing after activation")
}
// The re-registered skill tool resolves the plugin skill by name.
res := skillTool.Run(context.Background(), map[string]any{"name": "demo-skill"})
if res.Status != tools.StatusOK {
t.Fatalf("plugin skill not resolvable via the agent skill tool: %q (%s)", res.Status, res.Output)
}
}
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
// rather than dropping it, so a skipped plugin is never silent.
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
return plugins.LoadResult{
Diagnostics: []plugins.Diagnostic{{
Kind: plugins.DiagnosticSchema,
Message: "schemaVersion: Expected schemaVersion 1.",
}},
}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, deps, &stderr, workspace)
if stderr.Len() == 0 {
t.Fatal("a load diagnostic must be surfaced on stderr")
}
if !bytes.Contains(stderr.Bytes(), []byte("Expected schemaVersion 1")) {
t.Fatalf("stderr should carry the diagnostic message, got %q", stderr.String())
}
}
func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
deps := appDeps{
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
return plugins.LoadResult{}, errors.New("boom")
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, deps, &stderr, workspace)
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
}
if stderr.Len() == 0 {
t.Fatalf("a load error should be surfaced as a warning on stderr")
}
}
func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
good := plugins.LoadedPlugin{
ID: "zero.good",
Name: "Good",
Enabled: true,
Source: plugins.SourceProject,
PluginDir: t.TempDir(),
Tools: []plugins.ToolExtension{{Name: "good_tool", Command: "true", Permission: plugins.PermissionPrompt}},
}
bad := plugins.LoadedPlugin{
ID: "zero.bad",
Name: "Bad",
Enabled: true,
Source: plugins.SourceProject,
PluginDir: t.TempDir(),
Tools: []plugins.ToolExtension{{Name: "bad_tool", Command: "", Permission: plugins.PermissionPrompt}},
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr, workspace)
if _, ok := registry.Get("good_tool"); !ok {
t.Fatal("the good plugin tool must still register despite a malformed sibling")
}
if _, ok := registry.Get("bad_tool"); ok {
t.Fatal("the malformed plugin tool must not register")
}
if stderr.Len() == 0 {
t.Fatal("a malformed plugin should produce a warning on stderr")
}
}
func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
// Keep the hook audit store inside a temp dir rather than the user's real data
// directory (the audit path is derived from XDG_DATA_HOME).
t.Setenv("XDG_DATA_HOME", t.TempDir())
workspace := t.TempDir()
extra := []hooks.Definition{{
ID: "zero.demo.pre",
Event: hooks.EventBeforeTool,
Command: "guard.sh",
Enabled: true,
}}
dispatcher, _ := newHookDispatcherWithExtra(workspace, extra, workspace)
if dispatcher == nil {
t.Fatal("dispatcher should never be nil for a clean workspace")
}
// Drive a beforeTool dispatch: the plugin hook runs, proving it is part of the
// active hook set (not merely returned as data).
outcome := dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
Event: hooks.EventBeforeTool,
ToolName: "bash",
})
// With no real guard.sh on PATH the command fails to launch, which for a
// beforeTool hook is advisory (fails OPEN), so the call is recorded as having
// run exactly one hook.
if outcome.Ran != 1 {
t.Fatalf("expected the plugin beforeTool hook to run, Ran=%d", outcome.Ran)
}
}