Skip to main content

VibeJoy

CI PyPI Python License: MIT Platform: macOS

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 via pynput, 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 validate catches 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) or pip.
  • 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 = pressed or released so 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-python 0.2.4 forgot to declare pyglm as a dependency; pyproject.toml pins 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)

Source distribution for vibejoy 0.2.0
File Size Uploaded
vibejoy-0.2.0.tar.gz 48.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vibejoy 0.2.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page