Files
05c1b5e160 feat(plugins): add CLI enable/disable for plugin.json (#1202)
* feat(plugins): add CLI enable/disable for plugin.json

Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.

* fix(plugins): serialize toggles with installs and preserve manifest bytes

Address review findings on the enable/disable CLI:

- Take the same cross-process installtxn lock plugins add/remove take on
  the plugin's install root before the final lookup and read, and publish
  plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
  be overwritten with manifest bytes read before it committed, retrying
  when precedence moves to the other root; a plugin removed under the
  lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
  order, formatting, and numeric literals survive; the old
  decode/re-encode dropped precision (9007199254740993 became
  9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
  after a toggle, and leave pre-existing drift in place instead of
  absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
  error, and document project-over-user precedence in the help text.

* fix(plugins): recover interrupted install transactions before toggling

SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.

Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.

Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(plugins): say when a plugin toggle takes effect

Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.

State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key

Address the review findings on the plugin enable/disable toggle.

Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.

Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.

Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): keep the toggle on the pinned plugin directory throughout

The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.

Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): hash plugin trees through native os.Root calls

20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.

Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:16 -08:00

785 lines
24 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/plugins"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/tools"
)
type pluginListOptions struct {
json bool
}
type hookListOptions struct {
json bool
}
type mcpCommandOptions struct {
json bool
confirm bool
}
type mcpLegacyListOptions struct {
json bool
tools bool
}
func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "plugins subcommand required. Use `zero plugins list`.")
}
switch args[0] {
case "-h", "--help", "help":
if err := writePluginsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
case "list":
options, help, err := parsePluginListArgs(args[1:])
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writePluginsListHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
result, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Plugins []plugins.LoadedPlugin `json:"plugins"`
Diagnostics []plugins.Diagnostic `json:"diagnostics"`
}{Plugins: result.Plugins, Diagnostics: result.Diagnostics}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
output := redaction.RedactString(plugins.FormatList(result.Plugins, result.Diagnostics), redaction.Options{})
if _, err := fmt.Fprintln(stdout, output); err != nil {
return exitCrash
}
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)
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
}
// runPluginsToggle implements `zero plugins enable|disable <id>`.
//
// Every message it writes, including usage errors that echo the caller's
// arguments, passes through the redaction pipeline: an argument is user input
// and can be a mistyped credential.
func runPluginsToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
commandName := "enable"
if disabled {
commandName = "disable"
}
usageError := func(message string) int {
return writeExecUsageError(stderr, redaction.RedactString(message, redaction.Options{}))
}
options, positional, help, err := parsePluginToggleArgs(args, commandName)
if err != nil {
return usageError(err.Error())
}
if help {
if err := writePluginsToggleHelp(stdout, commandName); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
pluginID := strings.TrimSpace(positional[0])
if pluginID == "" {
return usageError(fmt.Sprintf("usage: zero plugins %s <id> [--user] [--json]", commandName))
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+redaction.ErrorMessage(err, redaction.Options{}), 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 usageError(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
}
message := fmt.Sprintf("Plugin %s is now %s in %s.\n", result.ID, state, result.ManifestPath)
if !result.Changed {
message = fmt.Sprintf("Plugin %s was already %s in %s.\n", result.ID, state, result.ManifestPath)
}
if _, err := fmt.Fprint(stdout, redaction.RedactString(message, redaction.Options{})); 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`.")
}
switch args[0] {
case "-h", "--help", "help":
if err := writeHooksHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
case "list":
options, help, err := parseHookListArgs(args[1:])
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeHooksListHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
result, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Hooks hooks.Config `json:"hooks"`
Diagnostics []hooks.Diagnostic `json:"diagnostics"`
}{Hooks: result.Config, Diagnostics: result.Diagnostics}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
output := redaction.RedactString(hooks.FormatList(result.Config, result.Diagnostics), redaction.Options{})
if _, err := fmt.Fprintln(stdout, output); err != nil {
return exitCrash
}
return exitSuccess
case "add":
return runHooksAdd(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runHooksRemove(args[1:], stdout, stderr, deps)
case "enable":
return runHooksToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runHooksToggle(args[1:], stdout, stderr, deps, true)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown hooks subcommand %q", args[0]))
}
}
func runMCP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runMCPWithContext(context.Background(), args, stdout, stderr, deps)
}
func runMCPWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if ctx == nil {
ctx = context.Background()
}
if len(args) == 0 {
return writeExecUsageError(stderr, "mcp subcommand required. Use `zero mcp permissions list`.")
}
switch args[0] {
case "-h", "--help", "help":
if err := writeMCPHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
case "add":
return runMCPAdd(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runMCPRemove(args[1:], stdout, stderr, deps)
case "enable":
return runMCPToggle(args[1:], stdout, stderr, deps, false)
case "disable":
return runMCPToggle(args[1:], stdout, stderr, deps, true)
case "check":
return runMCPCheck(ctx, args[1:], stdout, stderr, deps)
case "permissions":
return runMCPPermissions(args[1:], stdout, stderr, deps)
case "tools":
return runMCPTools(ctx, args[1:], stdout, stderr, deps)
case "oauth":
return runMCPOAuth(args[1:], stdout, stderr, deps)
case "list":
return runMCPLegacyList(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp subcommand %q", args[0]))
}
}
func runMCPLegacyList(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseMCPLegacyListArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPLegacyListHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if options.tools {
forwarded := []string{"list"}
if options.json {
forwarded = append(forwarded, "--json")
}
return runMCPTools(context.Background(), forwarded, stdout, stderr, deps)
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
// Enumeration for the extensions listing, never spawns a server, so it is left
// ungated (excludeProject=false) like the other doctor/status report sites.
cfg, err := deps.resolveMCPConfig(cwd, false)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if cfg.Servers == nil {
cfg.Servers = map[string]config.MCPServerConfig{}
}
if options.json {
// Env and header VALUES are credentials by construction (API keys,
// bearer tokens) and must never reach stdout: pattern-based redaction
// only catches recognizable token shapes, so the values are replaced
// structurally — keys stay visible for triage.
payload := struct {
Servers map[string]config.MCPServerConfig `json:"servers"`
}{Servers: redactMCPServerConfigs(cfg.Servers)}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMCPServerList(cfg.Servers), redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
// redactMCPServerConfigs deep-copies the server map with every env and header
// value replaced by a redaction marker.
func redactMCPServerConfigs(servers map[string]config.MCPServerConfig) map[string]config.MCPServerConfig {
redacted := make(map[string]config.MCPServerConfig, len(servers))
for name, server := range servers {
clone := server
if len(server.Env) > 0 {
clone.Env = make(map[string]string, len(server.Env))
for key := range server.Env {
clone.Env[key] = "[REDACTED]"
}
}
if len(server.Headers) > 0 {
clone.Headers = make(map[string]string, len(server.Headers))
for key := range server.Headers {
clone.Headers[key] = "[REDACTED]"
}
}
clone.URL = redactMCPURL(clone.URL, "[REDACTED]")
redacted[name] = clone
}
return redacted
}
func runMCPTools(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if ctx == nil {
ctx = context.Background()
}
if len(args) == 0 {
return writeExecUsageError(stderr, "mcp tools subcommand required. Use `zero mcp tools list`.")
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
if err := writeMCPToolsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
switch args[0] {
case "list":
options, help, err := parseMCPCommandOptions(args[1:])
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPToolsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
registry := tools.NewRegistry()
// `mcp tools list` connects to (spawns) each server to enumerate its live tools,
// so it is a spawn site and gates the project layer behind the trust check. No
// --worktree reassignment on this command path, so trustRoot == cwd.
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow, cwd)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
emitTrustNotice(stderr, mcpSkip)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
items := mcpToolList(registry)
if options.json {
payload := struct {
Tools []mcpToolListItem `json:"tools"`
}{Tools: items}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMCPToolList(items), redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp tools subcommand %q", args[0]))
}
}
func runMCPPermissions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "mcp permissions subcommand required. Use `zero mcp permissions list`.")
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
if err := writeMCPPermissionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
switch args[0] {
case "list":
options, help, err := parseMCPCommandOptions(args[1:])
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPPermissionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
store, err := deps.newMCPStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
permissions, err := store.List()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Permissions []mcp.PermissionGrant `json:"permissions"`
}{Permissions: permissions}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, mcp.FormatPermissionList(permissions)); err != nil {
return exitCrash
}
return exitSuccess
case "revoke":
return runMCPPermissionsRevoke(args[1:], stdout, stderr, deps)
case "clear":
return runMCPPermissionsClear(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp permissions subcommand %q", args[0]))
}
}
func runMCPPermissionsRevoke(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, positional, help, err := parseMCPPositionalCommand(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPPermissionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) == 0 || len(positional) > 2 {
return writeExecUsageError(stderr, "usage: zero mcp permissions revoke <server> [<tool>] [--json]")
}
store, err := deps.newMCPStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
serverName := positional[0]
if len(positional) == 2 {
revoked, err := store.RevokeTool(serverName, positional[1])
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Revoked int `json:"revoked"`
Scope string `json:"scope"`
ServerName string `json:"serverName"`
ToolName string `json:"toolName"`
}{Revoked: revoked, Scope: string(mcp.ScopeTool), ServerName: serverName, ToolName: positional[1]}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprintf(stdout, "Revoked %d MCP tool permission grant(s) for %s/%s.\n", revoked, serverName, positional[1]); err != nil {
return exitCrash
}
return exitSuccess
}
revoked, err := store.RevokeServer(serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Revoked int `json:"revoked"`
Scope string `json:"scope"`
ServerName string `json:"serverName"`
}{Revoked: revoked, Scope: string(mcp.ScopeServer), ServerName: serverName}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprintf(stdout, "Revoked %d MCP server permission grant(s) for %s.\n", revoked, serverName); err != nil {
return exitCrash
}
return exitSuccess
}
func runMCPPermissionsClear(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseMCPCommandOptions(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPPermissionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if !options.confirm {
return writeExecUsageError(stderr, "Pass --confirm to clear all MCP permission grants.")
}
store, err := deps.newMCPStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
cleared, err := store.Clear()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
Cleared int `json:"cleared"`
}{Cleared: cleared}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprintf(stdout, "Cleared %d MCP permission grant(s).\n", cleared); err != nil {
return exitCrash
}
return exitSuccess
}
func parsePluginListArgs(args []string) (pluginListOptions, bool, error) {
options := pluginListOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown plugins list flag %q", arg)}
}
}
return options, false, nil
}
func parseHookListArgs(args []string) (hookListOptions, bool, error) {
options := hookListOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown hooks list flag %q", arg)}
}
}
return options, false, nil
}
func parseMCPCommandOptions(args []string) (mcpCommandOptions, bool, error) {
options := mcpCommandOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
case "--confirm":
options.confirm = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown mcp flag %q", arg)}
}
}
return options, false, nil
}
func parseMCPLegacyListArgs(args []string) (mcpLegacyListOptions, bool, error) {
options := mcpLegacyListOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
case "--tools":
options.tools = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown mcp list flag %q", arg)}
}
}
return options, false, nil
}
func parseMCPPositionalCommand(args []string) (mcpCommandOptions, []string, bool, error) {
options := mcpCommandOptions{}
positional := []string{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, positional, true, nil
case "--json":
options.json = true
case "--confirm":
options.confirm = true
default:
if strings.HasPrefix(arg, "-") {
return options, positional, false, execUsageError{fmt.Sprintf("unknown mcp permissions flag %q", arg)}
}
positional = append(positional, arg)
}
}
return options, positional, false, nil
}
func writePluginsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins <command>
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
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
Without --user, a project plugin with the same id takes precedence over a user
plugin, matching the loader's discovery order.
Zero reads plugins when it starts, so the change applies to the next zero exec
run or interactive session. A running interactive session keeps the plugins it
started with until you restart it.
`, command)
return err
}
func writePluginsListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins list [flags]
Flags:
--json Print local plugin data as JSON
-h, --help Show this help
`)
return err
}
func writeHooksHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero hooks <command>
Commands:
list List configured Zero hooks
add Add or update a hook
remove Remove a hook by id
enable Enable a hook by id
disable Disable a hook by id
`)
return err
}
func writeHooksListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero hooks list [flags]
Flags:
--json Print hook config as JSON
-h, --help Show this help
`)
return err
}
func writeMCPHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero mcp <command>
Commands:
add <server> Add or update an MCP server in user config
remove <server> Remove an MCP server from user config
enable <server> Enable a user-configured MCP server
disable <server> Disable a user-configured MCP server
check <server> Connect to one MCP server and list its tools
list List configured MCP servers, or tools with --tools
oauth Manage OAuth credentials for remote MCP servers
permissions Manage persistent MCP tool permissions
tools Inspect configured MCP tools
`)
return err
}
func writeMCPLegacyListHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero mcp list [flags]
Flags:
--json Print MCP servers or tools as JSON
--tools Connect to enabled servers and list MCP tools
-h, --help Show this help
`)
return err
}
func writeMCPToolsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero mcp tools <command>
Commands:
list List configured MCP tools
Flags:
--json Print MCP tools as JSON
-h, --help Show this help
`)
return err
}
func writeMCPPermissionsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero mcp permissions <command>
Commands:
list List all persistent MCP permissions
revoke <server> [<tool>] Revoke an MCP server or tool permission
clear --confirm Clear all persistent MCP permissions
Flags:
--json Print command result as JSON
--confirm Confirm destructive clear operation
-h, --help Show this help
`)
return err
}