mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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.
265 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|