Skip to main content

Map Nintendo Switch Joy-Con to macOS keyboard shortcuts — TOML-configured, CLI-driven, AI-friendly.

Project description

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vibejoy-0.2.0.tar.gz (48.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vibejoy-0.2.0-py3-none-any.whl (43.9 kB view details)

Uploaded Python 3

File details

Details for the file vibejoy-0.2.0.tar.gz.

File metadata

  • Download URL: vibejoy-0.2.0.tar.gz
  • Upload date:
  • Size: 48.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vibejoy-0.2.0.tar.gz
Algorithm Hash digest
SHA256 029af3ef630844ff9ec3c198268eed38d3865ed163e9672f4def5bad02d74f6b
MD5 cdbe355e78475ff7f75ab407605383eb
BLAKE2b-256 b86b286fc4aafddf8fd09df947c3299adf2ade46c009fb6c80026073c468075b

See more details on using hashes here.

Provenance

The following attestation bundles were made for vibejoy-0.2.0.tar.gz:

Publisher: publish.yml on WEIFENG2333/vibejoy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file vibejoy-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: vibejoy-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 43.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vibejoy-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5a88b66b07f18783c3c10fd4a5cb201e51355b87d37021b340d3a0aaf011ac2e
MD5 7abc24445d6f94a5eb913af77cbe36bf
BLAKE2b-256 28ed4c5db4281ea1c5806f65a9868cd33559f167890c0881135ead80a2618d67

See more details on using hashes here.

Provenance

The following attestation bundles were made for vibejoy-0.2.0-py3-none-any.whl:

Publisher: publish.yml on WEIFENG2333/vibejoy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page