Kinjo
Browse local DNS-SD services, filter them, and run custom actions from a terminal UI.
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:
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:
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:
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:
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:
Build from source
Clone the repository and build a release binary:
The binary will be at target/release/kinjo (target\release\kinjo.exe on
Windows). To install it into Cargo's bin directory instead:
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:
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):
From a source checkout, you can explore a deterministic set of sample services even when there is nothing advertising on your network:
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): themdns-sd-discoverycrate. A single browser enumerates every service type on the link via the native DNS-SD meta-query. It accepts a custom--domain.zeroconf: thezeroconf-tokiocrate, 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-typeis given. This backend is behind the off-by-defaultzeroconfcargo feature (it needs the Avahi client headers to build, e.g.libavahi-client-devon Debian/Ubuntu).fake: a finite built-in sample stream for development and deterministic UI smoke tests. It is behind the off-by-defaultfakeCargo feature so release binaries do not ship actionable sample endpoints unless explicitly requested.
Install or build with sample discovery explicitly:
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:
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.
kinjodoes not phone home, collect analytics, or send usage data anywhere. There is no update checker and no crash reporter. - No proactive scanning.
kinjonever 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 thezeroconfcargo feature) delegates all network I/O to the systemavahi-daemonover D-Bus.kinjoitself 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:
- 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.
- 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.
- 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". - Command templates turn service fields into executable commands. For example,
ssh {hostname}uses the selected service hostname, whilexdg-open http://{hostname}:{port}builds a URL from the selected instance. - 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:
- Discovery (
src/discovery/) — the producer of entries. An entry is a discovered record described entirely by its attributes (name, type, host, address, port, TXT, …);Entryis the only contract the rest of the program depends on. Starting discovery hands back aDiscoverySession: 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: aDiscoveryConfigis a request, and validating it yields theDiscoveryOptionsthat 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. - 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 onEntry, never on the UI, and sits behind theRuleEnginetrait so an alternative matching strategy can be substituted. - 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.Appis 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 (&Appin, 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.
RuleEngineis public, andApp::newaccepts any implementor, so a crate depending onkinjocan bring its own matching strategy.Matcheris the engine kinjo ships, not the only one the interface admits. Doing so means writing your own composition root:runloads aMatcherand uses it concretely, so it is not generic over the engine — do whatrundoes, 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 concreteDiscoverySessionand a closedDiscoveryBackendenum. 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 downk/up: move upenter: show or run matching actions/: fuzzy text filtert: service type checklist filtertab/shift+tab(or←/→): switch view tabs: narrow the list to the selected row's host (press again to clear)d/u(orpage down/page up): scroll the details paner/F5: refresh — restart service discovery from scratch?: helpq: 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:
If XDG_CONFIG_HOME is not set, the fallback path is:
Additional command directories can be provided with:
Validate and list the registered commands with:
To validate and list only the commands from a specific directory:
--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.
A running instance reloads its command files on SIGHUP (the conventional
reload signal), so edits apply without restarting the TUI:
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:
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:
[]
= "ssh"
= "SSH into a service"
= ["ssh"]
[]
= "_ssh._tcp"
[]
= "SSH into the selected service"
= "ssh -- {hostname}"
= "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:
equalscontainsregex
Supported service fields:
nameservice_typeortypedomainhostnameaddressporttxt.<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.