Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Open more actions menu

Repository files navigation

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

About

Map Nintendo Switch Joy-Con to macOS keyboard shortcuts — TOML-configured, CLI-driven, AI-friendly, with rumble-as-notification over a control socket.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages

Morty Proxy This is a proxified and sanitized view of the page, visit original site.