diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 5c76d1cc6..d38f9b403 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -276,6 +276,7 @@ Install and manage: ```bash zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/ zero plugins list +zero plugins info github-pr-review # show manifest path, enabled state, lock metadata zero plugins remove github-pr-review # alias: rm ``` diff --git a/internal/cli/distribution.go b/internal/cli/distribution.go index 8cf2d640d..cf3f41064 100644 --- a/internal/cli/distribution.go +++ b/internal/cli/distribution.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "path/filepath" "sort" "strings" @@ -251,6 +252,115 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ return exitSuccess } +func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + id, options, help, err := parsePluginInfoArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writePluginInfoHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, "usage: zero plugins info [--user] [--json]") + } + + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + info, err := plugins.Info(plugins.InfoOptions{ + LoadOptions: pluginLoadOptions(deps, cwd, options.user), + }, id) + if err != nil { + if errors.Is(err, plugins.ErrNotInstalled) { + return writeExecUsageError(stderr, err.Error()) + } + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + + if options.json { + if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + + plugin := info.Plugin + lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)} + state := "enabled" + if !plugin.Enabled { + state = "disabled" + } + lines = append(lines, " state: "+state) + lines = append(lines, " source: "+string(plugin.Source)) + if len(plugin.Tools) > 0 { + lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools))) + } + if len(plugin.Hooks) > 0 { + lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks))) + } + if len(plugin.Skills) > 0 { + lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills))) + } + if len(plugin.Prompts) > 0 { + lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts))) + } + if info.LockSource != "" { + lines = append(lines, " lock source: "+info.LockSource) + } + if info.LockHash != "" { + lines = append(lines, " lock hash: "+info.LockHash) + lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift)) + } + lines = append(lines, " manifest: "+plugin.ManifestPath) + if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess +} + +type pluginInfoOptions struct { + json bool + user bool +} + +func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) { + options := pluginInfoOptions{} + id := "" + for _, arg := range args { + switch arg { + case "-h", "--help", "help": + return "", options, true, nil + case "--json": + options.json = true + case "--user": + options.user = true + default: + if strings.HasPrefix(arg, "-") { + return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)} + } + if id != "" { + return "", options, false, execUsageError{"plugins info takes a single id"} + } + id = arg + } + } + return id, options, false, nil +} + +func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions { + opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly} + roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}} + if !userOnly { + roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")}) + } + opts.Roots = roots + return opts +} + // --- tools make / list --- func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { @@ -566,6 +676,18 @@ Removes an installed plugin directory and its plugins.lock entry. return err } +func writePluginInfoHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins info [flags] + +Flags: + --user Target only user plugins (ignore ./.zero/plugins) + --json Print plugin info as JSON + -h, --help Show this help +`) + return err +} + func writeToolsHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero tools diff --git a/internal/cli/distribution_test.go b/internal/cli/distribution_test.go index 7782a621a..9112032a7 100644 --- a/internal/cli/distribution_test.go +++ b/internal/cli/distribution_test.go @@ -177,6 +177,15 @@ func TestRunPluginAddListRemove(t *testing.T) { t.Fatalf("add output missing id:\n%s", stdout.String()) } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugin", "info", "zero.demo"}, &stdout, &stderr, deps); exit != 0 { + t.Fatalf("plugin info exit = %d, stderr = %s", exit, stderr.String()) + } + if !strings.Contains(stdout.String(), "zero.demo") || !strings.Contains(stdout.String(), "manifest:") { + t.Fatalf("info output missing plugin details:\n%s", stdout.String()) + } + // remove stdout.Reset() stderr.Reset() diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 1a9adae5c..8dd1e9f00 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -78,6 +78,8 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return exitSuccess case "add": return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr) + case "info": + return runPluginInfo(args[1:], stdout, stderr, deps) case "remove", "rm": return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr) default: @@ -561,6 +563,7 @@ func writePluginsHelp(w io.Writer) error { Commands: list List local Zero plugins + info Show plugin details and lockfile metadata add Install a plugin (manifest-validated, pinned in plugins.lock) remove Remove an installed plugin and its lockfile entry `) diff --git a/internal/plugins/info.go b/internal/plugins/info.go new file mode 100644 index 000000000..0dac22570 --- /dev/null +++ b/internal/plugins/info.go @@ -0,0 +1,62 @@ +package plugins + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +// ErrNotInstalled is returned when Info cannot find a plugin with the requested id. +var ErrNotInstalled = errors.New("plugin is not installed") + +// PluginInfo combines a loaded plugin with optional lockfile metadata. +type PluginInfo struct { + Plugin LoadedPlugin `json:"plugin"` + LockSource string `json:"lockSource,omitempty"` + LockHash string `json:"lockHash,omitempty"` + HashDrift bool `json:"hashDrift,omitempty"` +} + +// InfoOptions controls plugin lookup and lockfile resolution for Info. +type InfoOptions struct { + LoadOptions LoadOptions +} + +// Info returns details for the named plugin after normal discovery precedence. +func Info(options InfoOptions, id string) (PluginInfo, error) { + id = strings.TrimSpace(id) + if id == "" { + return PluginInfo{}, errors.New("plugin id is required") + } + result, err := Load(options.LoadOptions) + if err != nil { + return PluginInfo{}, err + } + var plugin LoadedPlugin + found := false + for _, candidate := range result.Plugins { + if candidate.ID == id { + plugin = candidate + found = true + break + } + } + if !found { + return PluginInfo{}, fmt.Errorf("%w: %q", ErrNotInstalled, id) + } + + info := PluginInfo{Plugin: plugin} + if lock, readErr := ReadLock(filepath.Dir(plugin.PluginDir)); readErr == nil { + if entry, ok := lock[plugin.ID]; ok { + info.LockSource = entry.Source + info.LockHash = entry.Hash + } + } + if info.LockHash != "" { + if current, hashErr := hashTree(plugin.PluginDir); hashErr == nil { + info.HashDrift = current != info.LockHash + } + } + return info, nil +} diff --git a/internal/plugins/info_test.go b/internal/plugins/info_test.go new file mode 100644 index 000000000..16fd4c6d4 --- /dev/null +++ b/internal/plugins/info_test.go @@ -0,0 +1,69 @@ +package plugins + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInfoReturnsLockMetadataAndHashDrift(t *testing.T) { + dir := t.TempDir() + userRoot := filepath.Join(dir, "user") + pluginDir := filepath.Join(userRoot, "zero.demo") + writePluginManifest(t, pluginDir, map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Demo", + "version": "1.0.0", + "enabled": true, + "tools": []any{}, + }) + + hash, err := hashTree(pluginDir) + if err != nil { + t.Fatal(err) + } + if err := writeLock(userRoot, map[string]LockEntry{ + "zero.demo": {Source: "./demo-src", Hash: hash}, + }); err != nil { + t.Fatal(err) + } + + info, err := Info(InfoOptions{ + LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}}, + }, "zero.demo") + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.Plugin.ID != "zero.demo" || !info.Plugin.Enabled { + t.Fatalf("plugin = %#v", info.Plugin) + } + if info.LockSource != "./demo-src" || info.LockHash != hash { + t.Fatalf("lock metadata = %#v, want source ./demo-src hash %s", info, hash) + } + if info.HashDrift { + t.Fatal("expected no hash drift before manifest edit") + } + + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled":true}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + info, err = Info(InfoOptions{ + LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}}, + }, "zero.demo") + if err != nil { + t.Fatalf("Info after edit: %v", err) + } + if !info.HashDrift { + t.Fatal("expected hash drift after manifest edit") + } +} + +func TestInfoMissingPlugin(t *testing.T) { + _, err := Info(InfoOptions{ + LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, + }, "missing") + if err == nil { + t.Fatal("expected missing plugin error") + } +}