<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/yukimemi/kagi/main/assets/logo-dark.svg">
<img src="https://raw.githubusercontent.com/yukimemi/kagi/main/assets/logo.svg" alt="kagi — cross-platform key mapper with first-class IME control" width="540">
</picture>
</p>
# kagi
Cross-platform key mapper with **IME on/off as a first-class action**.
`Ctrl+[` → `Esc` *and close the IME*, with one config, on macOS, Windows and
Linux.
## Why another remapper
kanata, kmonad, Karabiner-Elements and xremap all remap keys well. None of them
treats "turn the IME off" as an action — you end up shelling out, or writing
per-OS glue like an AutoHotkey `ImmSetOpenStatus` helper next to a Karabiner
JSON rule next to an xremap YAML. kagi makes that one line:
```toml
[[rule]]
from = "ctrl-["
to = ["esc", "ime:off"]
```
kagi also needs no kernel extension and no DriverKit driver on macOS — an event
tap plus Accessibility permission is enough.
## Install
```sh
cargo install kagikey # the crate is `kagikey`; the binary is `kagi`
cargo install --path . # or from a checkout
```
kagi keeps itself up to date in the background and exposes `kagi update`.
Packagers who own updates themselves can drop that (and with it the
reqwest/rustls tree) entirely:
```sh
cargo install kagikey --no-default-features
```
## Configure
kagi reads `$KAGI_CONFIG`, else `~/.config/kagi/kagi.toml`, else
`%APPDATA%\kagi\kagi.toml`. See [`config/kagi.toml`](config/kagi.toml).
The file is rendered by [teravars](https://github.com/yukimemi/teravars)
before it is parsed, so one config can cover several machines: `[vars]` for
your own values, `{{ system.os }}` / `{{ system.host }}` / `{{ system.user }}`
for the machine, and `include` to pull in a shared fragment.
```toml
[[rule]]
desc = "Ctrl+[ -> Esc, and close the IME"
from = "ctrl-["
to = ["esc", "ime:off"]
[[rule]]
desc = "Esc also closes the IME"
from = "~esc"
to = ["ime:off"]
[[rule]]
from = "~henkan"
to = ["ime:on"]
os = ["windows", "linux"]
[[rule]]
from = "~muhenkan"
to = ["ime:off"]
os = ["windows", "linux"]
```
### `from`
A chord such as `ctrl-[`, `ctrl-shift-a`, `esc`. `-` separates modifiers, so a
literal hyphen is `minus` (or the trailing `-` in `ctrl--`).
Modifiers: `ctrl`, `shift`, `alt` (Option on macOS), `meta` (Command / Win).
Modifiers must match **exactly** unless the rule is wildcarded.
Two prefixes, borrowed from AutoHotkey:
| `~` | the original key still reaches the focused app |
| `*` | extra modifiers beyond the listed ones are tolerated |
### `to`
| `esc`, `ctrl-a`, … | synthesize that key |
| `ime:on` / `ime:off` / `ime:toggle` | drive the platform IME |
| `source:<id>` | select an input source (macOS TIS id, Linux IME engine) |
| `cmd:<shell command>` | run a command, detached |
An empty `to` swallows the key.
### `os`
`os = ["windows", "linux"]` restricts a rule to those platforms. Omit it for
every platform.
## Commands
```sh
kagi run # capture and remap (default)
kagi check # parse the config and print the rules that apply here
kagi watch # print key events as they arrive, to discover key names
kagi update # install the latest release (--check to only look)
```
`kagi watch` is the way to find the name of a key your keyboard actually sends:
```
down ctrl-[ (keycode=0x21 flags=0x20040000)
```
## Run it at login
```sh
kagi service install
```
kagi is a daemon, so this registers the mechanism each platform actually
wants rather than one generic autostart entry. Everything is per-user; none
of it needs root.
| macOS | launchd LaunchAgent | `ProcessType = Interactive` keeps launchd from throttling keyboard handling behind background QoS |
| Linux | systemd user unit, `PartOf=graphical-session.target` | kagi *grabs* evdev devices, so it has to come up and go down with the session; a `.desktop` autostart entry gives no ordering and no restart-on-failure |
| Windows | logon scheduled task driving a `wscript` shim | a console binary launched from the Startup folder leaves a window on screen for as long as the daemon runs |
`install` compiles your config first and refuses to register a service that
would die on startup. Pass `--config` to bake a non-default path into the
registration.
`kagi service status` reports registration and run state; `uninstall`,
`start` and `stop` do what they say.
### macOS permissions
An event tap needs **Accessibility** *and* **Input Monitoring**, and macOS
grants both per binary — the agent is not the terminal you installed from, so
its first run fails regardless of what your terminal is allowed to do.
`kagi service install` deploys the agent to `~/Applications/kagi.app`
(never `~/.cargo/bin/kagi` directly) and signs it with a fixed
identifier, rather than running the raw binary as the launchd job. Two
problems this fixes, together:
* macOS keys a grant to the binary's **signing identifier**, and `cargo
build` leaves a linker ad-hoc signature whose identifier embeds a hash of
the binary (`kagi-bef9cabe50a08b72`) — a different one on every rebuild.
Left alone, every rebuild looks like a new application to TCC, so
yesterday's grant goes stale and the Privacy lists accumulate a dead
`kagi` row per build.
* macOS also appears to key a grant to the binary's **path**, sometimes
permanently — an early `~/.cargo/bin/kagi` build asked while its signature
was still broken, and no amount of re-signing, re-toggling, or even
removing and re-adding the Settings row ever got that exact path working
again. A real, dedicated bundle escapes that path entirely.
A binary that has never *asked* does not even appear in those lists; kagi
asks for you, through `IOHIDRequestAccess` and `AXIsProcessTrustedWithOptions`,
which is what creates the entry. `kagi service install` walks both
permissions for you, one at a time with its own explanation dialog before
each OS prompt; `kagi permissions` repeats the same flow on demand; a daemon
that fails to start retries once in the background without a dialog.
Tick both entries, then `kagi service start`. Logs go to
`~/Library/Logs/kagi.log`.
If a permission ever gets stuck regardless (for instance, after a
`codesign` regression re-introduces an unstable signature), fix it with:
```sh
kagi permissions --reset
```
This runs `tccutil reset <service> <kagi's bundle id>` — surgical, because
`~/Applications/kagi.app` is a real, LaunchServices-registered bundle, unlike
a bare CLI path. It clears only kagi's own Accessibility and Input
Monitoring rows, never another application's grant for the same service.
(`kagi service install` must have deployed the bundle first.)
That is why the generated agent also sets `KAGI_NO_AUTOUPDATE=1`: a silent
self-update would swap the binary out and leave the agent running blind, with
nothing in the foreground to prompt you. Update deliberately with
`kagi update`, then `kagi service install` again to redeploy the bundle.
## How each platform does it
| macOS | `CGEventTap` at `kCGHIDEventTap` | posts the JIS 英数/かな keycodes, or `TISSelectInputSource` |
| Windows | `WH_KEYBOARD_LL` hook + `SendInput` | `ImmGetDefaultIMEWnd` + `WM_IME_CONTROL`/`IMC_SETOPENSTATUS` |
| Linux | evdev + uinput (works under X11 *and* Wayland) | `fcitx5-remote`, else `ibus` |
### Permissions
* **macOS** — System Settings ▸ Privacy & Security ▸ **Accessibility** and
**Input Monitoring**, for `kagi.app` (`kagi service install`'s deployment
target) or the terminal launching `kagi run` directly. Granting them
requires restarting the granted process. Event taps are bypassed while a
secure input field has focus, and at the login window.
* **Windows** — an elevated foreground window only receives hooked input if
kagi runs elevated too.
* **Linux** — read access to `/dev/input/event*` (`sudo usermod -aG input
$USER`) and write access to `/dev/uinput` (udev rule, or run as root).
## macOS IME method
```toml
[macos]
ime = "eisu" # or "source"
```
* `eisu` (default) posts the 英数 / かな keycodes a JIS keyboard sends. Every
Japanese IME honours them and the selected input method is preserved — the
closest analogue to AutoHotkey's `ImmSetOpenStatus`.
* `source` switches the input source outright via Text Input Services, using
`ascii_source` / `japanese_source`.
## Development
```sh
cargo test
cargo check --target x86_64-pc-windows-msvc
cargo check --target x86_64-unknown-linux-gnu
```
`cargo run --example synth` posts a synthetic `Ctrl+[` so the macOS tap can be
exercised without a human at the keyboard. Run `kagi watch` first, then
`kagi run`, then `synth`: the watcher should report `esc` and `eisu` rather
than `[`.
## License
MIT