Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,12 @@ Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```

A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`.

Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.

Expand Down
98 changes: 98 additions & 0 deletions internal/cli/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -80,11 +81,94 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
case "enable":
return runPluginsToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runPluginsToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
}
}

type pluginToggleOptions struct {
json bool
user bool
}

func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return writeExecUsageError(stderr, fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := positional[0]

cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
loadOptions := plugins.LoadOptions{Cwd: cwd, ExcludeProject: options.user}
result, err := plugins.SetEnabledByID(loadOptions, pluginID, !disabled)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return writeExecUsageError(stderr, err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}

state := "enabled"
if disabled {
state = "disabled"
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if result.Changed {
if _, err := fmt.Fprintf(stdout, "Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprintf(stdout, "Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath); err != nil {
return exitCrash
}
return exitSuccess
}

func parsePluginToggleArgs(args []string, command string) (pluginToggleOptions, []string, bool, error) {
options := pluginToggleOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown plugins %s flag %q", command, arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}

func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
Expand Down Expand Up @@ -563,10 +647,24 @@ Commands:
list List local Zero plugins
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
enable <id> Enable a plugin via its plugin.json
disable <id> Disable a plugin via its plugin.json
`)
return err
}

func writePluginsToggleHelp(w io.Writer, command string) error {
_, err := fmt.Fprintf(w, `Usage:
zero plugins %s <id> [flags]

Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print command result as JSON
-h, --help Show this help
`, command)
return err
}

func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
Expand Down
121 changes: 121 additions & 0 deletions internal/cli/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -333,3 +334,123 @@ func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
})
}
}

func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}

deps := appDeps{getwd: func() (string, error) { return cwd, nil }}

var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}

raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}

stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}

stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}

stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}

func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)

userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}

deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}

userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
118 changes: 118 additions & 0 deletions internal/plugins/enabled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package plugins

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

// ErrNotInstalled is returned when SetEnabledByID cannot find a plugin with
// the requested id under the given LoadOptions.
var ErrNotInstalled = errors.New("plugin is not installed")

// SetEnabledResult describes a successful enable/disable of one plugin manifest.
type SetEnabledResult struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Changed bool `json:"changed"`
Source Source `json:"source"`
ManifestPath string `json:"manifestPath"`
}

// FindByID returns the loaded plugin with the given id after normal discovery
// and precedence (project overrides user). A missing id returns ok=false.
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return LoadedPlugin{}, false, errors.New("plugin id is required")
}
result, err := Load(options)
if err != nil {
return LoadedPlugin{}, false, err
}
for _, plugin := range result.Plugins {
if plugin.ID == id {
return plugin, true, nil
}
}
return LoadedPlugin{}, false, nil
}

// SetEnabled updates the "enabled" field in plugin.json, preserving the rest of
// the manifest. It returns whether the file actually changed.
func SetEnabled(manifestPath string, enabled bool) (changed bool, err error) {
manifestPath = strings.TrimSpace(manifestPath)
if manifestPath == "" {
return false, errors.New("plugin manifest path is required")
}
resolved, err := filepath.Abs(manifestPath)
if err != nil {
return false, err
}

raw, err := os.ReadFile(resolved)
if err != nil {
return false, err
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return false, fmt.Errorf("parse plugin manifest: %w", err)
}
if obj == nil {
return false, errors.New("plugin manifest must be a JSON object")
}

previous, err := optionalBool(obj, "enabled", true)
if err != nil {
return false, err
}
if previous == enabled {
return false, nil
}
obj["enabled"] = enabled

data, err := json.MarshalIndent(obj, "", " ")
if err != nil {
return false, err
}
mode := os.FileMode(0o644)
if info, statErr := os.Stat(resolved); statErr == nil {
mode = info.Mode().Perm()
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", resolved, os.Getpid(), time.Now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), mode); err != nil {
return false, err
}
if err := os.Rename(tempPath, resolved); err != nil {
_ = os.Remove(tempPath)
return false, err
}
return true, nil
}

// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles its
// manifest enabled field.
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
plugin, ok, err := FindByID(options, id)
if err != nil {
return SetEnabledResult{}, err
}
if !ok {
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
changed, err := SetEnabled(plugin.ManifestPath, enabled)
if err != nil {
return SetEnabledResult{}, err
}
return SetEnabledResult{
ID: plugin.ID,
Enabled: enabled,
Changed: changed,
Source: plugin.Source,
ManifestPath: plugin.ManifestPath,
}, nil
}
Comment on lines +97 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant files and nearby code paths.
sed -n '1,220p' internal/plugins/enabled.go
printf '\n--- extensions.go ---\n'
sed -n '1,220p' internal/cli/extensions.go

Repository: Gitlawb/zero

Length of output: 9719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for the error strings and any sentinel/type already used elsewhere.
rg -n '"is not installed"|plugin id is required|ErrPlugin|errors.Is|Contains\(err.Error' internal -S

Repository: Gitlawb/zero

Length of output: 47876


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all callers and tests around plugin enable/disable so we can judge reachability.
rg -n 'SetEnabledByID\(|runPluginsToggle\(|plugins enable|plugins disable|plugin id is required|is not installed' internal -S

Repository: Gitlawb/zero

Length of output: 1703


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the toggle parser and any tests that cover empty or missing IDs.
sed -n '100,170p' internal/cli/extensions.go
printf '\n--- cli tests ---\n'
rg -n 'plugins (enable|disable)|is not installed|plugin id is required|usage: zero plugins (enable|disable)' internal/cli internal/plugins -S

Repository: Gitlawb/zero

Length of output: 2710


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the plugin enable tests and the install code for matching error-handling patterns.
sed -n '1,220p' internal/plugins/enabled_test.go
printf '\n--- install.go excerpt ---\n'
sed -n '160,230p' internal/plugins/install.go

Repository: Gitlawb/zero

Length of output: 5170


Use a typed error for the missing-plugin path. SetEnabledByID should wrap plugin %q is not installed with a sentinel, and internal/cli/extensions.go should switch from strings.Contains(err.Error(), "is not installed") to errors.Is(...); otherwise the empty-id path (plugin id is required) still falls into the crash branch.

📍 Affects 2 files
  • internal/plugins/enabled.go#L89-L110 (this comment)
  • internal/cli/extensions.go#L122-L128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugins/enabled.go` around lines 89 - 110, Introduce or reuse a
sentinel error for the missing-plugin case in SetEnabledByID and wrap it with
the plugin ID context. In internal/plugins/enabled.go:89-110, update the !ok
return; in internal/cli/extensions.go:122-128, replace
strings.Contains(err.Error(), "is not installed") with errors.Is against that
sentinel, adding the required import. Preserve distinct handling for the
empty-ID validation error.

Loading