rethocker 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,391 @@
1
1
  # rethocker
2
2
 
3
- A library to intercept and remap keys in macOS.
3
+ Global key interception and remapping for macOS. Intercept any key or combo system-wide, remap keys, fire shell commands, react to key sequences, and scope rules to specific apps — all from TypeScript.
4
+
5
+ Requires **macOS 13+** and **Accessibility permission** (prompted automatically on first run).
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
- npm install rethocker
10
+ bun add rethocker
9
11
  ```
10
12
 
11
13
  ## Usage
12
14
 
13
15
  ```ts
14
- import rethocker from "rethocker";
16
+ import { actions, Key, rethocker } from "rethocker"
17
+
18
+ const rk = rethocker([
19
+ // Remap Caps Lock → Escape
20
+ // Caps Lock is handled transparently — no extra setup needed
21
+ {
22
+ key: Key.capsLock,
23
+ remap: Key.escape,
24
+ },
25
+
26
+ // Remap key to key, chord to key, key to chord, or even chord to chord (type any sequence you want)
27
+ // Use Key.* constants for autocomplete and safe string interpolation
28
+ {
29
+ key: "Ctrl+H E",
30
+ remap: `h e l l o Shift+n1 n1 ${Key.delete}`,
31
+ },
32
+
33
+ // Media / system keys are fully interceptable
34
+ // Use spaces for key sequences (steps pressed in order)
35
+ {
36
+ key: `${Key.brightnessDown} ${Key.brightnessUp}`,
37
+ execute: "open -a 'My App'",
38
+ },
39
+
40
+ // Sequence with app filter, consume, and a TypeScript handler
41
+ {
42
+ key: "Cmd+R T",
43
+ sequenceTimeoutMs: 10_000,
44
+ // exclude specific apps by bundle ID (prefix with ! to negate)
45
+ app: ["!com.google.Chrome", "!com.apple.Safari"],
46
+ // consume: swallow the keys so they don't reach the app
47
+ consume: true,
48
+ handler: async () => {
49
+ // handlers allow async/await and full access to the rk instance
50
+ await rk.execute(actions.window.halfTop())
51
+ // actions.* provide quick access to common macOS tasks
52
+ },
53
+ },
54
+ ])
55
+
56
+ // Handle lifecycle events
57
+ rk.on("accessibilityDenied", () => {
58
+ console.error("Go to System Settings → Privacy & Security → Accessibility")
59
+ })
60
+ rk.on("error", (code, message) => console.error(`[${code}] ${message}`))
61
+ rk.on("exit", (code) => {
62
+ console.error(`daemon exited (${code}), restarting...`)
63
+ rk.start().catch(console.error)
64
+ })
65
+ ```
66
+
67
+ The daemon starts automatically in the background. Rules take effect as soon as it's ready — no `await` needed. Call `await rk.start()` only if you want to explicitly catch startup errors.
68
+
69
+ ## Key syntax
70
+
71
+ Rules use a readable string syntax for keys and combos:
72
+
73
+ ```ts
74
+ "escape" // single key by name
75
+ "Cmd+A" // modifier + key
76
+ "Cmd+Shift+K" // multiple modifiers
77
+ "Cmd+R T" // sequence: Cmd+R then T (space-separated steps)
78
+ "Ctrl+J Ctrl+K" // sequence with modifiers on each step
79
+ ```
80
+
81
+ Modifier names are case-insensitive: `Cmd`, `Shift`, `Alt` / `Opt` / `Option`, `Ctrl` / `Control`, `Fn`.
82
+
83
+ ### `Key` constants
84
+
85
+ Import `Key` for autocomplete and safe string interpolation. Values are key name strings, so they compose naturally:
86
+
87
+ ```ts
88
+ import { Key } from "rethocker"
89
+
90
+ Key.capsLock // "capsLock"
91
+ Key.escape // "escape"
92
+ Key.brightnessDown // "brightnessDown"
93
+
94
+ // Interpolation always produces valid key strings
95
+ `Cmd+${Key.v}` // "Cmd+v"
96
+ `${Key.brightnessDown} ${Key.brightnessUp}` // "brightnessDown brightnessUp"
97
+ `Cmd+${Key.r} ${Key.t}` // "Cmd+r t"
98
+ ```
99
+
100
+ Key names are case-insensitive. Common aliases:
101
+
102
+ | Name | Aliases |
103
+ |---|---|
104
+ | `escape` | `esc` |
105
+ | `return` | `enter` |
106
+ | `delete` | `backspace`, `back` |
107
+ | `forwardDelete` | `del` |
108
+ | `capsLock` | `caps` |
109
+ | `left` / `right` / `up` / `down` | `arrowLeft` etc. |
110
+ | `numpadEnter` | `numenter` |
111
+ | `numpadAdd` | `numpadplus`, `numadd` |
112
+ | `numpadSubtract` | `numpadminus`, `numsubtract` |
113
+ | `numpadDecimal` | `numpadperiod`, `numdecimal` |
114
+ | `numpad0`–`numpad9` | `num0`–`num9` |
115
+
116
+ ### Media / system keys
117
+
118
+ Top-row physical keys (volume, brightness, media control, keyboard backlight) are fully interceptable:
119
+
120
+ ```ts
121
+ { key: Key.volumeUp, execute: "..." }
122
+ { key: Key.playPause, handler: () => {} }
123
+ { key: Key.brightnessDown, remap: Key.brightnessUp }
124
+ { key: `${Key.mediaNext} ${Key.mediaPrevious}`, handler: () => {} }
125
+ ```
126
+
127
+ Available: `volumeUp`, `volumeDown`, `mute`, `brightnessUp`, `brightnessDown`, `playPause`, `mediaNext`, `mediaPrevious`, `mediaFastForward`, `mediaRewind`, `eject`, `illuminationUp`, `illuminationDown`, `illuminationToggle`.
128
+
129
+ ## Rules
130
+
131
+ All rules share these common fields:
132
+
133
+ | Field | Type | Description |
134
+ |---|---|---|
135
+ | `key` | `string` | Key or sequence that triggers the rule |
136
+ | `id` | `string?` | Stable ID for later enable/disable/remove. Auto-generated if omitted. |
137
+ | `app` | `string \| string[]?` | Only fire when this app is frontmost (see [App filter](#app-filter)) |
138
+ | `conditions` | `RuleConditions?` | Advanced condition control |
139
+ | `disabled` | `boolean?` | Start the rule disabled |
140
+
141
+ ### Remap
142
+
143
+ Replace a key with a different key — or a whole sequence of keys:
144
+
145
+ ```ts
146
+ { key: Key.capsLock, remap: Key.escape }
147
+ { key: "NumpadEnter", remap: "Cmd+return" }
148
+ { key: "Ctrl+h", remap: "left" }
149
+
150
+ // Remap to a sequence: original key is suppressed, steps are posted in order
151
+ { key: "Ctrl+H E", remap: `h e l l o Shift+n1 n1 ${Key.delete}` }
152
+ { key: Key.eject, remap: "Cmd+Ctrl+q" } // single target, no sequence
153
+ ```
154
+
155
+ ### Execute
156
+
157
+ Run a shell command when a key fires. The key is consumed:
158
+
159
+ ```ts
160
+ { key: "Cmd+Shift+Space", execute: "open -a 'Alfred 5'" }
161
+
162
+ // Multiple commands run sequentially
163
+ { key: Key.playPause, execute: [actions.media.playPause(), actions.app.focus("Spotify")] }
164
+ ```
165
+
166
+ ### Handler
167
+
168
+ Call a TypeScript function when a key fires. The key is consumed:
169
+
170
+ ```ts
171
+ { key: "Ctrl+J", handler: (e) => console.log("fired", e.keyCode) }
172
+
173
+ // Async handlers work too
174
+ {
175
+ key: "Ctrl+Shift+L",
176
+ handler: async () => {
177
+ await rk.execute(actions.window.halfLeft())
178
+ },
179
+ }
15
180
  ```
181
+
182
+ ### Sequences
183
+
184
+ Space-separated steps in `key` make a sequence. Steps must be pressed within `sequenceTimeoutMs` (default: 5000ms):
185
+
186
+ ```ts
187
+ {
188
+ key: "Cmd+R T",
189
+ execute: `osascript -e 'display notification "done"'`,
190
+ consume: true, // swallow Cmd+R and T so they don't reach the app
191
+ sequenceTimeoutMs: 10_000,
192
+ }
193
+ ```
194
+
195
+ `consume` is available on `execute` and `handler` rules. When true, all intermediate key events in the sequence are suppressed.
196
+
197
+ ## App filter
198
+
199
+ Scope any rule to specific frontmost apps with the `app` field. Use a **bundle ID** (contains a dot) or a **display name** (prefix match, case-insensitive). Prefix with `!` to negate. Multiple values are OR-ed (negations are AND-ed).
200
+
201
+ ```ts
202
+ // Only in Figma
203
+ { key: "Cmd+W", execute: "...", app: "com.figma.Desktop" }
204
+
205
+ // Only in Terminal (display name prefix match)
206
+ { key: "Ctrl+L", remap: "escape", app: "Terminal" }
207
+
208
+ // In any browser
209
+ { key: "Cmd+L", handler: () => {}, app: ["Safari", "Chrome", "Firefox"] }
210
+
211
+ // Everywhere except VSCode
212
+ { key: "Ctrl+P", handler: () => {}, app: "!com.microsoft.VSCode" }
213
+
214
+ // Everywhere except Chrome and Safari
215
+ { key: "Cmd+R T", execute: "...", app: ["!com.google.Chrome", "!com.apple.Safari"] }
216
+ ```
217
+
218
+ ## Actions
219
+
220
+ Built-in helpers for common macOS tasks. Each returns a shell command string for use in `execute`, or can be run directly with `rk.execute()`.
221
+
222
+ ```ts
223
+ import { actions } from "rethocker"
224
+
225
+ // Window layout (targets frontmost app, or pass app name/bundle ID)
226
+ actions.window.halfLeft()
227
+ actions.window.halfRight()
228
+ actions.window.halfTop()
229
+ actions.window.halfBottom()
230
+ actions.window.thirdLeft()
231
+ actions.window.thirdCenter()
232
+ actions.window.thirdRight()
233
+ actions.window.quarterTopLeft()
234
+ actions.window.maximize()
235
+ actions.window.halfLeft("Figma") // move specific app
236
+
237
+ // App management
238
+ actions.app.focus("Slack") // open if not running, bring to front
239
+ actions.app.focus("com.tinyspeck.slackmacgap") // by bundle ID
240
+ actions.app.quit("Slack")
241
+
242
+ // Media
243
+ actions.media.playPause()
244
+ actions.media.next()
245
+ actions.media.previous()
246
+ actions.media.mute()
247
+ actions.media.setVolume(50)
248
+ actions.media.volumeUp(10)
249
+ actions.media.volumeDown(10)
250
+
251
+ // System
252
+ actions.system.sleep()
253
+ actions.system.lockScreen()
254
+ actions.system.missionControl()
255
+ actions.system.emptyTrash()
256
+
257
+ // Run a Shortcut from the macOS Shortcuts app
258
+ actions.shortcut("My Shortcut Name")
259
+
260
+ // Use in rules
261
+ { key: "Ctrl+Left", execute: actions.window.halfLeft() }
262
+ { key: "Ctrl+Shift+S", execute: actions.app.focus("Slack") }
263
+ { key: "F8", execute: actions.media.playPause() }
264
+
265
+ // Multiple actions at once
266
+ { key: "Ctrl+Alt+W", execute: [actions.window.halfLeft("Figma"), actions.app.focus("Slack")] }
267
+
268
+ // Run imperatively from a handler
269
+ {
270
+ key: "Ctrl+Shift+L",
271
+ handler: async () => {
272
+ await rk.execute(actions.window.halfLeft())
273
+ },
274
+ }
275
+ ```
276
+
277
+ ## Managing rules
278
+
279
+ ```ts
280
+ const rk = rethocker([
281
+ { key: Key.capsLock, remap: Key.escape, id: "caps-remap" },
282
+ ])
283
+
284
+ // Add more rules later
285
+ rk.add({ key: "Ctrl+J", handler: () => {} })
286
+ rk.add([
287
+ { key: "Ctrl+K", handler: () => {} },
288
+ { key: "Ctrl+L", remap: "right" },
289
+ ])
290
+
291
+ // Enable / disable by ID
292
+ rk.disable("caps-remap")
293
+ rk.enable("caps-remap")
294
+
295
+ // Disable / enable ALL rules on this instance
296
+ rk.disable()
297
+ rk.enable()
298
+
299
+ // Remove permanently
300
+ rk.remove("caps-remap")
301
+ ```
302
+
303
+ ## Discover key codes
304
+
305
+ Run the included debug script to see what keys produce:
306
+
307
+ ```bash
308
+ bun node_modules/rethocker/src/scripts/debug-keys.ts
309
+ ```
310
+
311
+ Output shows keyCode, modifiers, and active app for every keypress — useful for finding the right key name or checking that app filters work correctly.
312
+
313
+ ## Events and lifecycle
314
+
315
+ ```ts
316
+ const rk = rethocker([...])
317
+
318
+ // Daemon lifecycle
319
+ rk.on("ready", () => console.log("daemon ready"))
320
+ rk.on("exit", (code) => { console.error(`exited (${code}), restarting...`); rk.start() })
321
+
322
+ // Permissions
323
+ rk.on("accessibilityDenied", () => {
324
+ console.error("Go to System Settings → Privacy & Security → Accessibility")
325
+ })
326
+
327
+ // Errors
328
+ rk.on("error", (code, message) => console.error(`[${code}] ${message}`))
329
+
330
+ // Listen to all key events (key recorder / debugging)
331
+ // Stream activates automatically when subscribed, stops when unsubscribed.
332
+ const off = rk.on("key", (e) => {
333
+ console.log(e.type, e.keyCode, e.modifiers, e.app, e.appBundleID)
334
+ })
335
+ off() // unsubscribe — stream deactivates automatically
336
+
337
+ // Optionally await startup to catch errors explicitly
338
+ await rk.start()
339
+
340
+ // Stop the daemon
341
+ await rk.stop()
342
+
343
+ // Let the process exit even while the daemon is running
344
+ // (by default rethocker keeps the event loop alive)
345
+ rk.unref()
346
+ ```
347
+
348
+ ## API reference
349
+
350
+ ### `rethocker(rules?, options?)` → `RethockerHandle`
351
+
352
+ | Option | Type | Description |
353
+ |---|---|---|
354
+ | `binaryPath` | `string?` | Override the native binary path |
355
+
356
+ ### `RethockerHandle`
357
+
358
+ | Method | Returns | Description |
359
+ |---|---|---|
360
+ | `add(rule \| rule[])` | `void` | Add one or more rules |
361
+ | `remove(id)` | `void` | Remove a rule permanently |
362
+ | `enable(id?)` | `void` | Enable a rule by ID, or all rules if no ID |
363
+ | `disable(id?)` | `void` | Disable a rule by ID, or all rules if no ID |
364
+ | `on(event, listener)` | `() => void` | Subscribe to an event; returns an unsubscribe function |
365
+ | `execute(command)` | `Promise<void>` | Run a shell command immediately (accepts `string \| string[]`) |
366
+ | `start()` | `Promise<void>` | Await daemon readiness (optional) |
367
+ | `stop()` | `Promise<void>` | Stop the daemon |
368
+ | `unref()` | `void` | Allow the process to exit while the daemon runs |
369
+ | `ready` | `boolean` | Whether the daemon is ready |
370
+
371
+ ### Events
372
+
373
+ | Event | Arguments | Description |
374
+ |---|---|---|
375
+ | `"ready"` | — | Daemon ready |
376
+ | `"key"` | `KeyEvent` | Every key event (stream auto-activates on subscribe) |
377
+ | `"accessibilityDenied"` | — | Accessibility permission not granted |
378
+ | `"error"` | `code, message` | Native daemon error |
379
+ | `"exit"` | `code` | Native process exited unexpectedly |
380
+
381
+ ### `KeyEvent`
382
+
383
+ | Field | Type | Description |
384
+ |---|---|---|
385
+ | `type` | `"keydown" \| "keyup" \| "flags"` | Event type |
386
+ | `keyCode` | `number` | macOS virtual key code |
387
+ | `modifiers` | `Modifier[]` | Active modifiers |
388
+ | `app` | `string?` | Frontmost app display name |
389
+ | `appBundleID` | `string?` | Frontmost app bundle ID |
390
+ | `suppressed` | `boolean` | Whether the key was consumed |
391
+ | `ruleID` | `string?` | ID of the matched rule |
Binary file
package/package.json CHANGED
@@ -1,9 +1,29 @@
1
1
  {
2
2
  "name": "rethocker",
3
- "version": "0.0.2",
4
- "module": "src/index.ts",
3
+ "version": "0.1.1",
4
+ "description": "Intercept and remap global keys on macOS — with per-app, per-device, and key-sequence support",
5
5
  "type": "module",
6
+ "main": "src/index.ts",
7
+ "module": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "os": [
10
+ "darwin"
11
+ ],
12
+ "files": [
13
+ "src/",
14
+ "bin/rethocker-native"
15
+ ],
16
+ "scripts": {
17
+ "build:native": "cd swift && swift build -c release && cp .build/release/rethocker-native ../bin/rethocker-native && codesign --sign - ../bin/rethocker-native",
18
+ "build:native:universal": "cd swift && swift build -c release --arch arm64 --arch x86_64 && lipo -create .build/arm64-apple-macosx/release/rethocker-native .build/x86_64-apple-macosx/release/rethocker-native -output ../bin/rethocker-native && codesign --sign - ../bin/rethocker-native",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint": "biome lint --error-on-warnings .",
21
+ "format": "biome format --write .",
22
+ "check": "biome check --error-on-warnings .",
23
+ "test": "bun test"
24
+ },
6
25
  "devDependencies": {
26
+ "@biomejs/biome": "^2.4.9",
7
27
  "@types/bun": "latest"
8
28
  },
9
29
  "peerDependencies": {