rethocker 0.2.1 → 0.2.3

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,8 +1,27 @@
1
- # rethocker
2
-
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).
1
+ <p align="center">
2
+ <img src="src/assets/logo.png" width="280px" align="center" alt="rethocker logo" />
3
+ <h1 align="center">rethocker</h1>
4
+ <p align="center">
5
+ System-wide key interception and remapping for macOS, in TypeScript.
6
+ </p>
7
+ </p>
8
+
9
+ <!--- badges -->
10
+ <p align="center">
11
+ <a href="https://github.com/benjamine/rethocker/actions?query=branch%3Amain"><img src="https://github.com/benjamine/rethocker/actions/workflows/publish.yml/badge.svg?branch=main" alt="rethocker CI status" /></a>
12
+ <a href="https://twitter.com/beneidel" rel="nofollow"><img src="https://img.shields.io/badge/created%20by-@beneidel-BACABA.svg" alt="Created by Benjamin Eidelman"></a>
13
+ <a href="https://opensource.org/licenses/MIT" rel="nofollow"><img src="https://img.shields.io/github/license/benjamine/rethocker" alt="License"></a>
14
+ <a href="https://www.npmjs.com/package/rethocker" rel="nofollow"><img src="https://img.shields.io/npm/dw/rethocker.svg" alt="npm"></a>
15
+ <a href="https://github.com/benjamine/rethocker" rel="nofollow"><img src="https://img.shields.io/github/stars/benjamine/rethocker" alt="stars"></a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ - Requires **macOS 13+** and **Accessibility permission** (prompted automatically on first run).
21
+ - Built with a native daemon for low-latency key interception, and a TypeScript API for maximum flexibility and AI agent friendliness.
22
+ - Intercept any key or key chord, with per-app scoping and advanced conditions.
23
+ - Remap to other keys, execute shell commands, or call TypeScript handlers with full access to the API.
24
+ - Bundled with convenient actions for common macOS tasks like window management, media control, etc..
6
25
 
7
26
  ## Install
8
27
 
@@ -14,7 +33,7 @@ brew install rethocker
14
33
  rethocker install
15
34
  ```
16
35
 
17
- `rethocker install` scaffolds `~/.config/rethocker/default.ts` and registers a LaunchAgent that starts on login and auto-reloads whenever you save the file.
36
+ `rethocker install` scaffolds `~/.config/rethocker/default.ts` (where you write your own rules) and registers a LaunchAgent that starts on login and auto-reloads whenever you save the file.
18
37
 
19
38
  ```bash
20
39
  rethocker log # live key monitor — see what rethocker captures
@@ -34,6 +53,8 @@ bun add rethocker
34
53
  ## Usage
35
54
 
36
55
  ```ts
56
+ #!/usr/bin/env bun
57
+
37
58
  import { actions, Key, rethocker } from "rethocker"
38
59
 
39
60
  const rk = rethocker([
Binary file
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "rethocker",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Intercept and remap global keys on macOS — with per-app and key-sequence support",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "module": "src/index.ts",
8
8
  "types": "src/index.ts",
9
9
  "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/benjamine/rethocker.git"
13
+ },
10
14
  "os": [
11
15
  "darwin"
12
16
  ],
package/src/actions.ts CHANGED
@@ -71,7 +71,7 @@ tell application "Finder"
71
71
  end tell
72
72
  set screenW to item 3 of screenBounds
73
73
  set screenH to item 4 of screenBounds
74
- set menuBarH to 30
74
+ set menuBarH to item 2 of screenBounds
75
75
  set pos to ${posExpr}
76
76
  set sz to ${sizeExpr}
77
77
  ${targetBlock}
Binary file
package/src/cli.ts CHANGED
@@ -183,26 +183,44 @@ async function cmdRunConfig(configFile: string) {
183
183
 
184
184
  // ─── Help ─────────────────────────────────────────────────────────────────────
185
185
 
186
- const HELP = `
187
- Usage: rethocker <command>
188
-
189
- Commands:
190
- install Scaffold a config file and set up a background agent that
191
- starts on login and auto-reloads when the config is saved
192
- uninstall Stop the background agent and remove it (keeps your config)
193
- restart Restart the background agent
194
- status Show whether the background agent is running
195
- log Live key monitor shows keypresses in rethocker rule syntax
196
- so you can copy-paste them directly into your config
197
- help Print this help message
198
-
199
- Install via Homebrew:
200
- brew tap benjamine/tap
201
- brew install rethocker
202
- rethocker install
186
+ function buildHelp() {
187
+ const lines = [
188
+ "Usage: rethocker <command>",
189
+ "",
190
+ "Commands:",
191
+ " install Scaffold a config file and set up a background agent that",
192
+ " starts on login and auto-reloads when the config is saved",
193
+ " uninstall Stop the background agent and remove it (keeps your config)",
194
+ " restart Restart the background agent",
195
+ " status Show whether the background agent is running",
196
+ " log Live key monitor shows keypresses in rethocker rule syntax",
197
+ " so you can copy-paste them directly into your config",
198
+ " help Print this help message",
199
+ "",
200
+ ];
201
+
202
+ if (existsSync(PLIST_FILE)) {
203
+ const result = run([
204
+ "launchctl",
205
+ "print",
206
+ `${AGENT_DOMAIN}/${AGENT_LABEL}`,
207
+ ]);
208
+ const running = result.ok;
209
+ lines.push(
210
+ `Config: ${tildeify(CONFIG_FILE)} (your rethocker rules are here)`,
211
+ );
212
+ lines.push(`Status: ${running ? "running" : "not running"}`);
213
+ if (!running)
214
+ lines.push('Run "rethocker restart" to start the background agent.');
215
+ } else {
216
+ lines.push(
217
+ `Config: ${tildeify(CONFIG_FILE)} (not found — run "rethocker install")`,
218
+ );
219
+ }
203
220
 
204
- Docs: ${GITHUB}
205
- `.trim();
221
+ lines.push("", `Docs: ${GITHUB}`);
222
+ return lines.join("\n");
223
+ }
206
224
 
207
225
  // ─── Command dispatch ─────────────────────────────────────────────────────────
208
226
 
@@ -240,12 +258,12 @@ switch (subcommand) {
240
258
  case "help":
241
259
  case "--help":
242
260
  case "-h":
243
- console.log(HELP);
261
+ console.log(buildHelp());
244
262
  break;
245
263
 
246
264
  default:
247
265
  if (subcommand) console.error(`Unknown command: "${subcommand}"\n`);
248
- console.log(HELP);
266
+ console.log(buildHelp());
249
267
  process.exit(subcommand ? 1 : 0);
250
268
  }
251
269
 
package/src/daemon.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
+ import { existsSync } from "node:fs";
14
15
  import type { KeyEvent, RethockerEvents } from "./types.ts";
15
16
 
16
17
  // ─── Typed EventEmitter (internal, never exported) ────────────────────────────
@@ -67,9 +68,33 @@ export function createDaemon(binaryPath: string): Daemon {
67
68
  [];
68
69
  let startPromise: Promise<void> | null = null;
69
70
 
71
+ // ─── Rule registry (for replay on daemon restart) ─────────────────────────
72
+ // Stores the JSON payload of every active add_rule / add_sequence command,
73
+ // keyed by rule ID. Updated on remove and set_enabled so replayed rules
74
+ // reflect the current state (not the original registration state).
75
+
76
+ const ruleRegistry = new Map<string, Record<string, unknown>>();
77
+ let listenAllEnabled = false;
78
+ let isFirstStart = true;
79
+
80
+ function trackSend(obj: Record<string, unknown>): void {
81
+ const cmd = obj.cmd as string | undefined;
82
+ if (cmd === "add_rule" || cmd === "add_sequence") {
83
+ ruleRegistry.set(obj.id as string, obj);
84
+ } else if (cmd === "remove_rule" || cmd === "remove_sequence") {
85
+ ruleRegistry.delete(obj.id as string);
86
+ } else if (cmd === "set_rule_enabled" || cmd === "set_sequence_enabled") {
87
+ const stored = ruleRegistry.get(obj.id as string);
88
+ if (stored) stored.enabled = obj.enabled;
89
+ } else if (cmd === "listen_all") {
90
+ listenAllEnabled = obj.enabled as boolean;
91
+ }
92
+ }
93
+
70
94
  // ─── Send / queue ─────────────────────────────────────────────────────────
71
95
 
72
96
  function send(obj: Record<string, unknown>): void {
97
+ trackSend(obj);
73
98
  const line = `${JSON.stringify(obj)}\n`;
74
99
  if (!_ready) {
75
100
  sendQueue.push(line);
@@ -79,6 +104,19 @@ export function createDaemon(binaryPath: string): Daemon {
79
104
  }
80
105
 
81
106
  function flushQueue(): void {
107
+ // On restart (not the first start), replay all registered rules first so
108
+ // the new daemon instance is back in the same state as before the crash.
109
+ if (!isFirstStart) {
110
+ for (const payload of ruleRegistry.values()) {
111
+ proc?.stdin.write(`${JSON.stringify(payload)}\n`);
112
+ }
113
+ if (listenAllEnabled) {
114
+ proc?.stdin.write(
115
+ `${JSON.stringify({ cmd: "listen_all", enabled: true })}\n`,
116
+ );
117
+ }
118
+ }
119
+ isFirstStart = false;
82
120
  for (const line of sendQueue) proc?.stdin.write(line);
83
121
  sendQueue = [];
84
122
  }
@@ -192,6 +230,19 @@ export function createDaemon(binaryPath: string): Daemon {
192
230
  },
193
231
  });
194
232
 
233
+ if (!existsSync(binaryPath)) {
234
+ const err = new Error(
235
+ `rethocker-native binary not found at: ${binaryPath}\n` +
236
+ ` If installed via Homebrew, try: brew reinstall rethocker\n` +
237
+ ` If installed via npm/bun, try: bun add rethocker\n` +
238
+ ` You can also override the path with: rethocker(rules, { binaryPath: "..." })`,
239
+ );
240
+ clearTimeout(timeout);
241
+ reject(err);
242
+ startPromise = null;
243
+ return;
244
+ }
245
+
195
246
  proc = Bun.spawn({
196
247
  cmd: [binaryPath],
197
248
  stdin: "pipe",
@@ -39,6 +39,7 @@ function toAppCondition(value: string): AppCondition {
39
39
 
40
40
  function buildConditions(rule: {
41
41
  app?: string | string[];
42
+ textInput?: boolean;
42
43
  conditions?: RuleConditions;
43
44
  }): RuleConditions | undefined {
44
45
  const base = rule.conditions ?? {};
@@ -49,10 +50,15 @@ function buildConditions(rule: {
49
50
  ? [...(base.activeApp ?? []), ...[rule.app].flat().map(toAppCondition)]
50
51
  : base.activeApp;
51
52
 
52
- const merged: RuleConditions = { ...base, activeApp };
53
+ // textInput shorthand merges into conditions (rule-level wins over conditions)
54
+ const textInput = rule.textInput ?? base.textInput;
55
+
56
+ const merged: RuleConditions = { ...base, activeApp, textInput };
53
57
 
54
58
  const hasAny =
55
- merged.activeApp !== undefined || merged.runningApps !== undefined;
59
+ merged.activeApp !== undefined ||
60
+ merged.runningApps !== undefined ||
61
+ merged.textInput !== undefined;
56
62
 
57
63
  return hasAny ? merged : undefined;
58
64
  }
@@ -158,7 +164,7 @@ function registerHandler(
158
164
  },
159
165
  );
160
166
 
161
- emitter.on("sequence", (seqRuleID, eid) => {
167
+ const listener = (seqRuleID: string, eid: string | undefined) => {
162
168
  if (eid === eventID) {
163
169
  const event: KeyEvent = {
164
170
  type: "keydown",
@@ -170,7 +176,13 @@ function registerHandler(
170
176
  };
171
177
  rule.handler(event);
172
178
  }
173
- });
179
+ };
180
+ emitter.on("sequence", listener);
181
+ const originalRemove = handle.remove;
182
+ handle.remove = () => {
183
+ emitter.off("sequence", listener);
184
+ originalRemove();
185
+ };
174
186
 
175
187
  return handle;
176
188
  }
package/src/rethocker.ts CHANGED
@@ -58,10 +58,14 @@ export function rethocker(
58
58
  // ─── Rule handles ─────────────────────────────────────────────────────────
59
59
  const handles = new Map<string, RuleHandle | SequenceHandle>();
60
60
 
61
- // Start daemon in background; errors surface via rk.on("error") or await rk.start()
62
- // Silent errors surface via rk.on("error") or await rk.start()
63
- daemon.start().catch((_e: unknown) => {
64
- /* intentional */
61
+ // Start daemon in background. If there's an error listener registered,
62
+ // startup errors are forwarded to it. Otherwise they're silently swallowed
63
+ // (call await rk.start() to catch them explicitly as a rejected promise).
64
+ daemon.start().catch((e: unknown) => {
65
+ if (daemon.emitter.listenerCount("error") > 0) {
66
+ const message = e instanceof Error ? e.message : String(e);
67
+ daemon.emitter.emit("error", "startup_failed", message);
68
+ }
65
69
  });
66
70
 
67
71
  function add(toAdd: RethockerRule | RethockerRule[]): void {
@@ -85,7 +85,7 @@ export function intercept(
85
85
  ): RuleHandle {
86
86
  const eventID = genID("intercept");
87
87
  const handle = addRule(send, trigger, { type: "emit", eventID }, opts);
88
- emitter.on("event", (eid, ruleID) => {
88
+ const listener = (eid: string, ruleID: string) => {
89
89
  if (eid === eventID) {
90
90
  handler({
91
91
  type: "keydown",
@@ -96,6 +96,12 @@ export function intercept(
96
96
  suppressed: true,
97
97
  });
98
98
  }
99
- });
99
+ };
100
+ emitter.on("event", listener);
101
+ const originalRemove = handle.remove;
102
+ handle.remove = () => {
103
+ emitter.off("event", listener);
104
+ originalRemove();
105
+ };
100
106
  return handle;
101
107
  }
package/src/rule-types.ts CHANGED
@@ -39,7 +39,18 @@ interface RuleBase {
39
39
  * app: ["Safari", "Chrome", "Firefox"]
40
40
  */
41
41
  app?: string | string[];
42
- /** Advanced: full condition control. Merged with `app` if both provided. */
42
+ /**
43
+ * Restrict this rule based on whether a text input field is focused.
44
+ * - `true` → fire only when a text field IS focused
45
+ * - `false` → fire only when NO text field is focused
46
+ * Omit to fire regardless of text input state.
47
+ *
48
+ * @example
49
+ * // Only fire when NOT in a text field (safe global shortcut)
50
+ * { key: "Ctrl+J", handler: () => {}, textInput: false }
51
+ */
52
+ textInput?: boolean;
53
+ /** Advanced: full condition control. Merged with `app` and `textInput` if both provided. */
43
54
  conditions?: RuleConditions;
44
55
  /** Start the rule disabled. */
45
56
  disabled?: boolean;
package/src/types.ts CHANGED
@@ -50,6 +50,15 @@ export interface RuleConditions {
50
50
  * Items are OR-ed; omit to not care.
51
51
  */
52
52
  runningApps?: AppCondition[];
53
+ /**
54
+ * Restrict this rule based on whether a text input field is focused.
55
+ * - `true` → fire only when a text field IS focused
56
+ * - `false` → fire only when NO text field is focused
57
+ * Omit to fire regardless of text input state.
58
+ *
59
+ * Uses the macOS Accessibility API (cached at 50ms TTL).
60
+ */
61
+ textInput?: boolean;
53
62
  }
54
63
 
55
64
  // ─── Actions ─────────────────────────────────────────────────────────────────