kinjo 0.3.8

Kinjo: mDNS TUI and commands launch for local network services
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# Custom Commands

Custom commands let `kinjo` turn a discovered service into an action, such as
opening a URL or starting an SSH session. Each command lives in one `.toml` file.

## Locations

User commands are loaded from:

```sh
$XDG_CONFIG_HOME/kinjo/commands/*.toml
```

If `XDG_CONFIG_HOME` is not set, the fallback path is:

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

System-wide commands are loaded from:

```sh
/etc/kinjo/commands/*.toml
```

Commands bundled with a relocatable install (a release tarball run in place,
or a prefix install such as Homebrew) are loaded relative to the binary:

```sh
<exe_dir>/commands/*.toml
<prefix>/share/kinjo/commands/*.toml   # binary at <prefix>/bin/kinjo
```

Extra directories can be added with `--config-dir`. The flag is repeatable:

```sh
kinjo --config-dir ./team-commands --config-dir ./local-commands
```

Directories are loaded as overlay layers in this order:

1. `<exe_dir>/commands`, then `<prefix>/share/kinjo/commands`
2. `/etc/kinjo/commands`
3. `$XDG_CONFIG_HOME/kinjo/commands` or `~/.config/kinjo/commands`
4. each `--config-dir` in the order given

If a later layer defines a command with the same `metadata.name`, it overrides
the earlier command. Duplicate names within the same directory layer are errors.

## Validate Commands

Use `list-commands` to validate and list the registered commands:

```sh
kinjo list-commands
```

To validate only specific directories:

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

`list-commands` is a real validator, not just a TOML syntax check. A command
file is compiled the same way it is when the TUI loads it, so everything that
could make a rule impossible to run is reported here rather than when someone
selects the action:

- an empty `metadata.name`, or an empty or whitespace-only `action.command`;
- an unsupported `[match.<field>]` field or predicate kind, and an invalid regex;
- an unknown, empty, nested, or unterminated `{placeholder}`;
- an unterminated quote or a dangling backslash in `action.command`;
- a malformed `requirements` entry.

It fails on the first invalid file and names it. Anything `list-commands`
accepts can be matched and prepared.

Requirements are the one thing it does not check: whether a program is installed
is a property of the machine, not of the file, and it is re-checked each time an
action runs.

## Reloading While It Runs

A running `kinjo` re-reads its command files on `SIGHUP`, so an edit applies
without restarting the TUI:

```sh
pkill -HUP kinjo
```

A reload is all-or-nothing. It loads the same directories in the same order as
startup, and installs the result only if **every** file in **every** directory
is valid. If any file is malformed — or any configured directory cannot be read
— nothing changes: the commands already loaded stay loaded, and stay runnable.

This is deliberately stricter than startup, because the two are not the same
situation:

| | Startup | Reload (`SIGHUP`) |
|---|---|---|
| An invalid file is | skipped, with a warning naming it | a reason to keep the current commands |
| The other files | still load | are not loaded either |
| The outcome | a partly working `kinjo` | the rule set you already had |

At startup, skipping a bad file is the difference between a partly working
`kinjo` and no `kinjo` at all — one stale file in `/etc/kinjo/commands` must not
stop the app launching. At reload there is already a rule set that works, so
skipping a bad file would trade it for a *smaller* one, and a command you use
would quietly disappear mid-session because a file was caught half-saved.
Refusing the reload leaves you exactly where you were: the edit simply has not
taken effect yet.

A rejected reload says so on the status line, naming how many files were invalid
and how many commands remain in force. The details — every invalid file, with
its full path and the reason — are printed to the terminal when `kinjo` exits,
where the status line cannot lose them:

```text
reload rejected: /home/you/.config/kinjo/commands/ssh.toml: unterminated quote
```

Only the most recent reload is reported: fixing the file and signalling again
clears it, because a reload always describes the configuration as it is now, not
the edits you have already corrected. To see the same errors immediately instead
of on exit, run `kinjo list-commands`, which validates the identical way.

## File Format

A command file has three parts:

```toml
[metadata]
name = "open-http"
description = "Open HTTP service in a browser"
requirements = ["xdg-open"]

[match.service_type]
regex = "^_http\\._tcp$"

[action]
description = "Open in browser"
command = "xdg-open http://{hostname}:{port}"
mode = "fork"
```

`metadata.name` is required, must not be empty, and is the stable command
identity used for overlay replacement. `metadata.description` and
`metadata.requirements` are optional; see [Requirements](#requirements).

`action.command` is required and must not be empty or whitespace only. It is the
command template to run for the selected service — a template, not a shell
command line; see [Command Templates](#command-templates). `action.description`
is optional and is shown in the action picker. `action.mode` is required and
must be one of:

- `fork`: spawn the command and return to the TUI.
- `execute`: restore the terminal and replace the TUI process with the command.
- `exec`: alias for `execute`.

## Requirements

`metadata.requirements` lists the programs a command needs. Each entry is
written in exactly one of two forms, after surrounding whitespace is trimmed:

```toml
requirements = ["xdg-open", "browser, optional"]
```

- `<program>`**mandatory**. If it cannot be found, `kinjo` refuses to run the
  action and reports it on the status line. Nothing is launched.
- `<program>, optional`**optional**. It is shown in the UI and never blocks
  the action. Use it for a dependency the command can do without.

The `, optional` marker is case-insensitive. Any other suffix, an extra comma,
or an empty program name is a configuration error and the file is rejected. This
is deliberately strict: a typo such as `"browser, optinal"` would otherwise be
read as a *mandatory* requirement named `browser`, silently blocking the action
it was meant to make optional.

A program is looked up the same way the operating system would when starting it:

- a name containing a path separator (`/usr/local/bin/tool`) is used as-is;
- a bare name (`ssh`) is searched for in each `PATH` directory, and must resolve
  to a file with an execute bit set. On Windows, `PATHEXT` extensions are tried
  too, so `cmd` finds `cmd.exe`.

Mandatory requirements are checked immediately before the action runs, not when
the file is loaded, so installing a missing tool takes effect without restarting
`kinjo`.

`kinjo` never installs anything. Requirements describe what a command needs so
that a missing dependency is reported clearly instead of surfacing as a failed
launch; installing it is up to you.

## Matching Services

Each `[match.<field>]` section adds predicates for a service field. All
predicates must match for the command to be offered.

Supported predicates:

- `equals`: exact string match.
- `contains`: substring match.
- `regex`: Rust regular expression.

Supported fields:

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

DNS-SD TXT keys are case-insensitive and are shown in canonical lowercase.
Kinjo accepts valid DNS-SD keys (1-255 printable ASCII characters excluding `=`)
and exact UTF-8 values. Binary TXT values are ignored rather than decoded with
replacement characters, so they cannot accidentally satisfy a text predicate
or become a process argument.

A service may advertise several addresses. Every `[match.address]` predicate is
applied to the *same* single address, so a command matches only if one concrete
address satisfies all of them at once, and it can then only run against such an
address. Predicates that no single address can satisfy together — for example
`contains = "10."` and `regex = ":"` on a dual-stack host — match nothing:

```toml
[match.address]
contains = "10."
regex = "\\.99$"    # only an address matching BOTH is offered
```

Example matching a service type and a TXT record:

```toml
[metadata]
name = "open-printer-admin"
description = "Open printer admin page"
requirements = ["xdg-open"]

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

[match.txt.adminpath]
contains = "/"

[action]
description = "Open printer admin"
command = "xdg-open http://{hostname}:{port}{txt.adminpath}"
mode = "fork"
```

## Command Templates

The same service fields can be used as placeholders in `action.command`:

```toml
command = "ssh -- {hostname}"
```

Common placeholders:

- `{name}`
- `{service_type}`
- `{type}`
- `{domain}`
- `{hostname}`
- `{address}`
- `{port}`
- `{txt.<key>}`

### Choosing a target

A row may cover several services — a host row collects everything on one host, a
service-type row everything advertising one type — and a service may advertise
several addresses. When you invoke a command there, `kinjo` decides whether to
ask you which one it should act on by looking at *what the command would
actually run*:

- It builds the command for every service and address the rule matches.
- Candidates producing the **identical** command collapse into one, and it runs
  without asking. There is nothing to choose between two identical commands.
- If two candidates produce **different** commands, `kinjo` asks which target to
  use, and runs only the one you pick.

This follows from the command line itself, so **any** placeholder that varies
across a row causes the question — `{hostname}`, `{name}`, `{service_type}`,
`{domain}`, `{port}`, `{address}`, or a `{txt.<key>}`. A command that
interpolates nothing, such as `echo hello`, runs once however many services the
row holds.

For example, `ssh {hostname}` on a service-type row covering two hosts asks
which host, because it would otherwise pick one for you. The same rule on a row
whose services all share one host does not ask, because both candidates would
run the very same command.

Discovery carries on while you are choosing. The list you are shown is rebuilt
as services appear, change, and go, so it always reflects what is currently
advertised. If the target you had selected is retracted, or changes such that it
would now run a different command, the picker closes and says so rather than
letting you confirm something that no longer exists — and it never moves your
selection onto a neighbouring service. Changes elsewhere leave your selection
alone.

A command using `{address}` needs one concrete address. With no
`[match.address]` predicate, every advertised address is offered for selection;
with predicates, only the addresses satisfying all of them are. A service whose
addresses are not (yet) resolved offers no such command at all, rather than
failing once it is run. If the rule constrains the address but does not
interpolate it, every satisfying address builds the same command, so `kinjo`
does not ask.

### Quoting and Escaping

`action.command` is split into an argument vector by `kinjo` and handed straight
to the operating system. It is **never passed through a shell**, so there is no
expansion, no environment substitution, no pipelines (`|`), no redirection
(`>`), and no command chaining (`&&`, `;`). Those characters have no special
meaning; they are ordinary text.

The full grammar:

- Unquoted whitespace separates arguments.
- Single (`'`) and double (`"`) quotes remove their delimiters and preserve
  their contents. The other quote style is literal text inside them, so
  `"it's"` is one argument: `it's`.
- Adjacent quoted and unquoted fragments form **one** argument:
  `user@"{hostname}":22` is a single argument.
- A backslash escapes exactly the next character, inside or outside quotes:
  `one\ arg` is one argument, and `\{` is a literal `{`.
- A quoted empty string is a real, preserved argument: `cmd "" next` passes
  three arguments, the middle one empty.
- A dangling backslash at the end, or an unclosed quote, is an error.

```toml
command = "ssh -- '{hostname}'"
```

### Placeholders

- `{field}` interpolates a supported service field.
- `{{` emits a literal `{`.
- A lone `}` is literal text, so `echo {hostname}}` ends with a `}`.
- An empty (`{}`), nested (`{a{b}}`), unknown (`{nonexistent}`), or unterminated
  (`{hostname`) placeholder is an error.

### Interpolation Is Safe

Argument boundaries are decided when the command file is loaded, before any
service exists. A discovered value only ever fills in an argument, so it cannot
add, remove, or split one — whatever it contains.

Service names, hostnames, and TXT values come from devices on the network and
are not trusted. A service advertising itself as
`evil" && rm -rf / #` is passed through as one ordinary argument containing
those exact characters. This is why quoting a placeholder is a readability
choice rather than a safety one: `{hostname}` and `'{hostname}'` are equally
safe.

Argument boundaries are not the whole option story: many programs interpret a
single argument beginning with `-` as an option. Kinjo therefore rejects a
text-field-led argument until the trusted template contains a literal `--`
before it. Address and port placeholders are typed and cannot begin with `-`.
For example, write `ssh -- {hostname}`, not `ssh {hostname}`.

Programs without an options terminator, and templates that intentionally use a
placeholder as an option value, may opt out explicitly:

```toml
[action]
command = "program {txt.value}"
mode = "fork"
allow_option_like_values = true
```

This trusts the local rule author to understand that program's argument
grammar. Kinjo never inserts `--` automatically because doing so would change
the meaning of programs with a different grammar. A placeholder is never
allowed in the program token (`argv[0]`): trusted local configuration must name
the executable literally.

## Examples

SSH into discovered SSH services:

```toml
[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"
```

Open an alternate HTTP port:

```toml
[metadata]
name = "open-http-alt"
description = "Open alternate HTTP service in a browser"
requirements = ["xdg-open"]

[match.service_type]
regex = "^_http-alt\\._tcp$"

[action]
description = "Open in browser"
command = "xdg-open http://{hostname}:{port}"
mode = "fork"
```

Open a service by IP address instead of hostname:

```toml
[metadata]
name = "open-by-address"
description = "Open service by address"
requirements = ["xdg-open"]

[match.service_type]
contains = "_http."

[action]
description = "Open by address"
command = "xdg-open http://{address}:{port}"
mode = "fork"
```

## Parser Notes

Command files are standard TOML. Values must be quoted strings unless the
field expects an array of strings, such as `requirements`. Unknown sections,
unknown metadata/action keys, and unknown predicate kinds are rejected with an
error naming the offending file.

When the TUI starts normally, an invalid command file is skipped with a warning
naming it (shown on the status line and printed on exit) so one bad file — for
example in the shared system directory — cannot prevent the app from starting.
Every invalid file is reported, not just the first, and the valid ones still
load. A reload of a running instance is stricter: see
[Reloading While It Runs](#reloading-while-it-runs). `kinjo list-commands` loads
strictly and fails on the first invalid file; use it to validate your
configuration.