Command plugins are long-lived subprocesses that contribute top-level CLI commands and can converse with Restish over a multi-message CBOR protocol while they run.
This plugin type is for workflow-oriented features that do not fit naturally into a single request hook.
In practice that often means one plugin-defined top-level entry point, such as
bulk or mcp, with the plugin itself owning any nested subcommands and
workflow-specific flags behind that entry point.
Some extensions need to:
- expose their own user-facing subcommands
- coordinate multiple HTTP requests
- stream progress or warnings while work is running
- optionally pass stdin/stdout through as part of an interactive session
Trying to model that as repeated hook invocations would be awkward and would force the plugin to reinvent parts of the CLI lifecycle itself.
A command plugin declares the command hook in its manifest. Restish then
discovers contributed commands in a separate startup phase by invoking the
plugin with --rsh-plugin-commands.
That command returns a CBOR payload describing one or more commands, including:
nameshortlongpassthrough_stdio
Each declaration becomes a Cobra command on the root CLI.
Restish deliberately disables host-side flag parsing for those commands before
launching the plugin. That lets the plugin receive raw arguments such as
bulk init --url-template=... and implement its own subcommand tree, flags,
and --help behavior without forcing the host to understand every plugin's UX.
After the plugin command name, short flags such as -f or -v are
plugin-owned even when they overlap Restish global shorthands. Explicit
Restish-owned long flags such as --rsh-config may still be stripped before
plugin launch if they were already consumed by the host, but shorthand aliases
must not be silently removed from plugin argv.
Restish also injects terminal context flags before plugin-owned args. These include stdout/stderr TTY state, whether color is enabled, and the configured terminal theme entries. Command plugins that render human output should use that context so plugin output matches built-in command output and user themes.
Command-name collisions must be handled explicitly. If a plugin tries to add a command that would shadow a built-in or another plugin command, Restish should skip it with a warning or fail startup, but never silently shadow the existing command.
When the user runs one of those commands, Restish starts the plugin and sends an initial message:
{
"type": "init",
"command": "<declared name>",
"args": ["..."]
}From there, the plugin and Restish exchange CBOR data items until the plugin
sends done or dies.
The host owns session lifetime. "Sent done" is not enough by itself if the
process refuses to exit; the host must still close stdin and use a bounded wait
before killing the process.
All command-plugin paths should share one wait result for the subprocess so
startup cleanup, cancellation, and normal shutdown cannot call Wait twice and
turn the real exit status into "wait was already called".
The current implementation handles these message types:
http-requestto ask Restish to perform an HTTP callapi-specto ask Restish to resolve a registered API specresponseto ask Restish to format and print a normalized responsestdout-dataandstderr-datato write raw bytes directlyprogress,spinner, andlogto print status text on stderrwarnto print a warning-prefixed messagedoneto terminate with an exit code
Prompt/confirm style interactions are also valid protocol concepts when a plugin needs host-owned prompting behavior.
Restish currently sends:
initwhen the command startshttp-responseafter handling anhttp-requestapi-spec-responseafter handling anapi-specstdin-dataandstdin-closewhenpassthrough_stdiois enabled
Long-lived request/response pairs use request_id correlation identifiers so
plugins can issue more than one delegated HTTP request at a time. The host
continues reading plugin messages while HTTP work runs, and replies include the
same request_id as the original request. The public CommandClient.Do helper
assigns request IDs when needed and routes replies back to the goroutine that
sent the request.
For passthrough_stdio, the public client dispatches stdin-data and
stdin-close through a bounded asynchronous queue. Slow stdin consumers must
not block reply delivery on the command-plugin read loop; if the queue fills,
the client fails pending requests with a clear queue-full error instead of
growing memory without bound.
The most important design choice is that command plugins delegate HTTP back to
Restish instead of constructing their own transport stack. When a plugin sends
http-request, Restish performs the request through the normal request path,
including:
- profile and API-base-URL resolution
- TLS signer resolution
- request middleware
- retries, caching, and output normalization
The reply sent back to the plugin is the normalized response shape, not a raw
*http.Response.
That keeps workflows consistent with the rest of the CLI and avoids creating a second HTTP client implementation inside every plugin.
Command plugins have two output modes:
- ask Restish to print a normalized
responseusing the current formatter and filter rules - emit raw
stdout-dataorstderr-databytes directly
This lets a workflow choose between "behave like a normal Restish command" and "own the terminal output myself" on a message-by-message basis.
Public helper names should make these side effects obvious. Methods such as
WriteStdout and WriteStderr are preferable to names that look like simple
accessors, because they write directly to the user's output channels.
Plugins that opt into passthrough stdio must still coexist with the host's session rules. The host must avoid leaking stdin readers that steal later shell keystrokes after plugin exit.
Restish also passes simple terminal context flags on plugin startup:
--rsh-stdout-tty=<bool>--rsh-stderr-tty=<bool>--rsh-color=<bool>
That gives plugins enough signal to adapt interactive behavior without coupling them tightly to the terminal implementation.
These flags live in the host-injected argv prefix. Plugins must parse terminal
context only until the first non-startup argument and then treat the rest of
argv as user input. This prevents commands such as
restish bulk init URL --rsh-plugin-manifest from switching the plugin into
manifest discovery mode or overriding the terminal context.
Command discovery responses include a protocol_version field. Version 0 is
treated as the initial command-plugin discovery shape for compatibility with
older helpers, and versions greater than the host's current command-plugin
protocol are rejected before commands are registered.
The host ends a command-plugin session when:
- the plugin sends
done - the plugin exits
- the host context is canceled
- a protocol error occurs
Termination should include:
- closing stdin to the plugin
- bounded wait for clean exit
- force kill if needed
- surfacing non-zero exit after
donewhen it indicates a late crash - flushing any buffered plugin stderr line before returning
Protocol errors should identify:
- plugin name
- message type
- decode or semantic failure
The host should prefer typed protocol errors over generic "unexpected reply" messages where possible.
Plugin-side helpers should report write, decode, and reply errors to stderr and exit nonzero. Dropping protocol errors silently makes plugin failures look like successful no-op commands.
That would be simpler mechanically, but the plugin would lose access to Restish-managed HTTP, config, and formatting. The protocol is what keeps the workflow integrated.
This would duplicate auth, retry, caching, TLS, and normalization logic across plugins. Delegation back into Restish is a better fit for consistency and maintainability.
That would make progress reporting, streamed stdin, and multi-step workflows far more awkward than keeping one long-lived session.
The current implementation is centered in
internal/cli/command_plugin.go,
with end-to-end examples in
internal/cli/testdata/testplugin/main.go
and tests in
internal/cli/command_plugin_test.go.
One detail worth preserving is that command plugins add commands at root-command construction time, not through late dynamic dispatch. That keeps help output, completion, and command discovery aligned with built-in commands.