Skip to content

commands

Thomas Mangin edited this page Aug 16, 2026 · 3 revisions

Pre-Alpha. This page describes behavior that may change.

A plugin exposes commands by declaring them in its Registration at startup and handling them with OnExecuteCommand at runtime. Once declared, the command is visible on every operator surface automatically: the CLI shell, ze cli -c, the MCP server, the REST API, and the Looking Glass (if it is read-only). You do not wire it up five times. You declare it once.

This page covers the author side. The consumer side (how commands flow from the CLI through the dispatcher to the plugin) is in the architecture reference.

Declaring a command

Commands go in the Commands field of sdk.Registration. The engine learns about them during stage 1 (declare-registration).

err := p.Run(ctx, sdk.Registration{
    Commands: []sdk.CommandDecl{
        {Name: "my-plugin status",  Description: "Show current status"},
        {Name: "my-plugin check",   Description: "Trigger immediate check", Args: []string{"target"}},
    },
})
Field Purpose
Name The full command name. Use spaces to group (my-plugin status).
Description One line shown in help and command-list.
Args The expected positional arguments. Used for tab completion and help text.

Handling a command

Register OnExecuteCommand before calling Run(). The handler receives the command serial (a correlation id for the request), the command name, the arguments, and the peer selector if one was provided.

p.OnExecuteCommand(func(serial, command string, args []string, peer string) (status, data string, err error) {
    switch command {
    case "my-plugin status":
        return "done", `{"status":"running","uptime":3600}`, nil

    case "my-plugin check":
        if len(args) < 1 {
            return "error", "usage: my-plugin check <target>", nil
        }
        result := performCheck(args[0])
        return "done", result, nil

    default:
        return "error", "unknown command: " + command, nil
    }
})

Return values

OnExecuteCommand returns (status, data, error).

Success with data. Return a short status string ("done" is the usual choice) and the structured result as a JSON string.

return "done", `{"count":42,"items":["a","b"]}`, nil

Success without data. Return "done" and an empty data string.

return "done", "", nil

Handler error. Return a non-nil error. The SDK sends an error response and the status and data values are ignored.

return "", "", fmt.Errorf("operation failed: database timeout")

User-facing validation failure. Return "error" as the status with an explanatory message in data, and nil for the error. The engine treats the response as a successful RPC but the operator sees the error message.

return "error", "usage: my-plugin check <target>", nil

The distinction matters: a Go error from the handler is a plugin bug, and the engine logs it. A "error" status is an expected failure that the user should see.

The wire surface

Commands are delivered as execute-command RPCs over the plugin connection.

#17 ze-plugin-callback:execute-command {"serial":"abc123","command":"my-plugin status","args":[],"peer":""}
#17 ok {"status":"done","data":"{\"status\":\"running\"}"}

On a handler error:

#17 error {"message":"operation failed: database timeout"}

Fields in ExecuteCommandInput

Field Type Purpose
serial string Correlation id for the request.
command string Command name (e.g. "my-plugin status").
args []string Additional arguments. May be empty.
peer string Peer selector. May be empty.

The peer field is the selector from the CLI (e.g. *, a peer name, or an IP). Use it to narrow your command's effect to the peers the operator targeted.

How a positional token finds its argument

Ze matches the operator's positional tokens against the declared ArgDef list before your handler runs.

  • Every argument kind takes part in the match, typed kinds included. A uint16 port and a plain string are both offered a token, so show tcp-check 127.0.0.1 1 timeout 2s binds 1 to the port instead of skipping it and then failing with required argument missing: port.
  • Mandatory definitions are offered a token before optional ones. An optional pattern-less string can no longer take the value a required argument needed.
  • A single spare token becomes the peer selector when the command declares that it requires one, no selector arrived out of band, and there is exactly one spare token. That is what makes delete bgp peer 127.0.0.1 work when peer is the last key word of the command. A command that takes one token AND declares a mandatory selector, such as announce, is not affected: the one-spare-token fence keeps its argument out of the selector slot.
  • A validation failure is reported at the position where it was found, so the operator-visible message order does not change.

Wildcards and exclusions on a destructive command

A command that acts on ONE peer resolves its selector through the shared resolver. It accepts a name, an address, an ASN (as65001), or a prefix, and it refuses two forms:

Selector Verdict
* or empty Refused: the command requires one specific peer
!edge1, !as65001, !10.0.0.0/24 Refused: an exclusion selector cannot name one peer
A selector matching several peers Refused as ambiguous, never resolved to a guess
A selector matching nothing Refused, with the selector quoted

A filter for a show command is different: a complement is a good answer when the command narrows a list rather than acting on one target.

A proxied command appears once

A plugin-proxied command is registered in both the dispatcher and the plugin command registry, deliberately, so the dispatcher can route to the plugin. The merged command list deduplicates on the name both sources key on, keeps the dispatcher entry, fills an empty dispatcher help text from the plugin description, and sorts by name. Consumers of the list (the MCP tool schema, the API command lister) therefore see one stable entry per command rather than two whose content depended on map iteration order.

A few patterns that come up

Forward to a worker. The handler runs on the plugin's event loop. If the command does real work, push the request onto a channel and return a status that says "accepted" or "in progress". The operator can poll for the result with a separate command.

JSON all the way. data is a string, but everything else in Ze treats the contents as JSON. Return JSON even when the output is trivial. It plays better with ze cli ... | json and with the MCP tools.

Tab completion. The Args field gives Ze a hint for tab completion. For dynamic completion (peer names, family names), the engine generates candidates from its own registry, so you usually do not need to do anything extra.

See also

Adapted from main/docs/plugin-development/commands.md.

Home

About

First Steps

Configuration

Operation

Interfaces

Plugins

Plugin Development

Chaos Testing

Blueprints

Development

Reference

Clone this wiki locally