From 41fb27df2dfd1eb8c0ce0f4d63c3b248cb5dcb23 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Mon, 20 Jul 2026 15:55:46 +0530 Subject: [PATCH 1/2] feat(plugins): add zero plugins info command Add plugins info to inspect manifest state, extension counts, and lockfile source/hash with optional hash drift detection. --- docs/EXTENDING.md | 1 + internal/cli/distribution.go | 125 ++++++++++++++++++++++++++++++ internal/cli/distribution_test.go | 9 +++ internal/cli/extensions.go | 3 + internal/plugins/info.go | 65 ++++++++++++++++ internal/plugins/info_test.go | 71 +++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 internal/plugins/info.go create mode 100644 internal/plugins/info_test.go 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..4ea89fe4d 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,118 @@ 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), + LockDir: deps.pluginsDir(), + }, 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) + } + if 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 +679,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..f423bf4c0 --- /dev/null +++ b/internal/plugins/info.go @@ -0,0 +1,65 @@ +package plugins + +import ( + "errors" + "fmt" + "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 + LockDir string +} + +// 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} + lockDir := strings.TrimSpace(options.LockDir) + if lockDir != "" { + if lock, readErr := ReadLock(lockDir); 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..e010fc12a --- /dev/null +++ b/internal/plugins/info_test.go @@ -0,0 +1,71 @@ +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}}}, + LockDir: 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}}}, + LockDir: 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") + } +} From 74aac049c79b7660e2e4afa46f8a8cb101b7c7d3 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Mon, 20 Jul 2026 16:52:24 +0530 Subject: [PATCH 2/2] fix(plugins): derive lock dir from plugin install path for info Remove LockDir from InfoOptions and read the lockfile from filepath.Dir(plugin.PluginDir) so lock metadata matches the resolved plugin location. Consolidate duplicate lock hash output lines. --- internal/cli/distribution.go | 3 --- internal/plugins/info.go | 13 +++++-------- internal/plugins/info_test.go | 2 -- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/internal/cli/distribution.go b/internal/cli/distribution.go index 4ea89fe4d..cf3f41064 100644 --- a/internal/cli/distribution.go +++ b/internal/cli/distribution.go @@ -273,7 +273,6 @@ func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDe } info, err := plugins.Info(plugins.InfoOptions{ LoadOptions: pluginLoadOptions(deps, cwd, options.user), - LockDir: deps.pluginsDir(), }, id) if err != nil { if errors.Is(err, plugins.ErrNotInstalled) { @@ -314,8 +313,6 @@ func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDe } if info.LockHash != "" { lines = append(lines, " lock hash: "+info.LockHash) - } - if info.LockHash != "" { lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift)) } lines = append(lines, " manifest: "+plugin.ManifestPath) diff --git a/internal/plugins/info.go b/internal/plugins/info.go index f423bf4c0..0dac22570 100644 --- a/internal/plugins/info.go +++ b/internal/plugins/info.go @@ -3,6 +3,7 @@ package plugins import ( "errors" "fmt" + "path/filepath" "strings" ) @@ -20,7 +21,6 @@ type PluginInfo struct { // InfoOptions controls plugin lookup and lockfile resolution for Info. type InfoOptions struct { LoadOptions LoadOptions - LockDir string } // Info returns details for the named plugin after normal discovery precedence. @@ -47,13 +47,10 @@ func Info(options InfoOptions, id string) (PluginInfo, error) { } info := PluginInfo{Plugin: plugin} - lockDir := strings.TrimSpace(options.LockDir) - if lockDir != "" { - if lock, readErr := ReadLock(lockDir); readErr == nil { - if entry, ok := lock[plugin.ID]; ok { - info.LockSource = entry.Source - info.LockHash = entry.Hash - } + 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 != "" { diff --git a/internal/plugins/info_test.go b/internal/plugins/info_test.go index e010fc12a..16fd4c6d4 100644 --- a/internal/plugins/info_test.go +++ b/internal/plugins/info_test.go @@ -31,7 +31,6 @@ func TestInfoReturnsLockMetadataAndHashDrift(t *testing.T) { info, err := Info(InfoOptions{ LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}}, - LockDir: userRoot, }, "zero.demo") if err != nil { t.Fatalf("Info: %v", err) @@ -51,7 +50,6 @@ func TestInfoReturnsLockMetadataAndHashDrift(t *testing.T) { } info, err = Info(InfoOptions{ LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}}, - LockDir: userRoot, }, "zero.demo") if err != nil { t.Fatalf("Info after edit: %v", err)