kinjo 0.3.8

Kinjo: mDNS TUI and commands launch for local network services
Documentation

Kinjo

Browse local DNS-SD services, filter them, and run custom actions from a terminal UI.

GitHub Release Crates.io docs.rs GitHub branch check runs Quality Gate Status License: MIT

Kinjo turns the services already advertised on your local network into a useful, keyboard-driven launcher. Find an SSH server, a device's web interface, a development service, or a printer without memorizing hostnames and ports.

  • Browse services by name, host, type, or matching command.
  • Narrow a busy network with fuzzy search and service-type filters.
  • Launch configurable actions such as SSH or opening a web interface.
  • Discover over standard mDNS/DNS-SD—no external discovery CLI required.
  • Keep everything local: there is no telemetry, account, or cloud service.

Kinjo supports macOS, Linux, and Windows. The default discovery backend works out of the box; Avahi is only needed if you deliberately build and select the optional zeroconf backend.

Installation

Choose the most convenient option for your platform:

Platform Recommended installation
macOS Homebrew
Debian / Ubuntu .deb package or Homebrew
Other Linux Homebrew or Nix
Windows Cargo or build from source

Homebrew (macOS and Linux)

Install Kinjo directly from the abbyssoul/abyss tap:

brew install abbyssoul/abyss/kinjo

That single command adds the tap, installs Kinjo, and includes the default commands. Future releases are available through the usual brew upgrade.

Debian / Ubuntu

Download the .deb for your architecture (amd64 or arm64) from the latest GitHub release, then install it with apt:

sudo apt install ./kinjo_*.deb

The package includes the kinjo binary and default commands. The default discovery backend does not require avahi-daemon or development headers.

Nix / NixOS

Run Kinjo without installing it:

nix run github:abbyssoul/kinjo

The flake provides packages for x86_64-linux and aarch64-linux. To install Kinjo declaratively, add the flake as an input and use its overlay:

# flake inputs:
kinjo.url = "github:abbyssoul/kinjo";

# in your NixOS module:
nixpkgs.overlays = [ inputs.kinjo.overlays.default ];
environment.systemPackages = [ pkgs.kinjo ];

The Nix build uses the default mdns-sd backend, so there is no daemon to enable on NixOS.

Cargo (advanced)

If you already have the Rust toolchain, install Kinjo from crates.io:

cargo install kinjo

This works on macOS, Linux, and Windows and compiles Kinjo locally. Windows is tested in CI but does not currently have a package-manager installation, so Cargo is the simplest Windows option.

Optional discovery backends are selected at compile time. For example, the Avahi-backed zeroconf backend on Linux requires the Avahi client development headers:

cargo install kinjo --features zeroconf

Build from source

Clone the repository and build a release binary:

git clone https://github.com/abbyssoul/kinjo.git
cd kinjo
cargo build --release --locked

The binary will be at target/release/kinjo (target\release\kinjo.exe on Windows). To install it into Cargo's bin directory instead:

cargo install --path .

Contributors can find the full development setup and verification commands in CONTRIBUTING.md.

Try it

Start Kinjo with no configuration to browse the default local domain:

kinjo

Use the arrow keys or j/k to move, / to search, tab to switch views, enter to run a matching action, and ? to see all shortcuts. Press q to quit.

Browse another DNS-SD domain with --domain (-d):

kinjo --domain example.local

From a source checkout, you can explore a deterministic set of sample services even when there is nothing advertising on your network:

cargo run --features fake -- --backend fake --config-dir actions

Discovery backends

The app discovers services over mDNS/DNS-SD so no external CLI tools are required. Backends are selected exclusively with --backend:

  • mdns-sd (default): the mdns-sd-discovery crate. A single browser enumerates every service type on the link via the native DNS-SD meta-query. It accepts a custom --domain.
  • zeroconf: the zeroconf-tokio crate, which talks to the system Avahi daemon on Linux. It browses one service type at a time, so a curated set of common types is swept in parallel when no --service-type is given. This backend is behind the off-by-default zeroconf cargo feature (it needs the Avahi client headers to build, e.g. libavahi-client-dev on Debian/Ubuntu).
  • fake: a finite built-in sample stream for development and deterministic UI smoke tests. It is behind the off-by-default fake Cargo feature so release binaries do not ship actionable sample endpoints unless explicitly requested.
cargo install kinjo --features zeroconf
kinjo --backend zeroconf

Install or build with sample discovery explicitly:

cargo install kinjo --features fake
kinjo --backend fake

Selecting an optional backend in a binary built without it fails with an error that names the Cargo feature to enable.

--fake-discovery has been removed; replace it with --backend fake. See the release notes for the migration decision.

The zeroconf backend browses only the default local domain. Its browser exposes no way to select a domain, so rather than accept --domain and quietly browse local anyway, it refuses the combination up front:

$ kinjo --backend zeroconf --domain corp
error: invalid value for `--domain`: the `zeroconf` backend cannot browse the
`corp` domain: it can only browse the default `local` domain. Browse `local`, or
select the `mdns-sd` backend, which supports custom domains

Use the default mdns-sd backend to browse a custom domain. Empty, local, and local. all name the default domain.

Limiting discovery to one service type

When a --service-type is given, only that type is browsed:

kinjo --service-type _ssh._tcp

The value must be a DNS-SD service type — _<name>._tcp or _<name>._udp, where <name> is 1–15 ASCII letters, digits, and internal hyphens, begins and ends alphanumeric, and contains at least one letter. Service types are case-insensitive, so _SSH._TCP and _ssh._tcp are the same browse.

A value that is not a service type is rejected before discovery starts, rather than being ignored in favour of browsing everything — a filter is there to narrow what the program observes, so a typo must never widen it:

$ kinjo --service-type bogus
error: invalid value for `--service-type`: `bogus` is not a DNS-SD service type:
a service type begins with `_`. Use a type such as `_ssh._tcp` or `_dns-sd._udp`,
or omit it to browse every service type

Omit --service-type to browse every supported type.

Discovery never falls back to sample records. If mDNS discovery is unavailable, the list stays empty and the status line explains why. If a running browse stops unexpectedly, kinjo says so and clears the list rather than leaving stale entries on screen: mDNS is edge-triggered, so once the browse is gone nothing can report that a listed service has since disappeared, and a command launched at one could be pointed at a host that is no longer there. Either way the failure and its cause stay on screen rather than scrolling past — discovery is not retried automatically. Refresh (r) restarts it, and is the recovery action from a failure.

Select --backend fake in a build with the fake feature for sample records on demand. Those samples are a short, finite stream; when it ends the status line reports normal completion and the samples remain listed.

The sample set is chosen to exercise the behavior the real app has: a service reachable at several addresses, a service with no resolved host yet, and SSH on two different hosts — so the _ssh._tcp service-type row aggregates children whose commands differ and asks which host to act on.

Privacy

kinjo browses your local network, so it's worth being explicit about what it does and does not do with that access:

  • No telemetry. kinjo does not phone home, collect analytics, or send usage data anywhere. There is no update checker and no crash reporter.
  • No proactive scanning. kinjo never port-scans or probes hosts on its own initiative. All discovery is delegated to a pluggable backend behind a discovery session (see Architecture), and every backend speaks only the standard mDNS/DNS-SD protocol — it surfaces services that are already being announced on the link, nothing more.
  • Discovered data stays local. Services found on the network are shown in the terminal and used only to fill in the commands you configure. Nothing is uploaded or shared with anyone but you.

The two network discovery backends differ in how they reach the network:

  • mdns-sd (default) implements mDNS/DNS-SD itself: it sends standard multicast queries on the local link and listens for responses. It makes no other network calls and talks to nothing off-link.
  • zeroconf (opt-in, behind the zeroconf cargo feature) delegates all network I/O to the system avahi-daemon over D-Bus. kinjo itself opens no sockets in this mode — it only reads the records the daemon already maintains.

Either way, the traffic involved is the same kind of local multicast query your OS already performs for Bonjour/AirPlay/network-printer discovery — not a general network scan.

How It Works

kinjo has five moving parts:

  1. Discovery finds DNS-SD service records on the network. Each record can carry fields such as service name, service type, domain, hostname, address, port, and TXT values.
  2. Filtering and view tabs organize those records in the UI. You can fuzzy-search the visible services, limit by service type, and switch the top-panel tab to view discovery by service, host, service type, or matching command.
  3. Actions decide what can be done with a selected service. Action command files define match predicates such as "service type equals _ssh._tcp" or "TXT field contains a URL".
  4. Command templates turn service fields into executable commands. For example, ssh {hostname} uses the selected service hostname, while xdg-open http://{hostname}:{port} builds a URL from the selected instance.
  5. Keybindings control the TUI. The defaults use Vim-style navigation, and every built-in UI command can be rebound in keybindings.toml.

The result is a small local service browser that behaves like a configurable launcher: discover services, narrow the list, choose a matching action, and run the command built from that service's fields.

Architecture

Internally those moving parts live in three deliberately decoupled modules, so the project is easy to extend and hack on. Each is designed to be swapped or reused independently:

  1. Discovery (src/discovery/) — the producer of entries. An entry is a discovered record described entirely by its attributes (name, type, host, address, port, TXT, …); Entry is the only contract the rest of the program depends on. Starting discovery hands back a DiscoverySession: one value owning the running adapter, its events, its state, and its shutdown, so the caller cannot hold a receiver whose producer has silently died, and dropping it stops the browse. Adapters vary behind that session — the mDNS/Avahi backend is the default, with a feature-gated built-in sample backend selected by --backend fake — so a different DNS-SD source, a static file, or an SSDP/UPnP browser slots in beside them without disturbing anything above (see Extending it for where that seam is). Discovery options are checked once at that seam: a DiscoveryConfig is a request, and validating it yields the DiscoveryOptions that starting an adapter requires. A malformed service type, or a domain the selected backend cannot honour, is therefore refused before anything spawns — no adapter can be reached with a value it would have to quietly reinterpret.
  2. Plumber (src/plumber/) — the rules engine. A serializable collection of command rules (the TOML command files) is matched against entries by their attributes; multiple rules can match one entry, and a matching rule can be executed. It depends only on Entry, never on the UI, and sits behind the RuleEngine trait so an alternative matching strategy can be substituted.
  3. UI (src/ui/) — ties discovery and the rules engine together for a person at the terminal: CLI parsing, config and keymap loading, the application state machine, and rendering. It depends on the other two; they do not depend on it. App is the event loop and the state it decides from; its interface is six operations — build it, attach a discovery factory and a config loader, hand out its reload trigger, run it, and take any reload diagnostics that outlived the terminal. Its state is not public. Rendering is a pure function of it (&App in, a frame out), and the app/render boundary is field visibility rather than a projected view — see ADR 0002.

The dependency flow is one-directional — discovery ← plumber ← ui — wired together by run in src/lib.rs, which the kinjo binary is a thin wrapper around.

Extending it

The two layers are extensible in deliberately different ways, and the difference is the interesting part:

  • A rule engine is substitutable from outside. RuleEngine is public, and App::new accepts any implementor, so a crate depending on kinjo can bring its own matching strategy. Matcher is the engine kinjo ships, not the only one the interface admits. Doing so means writing your own composition root: run loads a Matcher and uses it concretely, so it is not generic over the engine — do what run does, with your engine in its place. See ADR 0001.
  • A discovery backend is not. Adapters vary inside src/discovery/, at the browse loop, behind one concrete DiscoverySession and a closed DiscoveryBackend enum. They differ in how they browse but not in how a caller runs and stops them, so there is no trait to implement: a new source — a static file, an SSDP/UPnP browser — is a module and an enum variant in this crate, not something a dependent adds from outside.

See CONTRIBUTING.md for development setup.

UI

Default keys follow Vim-style conventions:

  • j / down: move down
  • k / up: move up
  • enter: show or run matching actions
  • /: fuzzy text filter
  • t: service type checklist filter
  • tab / shift+tab (or / ): switch view tab
  • s: narrow the list to the selected row's host (press again to clear)
  • d / u (or page down / page up): scroll the details pane
  • r / F5: refresh — restart service discovery from scratch
  • ?: help
  • q: quit

The top panel exposes four tabs — services, hosts, types, and commands. Each tab swaps the list and details panes to view discovery from that angle: individual services, hosts and the services they offer, discovered service types, or configured commands and the services they match. The list also supports fuzzy text search and service type filtering.

Each tab shows how many rows it lists: logical services, hosts (plus one row for the registrations that have not resolved a host yet), distinct service types, or configured commands. The aggregate tabs report only what is true of a whole row. A host's details name the host and list every service on it — each with its own type, port, and TXT data — rather than presenting one service's fields as the host's. A service type's details likewise list every host offering it. Actions always run against a concrete discovered service, whichever tab they start from.

The indicator at the top left says what discovery is doing, and it only animates while something is actually happening:

Indicator Meaning
(spinning) Browsing. The list may still change.
A sample stream finished normally. Its records stay valid; nothing more will arrive.
Discovery failed or stopped. Its records are no longer being confirmed.

A still indicator therefore means the list is final until you refresh (r), which starts a new browse and returns the indicator to spinning. The empty-list message says the same thing in words, so the two can never disagree. Neither ending retries by itself.

The type filter (t) lists the service types currently being advertised, and its types n/m chip counts only those: m is how many types are on the link right now, and n is how many of them you are showing. Switching a type off is remembered rather than observed, so a device that drops off the link and comes back stays switched off — but a type nobody is advertising is counted on neither side of the chip.

The details pane keeps your scroll position while you stay on the same row, including as discovery re-reports it. Moving to a different row starts its details from the top, and a row that shortens or a terminal that grows pulls the view back to the end of the content rather than past it.

The s (same-host) filter needs a row with a single host, so it is offered in the services and hosts tabs. The types and commands tabs report it unavailable instead of guessing a host; an active filter can be cleared from any tab.

Keybindings are fully customizable: all built-in UI commands can be rebound with a keybindings config file. See docs/keybindings.md for the full keybinding reference and examples.

Configuration

Command files follow the XDG Base Directory Specification. User command files are loaded from:

$XDG_CONFIG_HOME/kinjo/commands/*.toml

If XDG_CONFIG_HOME is not set, the fallback path is:

~/.config/kinjo/commands/*.toml

Additional command directories can be provided with:

kinjo --config-dir ./commands

Validate and list the registered commands with:

kinjo list-commands

To validate and list only the commands from a specific directory:

kinjo list-commands --config-dir ./commands

--config-dir may be written on either side of list-commands, and repeated on both; the two lines below are equivalent. Directories always overlay in the order they appear on the command line, whichever side they were written on.

kinjo --config-dir ./commands list-commands
kinjo list-commands --config-dir ./commands

A running instance reloads its command files on SIGHUP (the conventional reload signal), so edits apply without restarting the TUI:

pkill -HUP kinjo

The reload is transactional: it applies only if every configured command file is valid. If one is not, the commands already loaded stay in force and the reason is reported, rather than a half-saved edit taking away a command you were using. See docs/actions.md.

Keybindings can be overridden at:

$XDG_CONFIG_HOME/kinjo/keybindings.toml

See docs/keybindings.md for examples and the complete list of bindable commands.

Command Files

Each command file defines one action and structured match predicates. Example SSH opener:

[metadata]
name = "ssh"
description = "SSH into a service"
requirements = ["ssh"]

[match.service_type]
equals = "_ssh._tcp"

[action]
description = "SSH into the selected service"
command = "ssh -- {hostname}"
mode = "execute"

Supported action modes:

  • fork: spawn the command and return to the TUI.
  • execute: restore the terminal and replace the TUI process with the command.

Supported match predicates:

  • equals
  • contains
  • regex

Supported service fields:

  • name
  • service_type or type
  • domain
  • hostname
  • address
  • port
  • txt.<key>

The same fields can be used in action command interpolation, for example {hostname}, {address}, and {port}.

Multiple configured actions can match the same service. In that case, the TUI shows an action picker. If an action needs instance-specific fields such as address or port and the selected row contains multiple instances, the TUI asks which exact instance to use.

For the full command file format, examples, and overlay rules, see docs/actions.md.

Contributing

Bug reports, feature ideas, documentation fixes, and pull requests are welcome. If you are not sure where to start, open an issue or see CONTRIBUTING.md for the development setup and local verification commands.