diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 5c76d1cc..51931876 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -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 ` / `zero plugins enable ` 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. diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 1a9adae5..63f5b15f 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "io" "strings" @@ -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 [--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`.") @@ -563,10 +647,24 @@ Commands: list List local Zero plugins add Install a plugin (manifest-validated, pinned in plugins.lock) remove Remove an installed plugin and its lockfile entry + enable Enable a plugin via its plugin.json + disable 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 [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] diff --git a/internal/cli/extensions_test.go b/internal/cli/extensions_test.go index 00af4dd9..d77dee2c 100644 --- a/internal/cli/extensions_test.go +++ b/internal/cli/extensions_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "os" "path/filepath" "strings" "testing" @@ -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) + } +} diff --git a/internal/plugins/enabled.go b/internal/plugins/enabled.go new file mode 100644 index 00000000..1a3e8e4b --- /dev/null +++ b/internal/plugins/enabled.go @@ -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 +} diff --git a/internal/plugins/enabled_test.go b/internal/plugins/enabled_test.go new file mode 100644 index 00000000..ce3ea939 --- /dev/null +++ b/internal/plugins/enabled_test.go @@ -0,0 +1,148 @@ +package plugins + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestSetEnabledTogglesManifestAndIsIdempotent(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "demo") + writePluginManifest(t, pluginDir, map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Demo", + "version": "1.0.0", + "description": "keep me", + "tools": []any{}, + }) + manifestPath := filepath.Join(pluginDir, "plugin.json") + + changed, err := SetEnabled(manifestPath, false) + if err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + if !changed { + t.Fatal("expected first disable to change the manifest") + } + + raw, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("re-read manifest: %v", err) + } + if obj["enabled"] != false { + t.Fatalf("enabled = %#v, want false", obj["enabled"]) + } + if obj["description"] != "keep me" { + t.Fatalf("description was not preserved: %#v", obj["description"]) + } + + changed, err = SetEnabled(manifestPath, false) + if err != nil { + t.Fatalf("SetEnabled(false) again: %v", err) + } + if changed { + t.Fatal("expected second disable to be a no-op") + } + + changed, err = SetEnabled(manifestPath, true) + if err != nil { + t.Fatalf("SetEnabled(true): %v", err) + } + if !changed { + t.Fatal("expected enable to change the manifest") + } +} + +func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) { + dir := t.TempDir() + userRoot := filepath.Join(dir, "user") + projectRoot := filepath.Join(dir, "project") + writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "User Demo", + "version": "0.1.0", + "enabled": true, + }) + writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Project Demo", + "version": "0.2.0", + "enabled": true, + }) + + result, err := SetEnabledByID(LoadOptions{ + Roots: []Root{ + {Source: SourceUser, Path: userRoot}, + {Source: SourceProject, Path: projectRoot}, + }, + }, "zero.demo", false) + if err != nil { + t.Fatalf("SetEnabledByID: %v", err) + } + if result.Source != SourceProject { + t.Fatalf("source = %s, want project (precedence)", result.Source) + } + if !result.Changed || result.Enabled { + t.Fatalf("unexpected result: %#v", result) + } + + userOnly, err := SetEnabledByID(LoadOptions{ + Roots: []Root{ + {Source: SourceUser, Path: userRoot}, + {Source: SourceProject, Path: projectRoot}, + }, + ExcludeProject: true, + }, "zero.demo", false) + if err != nil { + t.Fatalf("SetEnabledByID user-only: %v", err) + } + if userOnly.Source != SourceUser { + t.Fatalf("user-only source = %s, want user", userOnly.Source) + } +} + +func TestSetEnabledPreservesManifestMode(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "demo") + writePluginManifest(t, pluginDir, map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Demo", + "version": "1.0.0", + }) + manifestPath := filepath.Join(pluginDir, "plugin.json") + if err := os.Chmod(manifestPath, 0o644); err != nil { + t.Fatal(err) + } + + if _, err := SetEnabled(manifestPath, false); err != nil { + t.Fatalf("SetEnabled: %v", err) + } + info, err := os.Stat(manifestPath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o644 { + t.Fatalf("mode = %04o, want 0644", got) + } +} + +func TestSetEnabledByIDMissingPlugin(t *testing.T) { + _, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false) + if err == nil { + t.Fatal("expected missing plugin error") + } + if !errors.Is(err, ErrNotInstalled) { + t.Fatalf("error = %v, want ErrNotInstalled", err) + } +}