Feature request: add a `msg` subcommand to kanata binary to act as its own TCP client
Is your feature request related to a problem? Please describe.
The TCP server has no first-party client, so every integration has to re-implement the same transport layer before it can send a single command.
My use case is application-aware layer switching on macOS: a small daemon watches
NSWorkspace.didActivateApplicationNotification and sends ChangeLayer on every app
switch. Conceptually that is "send one JSON object". In practice it required ~200 lines
of transport code, because a correct client has to deal with all of the following:
Framing. The protocol is newline-terminated JSON over a byte stream (
docs/config.adoc: "Each message is a single line of JSON terminated by a newline"), so the client needs its own line-splitting layer on top of TCP. On Apple platforms that means implementingNWProtocolFramerImplementation; in other languages, a hand-rolled buffer-and-split loop.Reconnection. kanata may restart, the socket is not available before the server starts, and
src/tcp_server.rsdeliberately drops the connection when a client sends something it cannot deserialize (ServerResponse::Error+connections.lock().remove(&addr)). A long-lived client needs idempotent reconnect/backoff logic.A heterogeneous response contract. On the same socket, three different things can arrive, and which one you get depends on the command:
ChangeLayerandSetMouse→ no response at all.ActOnFakeKey→ aServerMessage::Erroronly on failure, nothing on success, so a client can only conclude "it worked" by waiting out a timeout.Reload/ReloadNext/ReloadPrev/ReloadNum/ReloadFile→ServerResponse({"status":"Ok"}/{"status":"Error","msg":...}), plus a laterServerMessage::ReloadResultwhenwait: true.RequestLayerNames/RequestFakeKeyNames/RequestCurrentLayerName/RequestCurrentLayerInfo/Hello→ aServerMessagevariant.- Unsolicited events (
LayerChange,ConfigFileReload,MessagePush,HoldActivated,TapActivated) are pushed at any time and interleave with query responses, so a client cannot simply "read the next line" after a request — it must filter by variant.
Silent failure on a bad layer name.
Kanata::change_layerwalkslayer_infoand returns without doing anything if no name matches (src/kanata/mod.rs), and the TCP handler sends no response forChangeLayer. So a wrong or stale layer name is indistinguishable from success. To get any error signal at all, my daemon has to sendRequestLayerNamesfirst, cache the set, and validate locally before every send.
The only integration path the documentation offers today is
echo '{"ChangeLayer":{"new":"nav"}}' | nc localhost 7070. That is fine as a protocol
illustration but not usable as an interface: it cannot correlate a response with a
request (point 3), its exit status reflects nc's socket, not kanata's outcome, and nc
is not present on a default Windows install while kanata is cross-platform.
The result is that the same 150–250 lines of client code get rewritten, per language, in every downstream project, and each copy silently rots as protocol details change.
Describe the solution you'd like.
A send-cmd subcommand in the kanata binary itself, i.e. kanata ships its own client —
the same pattern as sing-box api ... and paneru send-cmd ...:
$ kanata send-cmd --help
Send a command to a running kanata instance via its TCP server
Usage: kanata send-cmd [OPTIONS] <COMMAND>
Commands:
change-layer Switch to a layer -> ChangeLayer
list-layers List all layer names -> RequestLayerNames
current-layer Print the active layer name -> RequestCurrentLayerName
current-layer-info Print active layer name + cfg text -> RequestCurrentLayerInfo
list-fake-keys List virtual key names -> RequestFakeKeyNames
act-on-fake-key Press/Release/Tap/Toggle a vkey -> ActOnFakeKey
set-mouse Move the cursor to x,y -> SetMouse
reload Reload the current config -> Reload
reload-next Load the next config -> ReloadNext
reload-prev Load the previous config -> ReloadPrev
reload-num Load config at index -> ReloadNum
reload-file Load a config by path -> ReloadFile
hello Print server version/capabilities -> Hello
raw Send a raw JSON ClientMessage -> (escape hatch)
Options:
-p, --port <PORT or IP:PORT> Same syntax/parsing as the server's --port.
Required, since the server itself has no default port;
could fall back to a $KANATA_PORT env var.
--timeout-ms <MS> Connect/read timeout [default: 5000]
--json Print the raw server message instead of plain text
-h, --help Print helpExamples:
kanata send-cmd -p 9999 change-layer nav
kanata send-cmd -p 9999 list-layers # base\nnav\nnum
kanata send-cmd -p 9999 list-layers --json # {"LayerNames":{"names":["base","nav","num"]}}
kanata send-cmd -p 9999 current-layer
kanata send-cmd -p 9999 act-on-fake-key email-sig tap
kanata send-cmd -p 9999 reload --wait --timeout-ms 5000
kanata send-cmd -p 9999 raw '{"ChangeLayer":{"new":"nav"}}'Behaviour I would ask for, since these are the parts a shell script cannot do itself:
- Meaningful exit codes.
0on success; non-zero and a message on stderr for connect failure, timeout,{"status":"Error"}, or aServerMessage::Error. This is the single most valuable part for scripting. - Response correlation. Read lines until the response variant that belongs to the request arrives (or the deadline passes), discarding unrelated pushed events.
- Client-side validation for
change-layer. QueryRequestLayerNamesfirst and exit non-zero with the available names listed if the requested layer does not exist. This turns today's silent no-op (point 4 above) into a real error without changing the wire protocol or server behaviour at all. rawescape hatch so newClientMessagevariants are reachable before a dedicated subcommand exists, and so the subcommand set never becomes a bottleneck.
Notes on fitting this into the existing CLI:
src/main_lib/args.rsis currently a flag-onlyclap::Parser. To keep this a non-breaking change,send-cmdshould be an optional subcommand (#[command(subcommand)] cmd: Option<Command>, withargs_conflicts_with_subcommands), so barekanata -c foo.kbdkeeps behaving exactly as it does today.- It can be gated on the existing
tcp_serverfeature (already indefault), so no new feature flag and no new release artifact. - The implementation should be small:
tcp_protocol::{ClientMessage, ServerMessage, ServerResponse}is already a workspace crate, andexample_tcp_client/src/main.rsalready contains most of the connect/read/parse logic. This is largely clap wiring plus moving that example into the binary, where it stays version-locked to the server. docs/config.adoc"Client Commands" could then showkanata send-cmd ...alongside thencone-liners, and the protocol tables become the reference rather than the API.
Describe alternatives you've considered.
nc/socatone-liners (what the docs recommend today). Rejected: no request/response correlation, exit status does not reflect kanata's outcome, and noncon a default Windows install.- Build and ship
example_tcp_client. Requires a Rust toolchain on the user's machine, produces a second binary to distribute and keep in sync, and the example is explicitly a demo rather than a supported interface. Every downstream project ends up vendoring its own fork of it. - Write a client per language (what I actually did). ~200 lines of Swift for framing,
reconnect, encode/decode, and local layer-name validation, none of which is specific to
my use case.
kanata-tray,qanata, and other community projects each carry an equivalent copy. This works, but it duplicates protocol knowledge outside the repo where it cannot be updated with the protocol. - A separate
kanata-cmd/kanata-clientbinary. Solves the same problem but adds a release artifact per platform and introduces client/server version skew. Putting the client in the same binary avoids both by construction. - Leaving long-lived subscribers out of scope. A client that wants pushed
LayerChange/MessagePush/HoldActivatedevents still needs a persistent socket, andsend-cmdis deliberately not proposed as a replacement for that. (Akanata listen-style streaming mode would be a natural follow-up, but I am not asking for it here.) - Known cost, stated plainly: driving
send-cmdfrom an event-driven daemon means one process spawn per event (~10–20 ms on macOS) instead of one persistent socket. For app-switch-triggered layer changes that is not perceptible, and it removes ~150 lines of transport code from my side. Anyone for whom the spawn cost matters can still use the raw protocol — this feature adds an option, it does not take one away.
Additional context
Prior art — the same shape in comparable daemons.
paneru (Unix socket, same "send a command to my daemon" role):
$ paneru send-cmd --help
Sends a command via a Unix socket to the running `paneru` daemon
Usage: paneru send-cmd [CMD]...
Arguments:
[CMD]...
$ paneru send-cmd window shrink
$ paneru send-cmd window focus unmanaged
$ paneru send-cmd window togglefloatlayersing-box (structured subcommand group over its API, with connection flags):
$ sing-box api --help
API service client
Usage:
api [command]
Available Commands:
connection Manage connections
group Manage outbound groups
logs Print the service logs
mode Print the current clash mode
outbounds List outbounds
status Print the service status
version Print the API service version
Flags:
--secret string API service secret (default: $BOX_API_SECRET)
--url string API service URL (default: $BOX_API_URL)
$ sing-box api --url 127.0.0.1:9091 --secret 11 mode list
$ sing-box api --url 127.0.0.1:9091 --secret 11 connection listThe transport differs (paneru uses a Unix socket, kanata uses TCP), but that is
irrelevant to the CLI shape: in both projects the daemon binary doubles as the client, and
integrators write one shell line instead of a socket client.
Source references for the claims above (kanata main @ 1.12.1-prerelease-1):
docs/config.adoc, "TCP Protocol Overview" / "Client Commands" / "Server Messages" — newline-framed JSON,ncexamples, full message tables.tcp_protocol/src/lib.rs—ClientMessage(13 variants),ServerMessage(12 variants),ServerResponse({"status":...}).src/tcp_server.rs—ChangeLayerandSetMousesend no response;ActOnFakeKeywrites aServerMessage::Erroron failure only;handle_reload_with_waitsendsServerResponsethen optionallyReloadResult; an undeserializable message triggersServerResponse::Errorand disconnects the client.src/kanata/mod.rs,Kanata::change_layer— returns silently when no layer matches.src/main_lib/args.rs— flag-onlyclap::Parser, no subcommands today.Cargo.toml—tcp_serveris indefaultfeatures.
Source: jtroo/kanata