VibeJoy
Map Nintendo Switch Joy-Con inputs to macOS keyboard shortcuts — configured via a single TOML file, controllable from the command line, and designed to be edited by humans or AI copilots.
Bonus: expose the Joy-Con's HD Rumble as a CLI, so your Claude Code / any-AI hook can buzz your hand when a task finishes.
# after `vibejoy run` is running in the background:
vibejoy rumble --pattern ok # a gentle double-click
vibejoy rumble --pattern error # a long angry buzz
Why
The existing Joy-Con → keyboard projects are Windows-first, GUI-heavy, and don't play well with an AI-driven workflow. VibeJoy is the opposite:
- macOS-native — reads Joy-Con directly over HID (
joycon-python), simulates keys viapynput, switches apps via Quartz/AppKit. - TOML is the API — one human-readable file, one DSL per binding (
tap:enter,combo:cmd+c,repeat:up@100). AIs can edit it;vibejoy validatecatches typos. - CLI first — no GUI, no system tray. Every operation is a subcommand.
- AI-reachable rumble — the daemon exposes a Unix-socket control channel; any script can trigger haptics.
Requirements
- macOS 13+ (Quartz + AppKit pyobjc frameworks).
- Python 3.11+ (uses the built-in
tomllib). uv(recommended) orpip.- Joy-Con paired over Bluetooth.
Install
# end-user install (once 0.1.0 lands on PyPI)
pip install vibejoy
# or with uv for a fully isolated tool
uv tool install vibejoy
# from source, for development
git clone https://github.com/WEIFENG2333/vibejoy.git
cd vibejoy
uv sync
Grant Accessibility permission to your terminal the first time you run anything that simulates keys — otherwise pynput silently does nothing.
System Settings → Privacy & Security → Accessibility → add Terminal / iTerm / VS Code.
Quick Start
# 1. Pair Joy-Con via Bluetooth (see `vibejoy doctor` for guidance)
vibejoy doctor
# 2. Start the daemon — first run writes a starter config automatically
vibejoy run
# first run: wrote starter config to ~/.config/vibejoy/config.toml
# edit to customize, then `vibejoy validate` to re-check
# 3. Edit the config — by hand or via AI
$EDITOR ~/.config/vibejoy/config.toml
vibejoy validate # catches typos
vibejoy run # pick up the new bindings
The daemon autodetects whichever Joy-Cons are paired and applies the matching profile (profile.right / profile.left).
Configuration
Everything lives in one TOML file. The full DSL:
| Verb | Form | Meaning |
|---|---|---|
none |
none |
explicit no-op |
tap |
tap:<key> |
press + release once |
hold |
hold:<key> |
press on input-down, release on input-up |
repeat |
repeat:<key>[@<ms>] |
re-tap every N ms while held (sticks) |
auto |
auto:<key>[@<ms>] |
short press = tap, long press (≥ ms) = hold |
combo |
combo:<k1>+<k2>+… |
one-shot chord |
sequence |
sequence:<mod>+<k>[@<ms>] |
hold mod, tap rest (optionally repeat) |
type |
type:<text> |
type a literal string |
delay |
delay:<ms> |
wait (inside macros only) |
macro |
macro:<name> |
run a [macro.<name>] block |
window_switch |
window_switch:<a>,<b>,… |
cycle focus between apps |
shell |
shell:<command> |
run /bin/sh -c <command>, non-blocking |
Minimal example:
[global]
deadzone = 0.2
poll_hz = 100
long_press_ms = 250
stick_mode = "4dir"
[profile.right.buttons]
a = "tap:enter"
b = "tap:escape"
x = "combo:cmd+w"
r = "window_switch:code,chrome,terminal"
zr = "macro:claude_focus"
plus = "combo:cmd+s"
[profile.right.stick]
up = "repeat:up@100"
down = "repeat:down@100"
left = "repeat:left@100"
right = "repeat:right@100"
[macro.claude_focus]
if_app = "Visual Studio Code" # run only when VS Code is frontmost
steps = [
"combo:cmd+shift+p",
"delay:100",
"type:Claude Code: Focus input",
"delay:100",
"tap:enter",
]
Run vibejoy schema to print the full annotated example.
Shell Actions
Bind any button or stick direction to a shell command:
[profile.right.buttons]
home = "shell:open -a Calculator"
capture = "shell:say done"
plus = "shell:osascript -e 'display notification \"buzzed\"'"
Semantics
- Runs
/bin/sh -c <command>in a new session — non-blocking (the daemon never waits). - Fires on both press and release. Your script gets
$VIBEJOY_EVENT=pressedorreleasedso it can tell which edge it's handling. - Inside a macro step,
$VIBEJOY_EVENT=macro.
Injected environment variables
Every shell invocation receives these in addition to the daemon's env:
| Variable | When set | Example |
|---|---|---|
VIBEJOY_EVENT |
always | pressed | released | macro |
VIBEJOY_BUTTON |
button triggers | zr |
VIBEJOY_SIDE |
buttons + sticks | left | right |
VIBEJOY_DIRECTION |
stick triggers | up-right |
VIBEJOY_FRONTMOST_APP |
macOS only | Visual Studio Code |
Scripts that only want to act on press:
[ "$VIBEJOY_EVENT" = "pressed" ] || exit 0
Output handling
Stdout/stderr inherit from vibejoy run — so you see your script's output in the same terminal. For noisy commands, redirect in the command itself:
home = "shell:long-running.sh >> ~/vibejoy.log 2>&1"
Security
Binding a button to shell: gives config.toml the same authority as a shell script under your account. VibeJoy already requires macOS Accessibility permission (arbitrary keystrokes), so the trust boundary doesn't change — but treat config.toml with dotfile-level care. If an AI is rewriting your config, review its edits the same way you'd review a PR.
CLI Reference
vibejoy run start the mapping daemon (auto-creates config on first run)
vibejoy validate parse + type-check config, exit non-zero on error
vibejoy discover live dump of button / stick events (for authoring)
vibejoy doctor probe environment: Joy-Con, permissions, IPC
vibejoy rumble trigger rumble (via daemon if running, else direct HID)
vibejoy schema print the annotated starter config
Each subcommand has --help.
Rumble from AI Hooks
The daemon listens on a Unix domain socket at ~/.vibejoy/control.sock. vibejoy rumble prefers this channel (so it works even while the daemon holds the HID handle) and falls back to opening HID directly when no daemon is running.
Built-in patterns: short, long, click, double, ok, error.
Custom patterns: pass raw bytes with --pattern "c8 c8 72 04" (4 bytes shared across sides, or 8 bytes for left / right).
Claude Code example
.claude/settings.json:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "vibejoy rumble --pattern ok" }] }
],
"Error": [
{ "hooks": [{ "type": "command", "command": "vibejoy rumble --pattern error" }] }
]
}
}
Your Joy-Con becomes a tactile notification channel.
Architecture
┌───────────────┐ events ┌──────────┐ actions ┌────────────────┐
│ joycon.py │──────────▶│ mapper.py│───────────▶│ keyboard.py │
│ (pyjoycon + │ │ (state │ │ window.py │
│ baseline cal)│ │ machine)│ │ (rumble via │
└───────────────┘ └──────────┘ │ shared HID) │
▲ ▲ └────────────────┘
│ config.py │
└── discover ──────── cli.py ──▶ runner.py ──▶ ipc.py
(control socket)
Nine source files, each a single responsibility:
src/vibejoy/
├── __init__.py
├── __main__.py # python -m vibejoy
├── cli.py # argparse subcommands
├── config.py # TOML load / validate / paths
├── events.py # ButtonEvent, StickEvent dataclasses
├── actions.py # Action DSL + parser
├── keyboard.py # pynput wrapper + key-name resolver
├── window.py # macOS app switcher (Quartz/AppKit)
├── joycon.py # pyjoycon wrapper + baseline calibration
├── mapper.py # event → action state machine
├── shell.py # non-blocking shell dispatch + env context
├── rumble.py # HD-Rumble primitives + presets
├── ipc.py # Unix-socket control channel
├── runner.py # main loop + signal handling
└── config.example.toml # bundled starter config
Development
uv sync --all-groups
uv run pytest # 81 tests, ~0.4s
uv run ruff check .
uv run vibejoy doctor # sanity check
Known Caveats
joycon-python0.2.4 forgot to declarepyglmas a dependency;pyproject.tomlpins it explicitly until upstream fixes that.- Rumble byte presets are derived from published reverse-engineering docs. They vibrate reliably but the exact tone isn't Nintendo-accurate — use raw bytes if you need a specific frequency.
- macOS sleeps Bluetooth Joy-Cons after ~30 min idle. Press any button to wake.
License
MIT
Metadata
Release files for vibejoy 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vibejoy-0.2.0.tar.gz | 48.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vibejoy-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.8 kB
Release files / vibejoy-0.2.0.tar.gz
| Download URL | vibejoy-0.2.0.tar.gz |
|---|---|
| Size | 48.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
029af3ef630844ff9ec3c198268eed38d3865ed163e9672f4def5bad02d74f6b
|
|
BLAKE2b-256 checksum How to use checksums |
b86b286fc4aafddf8fd09df947c3299adf2ade46c009fb6c80026073c468075b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 21, 2026.
Transparency logRelease files / vibejoy-0.2.0-py3-none-any.whl
| Download URL | vibejoy-0.2.0-py3-none-any.whl |
|---|---|
| Size | 43.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5a88b66b07f18783c3c10fd4a5cb201e51355b87d37021b340d3a0aaf011ac2e
|
|
BLAKE2b-256 checksum How to use checksums |
28ed4c5db4281ea1c5806f65a9868cd33559f167890c0881135ead80a2618d67
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 21, 2026.
Transparency log