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
1 change: 1 addition & 0 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
122 changes: 122 additions & 0 deletions internal/cli/distribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"

Expand Down Expand Up @@ -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 <id> [--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 {
Expand Down Expand Up @@ -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 <id> [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 <command>
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/distribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -561,6 +563,7 @@ func writePluginsHelp(w io.Writer) error {

Commands:
list List local Zero plugins
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
`)
Expand Down
62 changes: 62 additions & 0 deletions internal/plugins/info.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
}
69 changes: 69 additions & 0 deletions internal/plugins/info_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading