English · 日本語
A global mouse-gesture daemon for macOS. Hold a mouse button, draw a short shape with the cursor — down, then right — and wand fires an action: close a tab, reopen one, minimize a window, run a shell command. The action runs against the window the cursor was over when you started drawing.
Draw with the trigger button held down (right mouse by default). A cast is a sequence of cardinal directions:
L = left U = up R = right D = down
So DR is down-then-right, URD is up → right → down. When you
release the button, wand matches the shape against your rules and
runs the first match. A shape that matches nothing — or barely
moving at all — does nothing, and a plain click still behaves like a
normal click.
Out of the box (the bundled config.toml):
| Draw | Action | Where |
|---|---|---|
DR down → right |
close the current tab (cmd+w) |
Chrome / Safari |
UR up → right |
reopen last closed tab (cmd+shift+t) |
Chrome / Safari |
DRU down → right → up |
close the window | any app |
L left |
minimize the window | any app |
A translucent trail follows the cursor as you draw — match color while the shape so far fires a rule, no-match color once it doesn't. Around the cursor, small cards lay out what's reachable from here in the direction of each card's next arrow, and the one that fires right now is tinted in the match color. At the gesture's start point a small badge shows the target app's icon so the window wand will act on is unambiguous even when keyboard focus sits elsewhere.
Visual knobs live in scoped sub-blocks of [cast.overlay]:
the trail line itself in [cast.overlay.trail], the origin
badge in [cast.overlay.badge], and the assist-card exit
animations in [cast.overlay.cards].
The assist cards can animate out — drop, slide, explode, vibrate,
or burst with fireworks / confetti particles. Pick one effect for
the moment a card becomes unreachable mid-gesture (unmatch) and
another for the moment a rule actually fires (match), both in
[cast.overlay.cards]. Default is silent. Fire-moment effects
that work even when the trail overlay is off — the trail-end burst
in [cast.fire.burst] and the post-fire ink decal in
[cast.fire.decal] — live separately.
Actions target the window under the cursor, not whichever window
holds keyboard focus: ax actions operate on it directly, key
actions raise it first and send the keystroke, and shell actions
receive its identity (bundle id, pid, title, frame) as environment
variables.
wand also ships a middle-click contextual menu as a second
trigger. Off by default — set [tome].enabled = true in your
config and the daemon installs a second event tap alongside the
cast one. The tome renders as a non-activating panel that
keeps the source app focused: it floats above the underlying app
without stealing keyboard focus, so you can keep typing in your
editor while picking a row with the mouse. Submenus open on hover
as an adjacent child panel (group = ["..."]). Click outside or
press Esc to dismiss. The panel is anchored to the window
under the cursor at button-down — same invariant as the cast
path. Each [[tome.cursor.item]] is one row:
[tome]
enabled = true
button = "middle" # or "side1" / "side2" / "right"
[[tome.cursor.item]]
name = "New Tab"
icon = "🌐" # emoji / SF:<name> / file path
apps = ["*chrome*", "*safari*"]
action-type = "key"
action-keys = "cmd+t"
[[tome.cursor.item]]
name = "By Name"
icon = "SF:textformat.abc" # macOS SF Symbol
group = ["Sort"] # nests this row inside a "Sort" submenu
separator-before = true # divider line above the row
action-type = "shell"
action-cmd = "echo name"Item icon syntax: "🌐" (emoji / 1-2 char text glyph), "SF:globe"
(SF Symbol — macOS 11+), "app:com.apple.Safari" (the running app's
icon for that bundle id), "~/icons/foo.png" / "icons/foo.png"
(path; relative paths resolve against ~/.config/wand/), or
"/abs/path.png". Unrecognised values fall back to no icon and log
once to /tmp/wand.log.
Tome entries can be reordered by drag & drop: drag an entry and
drop it above or below another to rearrange the panel (list layout
only).
The new order is session-only — a config reload or daemon
restart discards it and the config.toml document order applies
again. To make an order permanent, reorder the
[[tome.cursor.item]] tables in config.toml.
Each row also accepts subtitle, a header separator, and
tint / tint-colors / icon-anim for SF-Symbol icons — see
config.toml for the full per-row vocabulary. Panel-
wide visual settings live in [tome.row], [tome.animation]
(open / close ∈ off | fade | pop), and [tome.decoration]
(border ∈ off | rainbow). [tome].layout switches the panel
between list, toolbar, and labeled-toolbar.
Items can also produce dynamic submenus. Set dynamic to a
shell command and provide template-* fields; each stdout line
becomes one child item with {line} substituted in the template:
[[tome.cursor.item]]
name = "Switch Branch"
icon = "SF:point.3.connected.trianglepath.dotted"
dynamic = 'cd ~/repo && git branch --format="%(refname:short)"'
template-name = "{line}"
template-icon = "SF:arrow.triangle.branch"
template-action-type = "shell"
template-action-cmd = 'cd ~/repo && git switch "{line}"'The shell is killed after 500 ms if it hangs; empty stdout / non-
zero exit / timeout show a disabled placeholder ((no items) /
(error: exit N) / (timeout)). Quote {line} substitutions in
shell commands — the line content is untrusted.
Items can also carry a checkmark state via state:
[[tome.cursor.item]]
name = "Dark Mode"
state = "shell:defaults read -g AppleInterfaceStyle 2>/dev/null | grep -q Dark"
action-type = "shell"
action-cmd = "..."state accepts "on" / "off" / "mixed" for static markers,
or "shell:<cmd>" to evaluate live at menu-open (exit 0 → ✓,
100 ms timeout).
Items with apps = ["*"] (or apps omitted) also fire on the
Dock, menu bar, and Desktop — places where no AX target resolves
under the cursor. App-specific items there are filtered out
automatically. Good fit for Spotlight, lock screen, "open
Terminal", etc.
Shell actions launched from the menu also see a $WAND_SELECTION
env var carrying the text selected in the focused element at the
moment you middle-clicked. Use it for translate / search /
send-to-app workflows:
[[tome.cursor.item]]
name = "Translate"
icon = "SF:globe"
action-type = "shell"
action-cmd = 'open "https://translate.google.com/?sl=auto&tl=en&text=$(printf %s "$WAND_SELECTION" | sed "s/ /%20/g")"'Every wand env var is WAND_-prefixed, and a context that doesn't
exist is left unset — never set to an empty string — so a
command can branch on presence:
[ -n "${WAND_SELECTION:-}" ] && …. $WAND_SELECTION is unset
when nothing is selected or the focused app doesn't expose AX
selection.
Quote $WAND_SELECTION in shell commands — the content is
whatever the user happened to highlight (URLs, code, shell
metacharacters), and is untrusted in the same sense
WAND_TARGET_TITLE is.
apps decides which apps a rule / item belongs to.
filter-title narrows that with a window-title glob, and
filter-shell is an escape hatch — a /bin/sh -c predicate
that decides at match time whether the row applies. Both work on
[[cast.cursor.rule]] and [[tome.cursor.item]]:
[[tome.cursor.item]]
name = "Open as PR"
icon = "SF:arrow.triangle.pull"
apps = ["*chrome*"]
filter-title = "*github.com*/issues/*" # only on a GitHub issue
action-type = "url"
action-url = "..."
[[tome.cursor.item]]
name = "Late-night ping"
filter-shell = "test $(date +%H) -ge 22" # only after 22:00
action-type = "shell"
action-cmd = "afplay /System/Library/Sounds/Glass.aiff"filter-title is sub-microsecond (in-process glob against the
title captured at button-down / click). filter-shell is ~5-20 ms
per row (process spawn), 100 ms hard timeout — use sparingly.
brew install akira-toriyama/tap/wand
curl --create-dirs -o ~/.config/wand/config.toml \
https://raw.githubusercontent.com/akira-toriyama/wand/main/config.toml
open "$(brew --prefix)/opt/wand/Wand.app" # triggers AX promptThen grant Accessibility to wand (System Settings → Privacy
& Security → Accessibility) and launch the daemon with wand.
To start it automatically at login:
brew services start wandThe formula bundles a Wand.app (LSUIElement — no Dock icon) plus
a stable self-signed code-signing identity created in your login
keychain on first install, so the Accessibility grant persists across
brew upgrade wand. If the keychain isn't reachable during install,
the formula falls back to ad-hoc signing and prints a loud warning
with a one-line recovery path. Details:
packaging/homebrew/.
wand is config.toml-driven — there is no settings GUI by
design. The curl line above drops the template at
~/.config/wand/config.toml. Out-of-range / unknown values clamp
silently to defaults — a typo can never break the daemon. Validate
explicitly with wand config --validate.
[failsafe]is mandatory. It defines the safety nets that catch a stuck click / drag (button-hold timeout, Esc emergency release). The bundled template ships it; don't delete the block —wand config --validateand daemon startup both refuse to run without it. See the[failsafe]block inconfig.tomlfor the knobs.
A rule looks like this:
[[cast.cursor.rule]]
name = "close tab"
icon = "SF:xmark.square" # optional — drawn next to `name` in the assist card
pattern = "DR" # down → right
apps = ["*chrome*", "*safari*"] # matches the window under the cursor
action-type = "key"
action-keys = "cmd+w"icon mirrors [[tome.cursor.item]].icon syntax — SF Symbols
("SF:globe"), emoji / text glyphs ("🌐"), installed app icons
("app:com.apple.Safari"), or a file path. Empty / omitted = no
icon (the assist card just shows arrow + name).
Pattern alphabet is L U R D (left / up / right / down) —
no consecutive duplicates, because the recogniser coalesces
same-direction motion into one segment (LLLL… is L, not LL).
A rule whose pattern repeats a direction (DRR, LL, …) is
unreachable; wand config --validate drops it loudly. Scroll-axis
directions are not recognised yet. Action types are key (a
keystroke), ax (close / minimize / zoom / raise), and
shell (any command), url (https://, slack://, file://,
any custom scheme an installed app advertises — via
NSWorkspace.shared.open).
apps is a glob list with positive entries (*chrome*,
com.apple.Safari, * for any) and !-prefixed exclusions. The
rule applies when at least one positive entry matches (or none
exists) and no ! entry matches. Case-insensitive. Examples:
apps = |
Applies to |
|---|---|
["*chrome*"] |
only Chrome (or anything whose bundle id contains "chrome") |
[] or ["*"] |
every app |
["!com.apple.dt.Xcode"] |
every app except Xcode |
["*", "!*.chrome.beta*"] |
every app except Chrome's beta channel |
["*chrome*", "*safari*"] |
Chrome OR Safari |
Globally suppress cast and tome in specific apps via
[exclude].apps (e.g. block wand inside a remote-desktop client).
The list short-circuits both trigger families before rule / item
matching runs.
Trail style, origin-badge size, blur on the overlay, final-hold
fade time, and many more visual knobs live in
[cast.overlay.trail], [cast.overlay.badge], and
[cast.overlay] — see config.toml for the
complete annotated list.
[cast.recognition].max-segment-ms caps how long any one segment
may take — the clock resets on every turn, so a multi-segment gesture
gets the full budget per leg and only a stalled single direction (an
ordinary deliberate right-drag) runs past it and is abandoned. 0
(default) = no limit; the trail turns the no-match color once a
segment runs past the budget.
[cast.recognition].cancel-reversals is the escape hatch: scribble
the cursor back and forth and the in-progress gesture is abandoned on
the spot — no waiting for a timeout, and releasing fires nothing. It
counts 180° direction reversals; the default 2 catches a deliberate
back-and-forth without tripping on real gestures. 0 = off.
cancel-window-ms (default 500) gates it on speed — the reversals
must land within that window, so a fast scribble cancels but a slow
deliberate back-and-forth doesn't; 0 = any speed.
[cast.overlay.cards] adds optional exit animations to the
assist cards. Each card normally pops out the moment it's no
longer reachable from the shape you've drawn; with an effect set
it eases out instead. Two hooks:
[cast.overlay.cards]
cancel = "drop" # cards that became unreachable mid-gesture
fire = "fireworks" # the firing card, on button-upAvailable kinds: off (default), drop, rise, slide-left,
slide-right, explode, vibrate, fade, fireworks, confetti,
and random (picks a different one each time a card disappears).
Particle effects (fireworks / confetti) read most naturally on
fire.
Fire-moment effects that work independently of the overlay live in their own click-through windows:
[cast.fire.burst]
kind = "burst" # off | burst
[cast.fire.decal]
kind = "ink-splatter" # off | ink-splatter | paint-blob | scorch | star
duration-ms = 3000
size = 60Both fire even when [cast.overlay].enabled = false.
A single intensity knob at the top level of [cast] scales
every visual effect produced by a cast firing — overlay card
animations AND the trail-end burst (decal has its own size /
duration knobs and is not affected):
[cast]
button = "right"
intensity = "wild" # subtle | normal | bold | wildyabai-style wand <domain> --<verb> [VALUE …]. Four domains —
daemon (lifecycle), cast (gesture engine), tome (launcher
menu), config (settings). Bare wand runs the agent.
wand # run as agent (CGEventTap loop)
WAND_DEBUG=1 wand # verbose log to /tmp/wand.log + stderr
# daemon — lifecycle (need a running daemon; exit 3 if none)
wand daemon --reload # re-read config.toml (also automatic on save)
wand daemon --show # rule count, trigger, last gesture, counters
wand daemon --quit # terminate the running daemon
wand daemon --resign # re-sign the installed Wand.app + restart
# (run once after `brew install` / upgrade)
# cast — gesture engine
wand cast --test DR [app] # dry-run: which rule fires for a pattern
wand cast --record # interactive recorder → paste-ready [[cast.cursor.rule]]
# tome — launcher menu (external trigger)
wand tome --open --items <PATH> --at <X> <Y> [--selection <TEXT>] [--title <TEXT>]
# pop the tome at <X> <Y> (Cocoa coords, Y-up;
# --at accepts negative coords). --selection →
# $WAND_SELECTION for shell items; --title
# overrides $WAND_TARGET_TITLE.
wand tome --validate --items <PATH> # validate a standalone items file
# config — settings
wand config --validate # schema-validate config.toml; exit 0 (valid) / 1 (schema violation) / 2 (unparseable)
wand config --doctor # health check: Accessibility, config, daemon, tap
wand config --emit-schema # print the config.toml JSON Schema (Draft-07)
wand --help, -hEach domain takes exactly one verb. Combining verbs (e.g.
wand daemon --reload --quit) or using a flag outside its domain
(e.g. --items without tome) exits 2 — no silent fallback; an
unknown flag prints a did you mean …? hint.
The daemon auto-reloads config.toml on save — daemon --reload is
the manual trigger. daemon --reload / daemon --show / daemon --quit
/ tome --open need a running daemon (exit 3 with a helpful message if
none). cast --record is the reverse — it refuses if the daemon is
running, because both would fight over the same CGEventTap.
There is no deprecation shim — the old flat flags exit 2. Map:
| old | new |
|---|---|
wand --reload / --status / --quit / --resign |
wand daemon --reload / --show / --quit / --resign |
wand --test P [app] / --record |
wand cast --test P [app] / --record |
wand --show-menu --items … --at … |
wand tome --open --items … --at … |
wand --validate --items P |
wand tome --validate --items P |
wand --validate / --doctor / --emit-schema |
wand config --validate / --doctor / --emit-schema |
Two transitions need a daemon restart — everything else hot-reloads:
[cast](button / modifiers) — baked into the running tap's event mask attapCreatetime[cast.overlay].enabled = false → true— when the daemon started with overlay disabled, the window was never created; flipping it on later has nothing to attach to
Both surface in wand daemon --show as a pending-restart: line, and
in /tmp/wand.log at reload time.
Commit messages are gitmoji-driven; CI lints
each PR against CONTRIBUTING.md.
Install the local hook once per clone with glyph hook install.
swift build # compile (CommandLineTools is enough)
swift test # needs Xcode for XCTest
.build/debug/wand --help # smoke testFor a local Wand.app with persistent Accessibility grant:
./setup-signing-cert.sh # once — creates stable self-signed cert
./run.sh # ./package.sh + open Wand.app
./run.sh --dev # → Wand-dev.app (com.wand.wand.dev)
# for parallel testing alongside a
# Homebrew install without TCC collision
./stop.sh # kill everything wandevent-tap: tapCreate failed — is Accessibility granted? in
/tmp/wand.log. macOS dropped (or never had) the Accessibility
grant for this binary. Two ways out:
- Quick: re-grant in System Settings → Privacy & Security →
Accessibility (toggle the
wandentry off and on, or+the binary if missing). Re-launch. - Sticky: run
./setup-signing-cert.shonce. It creates a stable self-signed cert in the login keychain;package.sh/run.shpick it up and sign every rebuild with the same identity, so the TCC grant survives. Each subsequentswift buildwould otherwise ad-hoc-sign with a new identity and look like a "new app" to TCC.
security find-identity -v -p codesigning returns 0 but
./run.sh still signs the bundle. That's expected — find-identity -v filters for codesigning-trusted identities, and a self-signed
cert isn't trusted as a CA. The cert is still in the keychain and
codesign --sign "<name>" finds it by Common Name. Confirm with
security find-certificate -c "wand Local Signing".
Cast doesn't fire on a Chrome page's content area. The AX
walk-to-window fails through Chrome's renderer process; wand falls
back to CGWindowListCopyWindowInfo. The log line reads
AX: resolved … via cg-window → com.google.Chrome … — if you see
via ax-walk for the same area you're fine. If you see neither,
the cursor was likely on the menu bar / Dock / desktop.
A rule with pattern = "DRR" or similar repeats never fires. By
design — the recogniser coalesces same-direction motion, so DRR
is unreachable. wand config --validate drops the rule loudly. Use
distinct directions per segment (DR plus a follow-on like DRU).
MIT © akira-toriyama