karules 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 276d1cb288bdf01c66130cebd08d2ffb2723f7e2b8fce6cbe6ffa97a0aa430d5
4
+ data.tar.gz: 5dd19e077072c671737e8fef8093e1f05b97a871a38c850bb24cfb0859e80935
5
+ SHA512:
6
+ metadata.gz: 9933c4488162a5fd9e792c3dce4e220982d3a1709d6c9c511e123d8d0a798ba9a7c10c9925750fca3c4a1a5def0568c1a646b535d79f6f312a4530b02b76acfc
7
+ data.tar.gz: 2fc49300015041c4ea3dd9ade8b6dbbb0823669316013a7af98f14ffeefbeef0d733ea41ea3eb896ab4afcff103940076dddb259a046e4f8d2616c0e6ce4495e
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-01-23
9
+
10
+ ### Added
11
+ - Initial release of KaRules gem
12
+ - Ruby DSL for configuring Karabiner-Elements
13
+ - Support for keyboard mappings with modifiers
14
+ - Support for mouse button mappings
15
+ - Application-specific rules with bundle identifiers
16
+ - Modal keyboard modes with state management
17
+ - Custom Karabiner config path via `karabiner_path` DSL
18
+ - XDG Base Directory specification support
19
+ - Command-line executable for loading user configs
20
+ - Example configuration file
21
+ - Comprehensive test suite
22
+
23
+ ### Features
24
+ - Simple mapping syntax: `m("from", "to")`
25
+ - Modifier support: `+mandatory` and `-optional`
26
+ - Shell command execution: `!command`
27
+ - Complex conditions and parameters
28
+ - Group organization with descriptions
29
+ - Deep hash sorting for consistent JSON output
30
+
31
+ [0.1.0]: https://github.com/avsej/karules/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sergey Tarasov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # karules
2
+
3
+ Configure Karabiner-Elements with Ruby DSL - cleaner, more maintainable keyboard customization for macOS.
4
+
5
+ ## What is this?
6
+
7
+ If you're like me and have a complex Karabiner-Elements configuration, you probably know the pain of editing that giant JSON file manually. I got tired of it and wrote a Ruby DSL to generate the config instead.
8
+
9
+ **Features:**
10
+ - Clean, readable Ruby syntax instead of JSON
11
+ - Group related mappings together
12
+ - Reusable helper methods
13
+ - Application-specific rules
14
+ - Modal keyboard modes
15
+ - No need to remember Karabiner's JSON structure
16
+
17
+ ## Why?
18
+
19
+ Karabiner-Elements is powerful but its configuration format is...verbose. Here's a simple caps lock → control mapping:
20
+
21
+ **JSON (what Karabiner wants):**
22
+ ```json
23
+ {
24
+ "description": "Caps Lock to Control",
25
+ "manipulators": [{
26
+ "type": "basic",
27
+ "from": {
28
+ "key_code": "caps_lock",
29
+ "modifiers": { "optional": ["any"] }
30
+ },
31
+ "to": [{ "key_code": "left_control" }]
32
+ }]
33
+ }
34
+ ```
35
+
36
+ **Ruby (what you write):**
37
+ ```ruby
38
+ group("Caps Lock") do
39
+ m("caps_lock -any", "left_control")
40
+ end
41
+ ```
42
+
43
+ Much better.
44
+
45
+ ## Installation
46
+
47
+ ### Via Homebrew (soon)
48
+
49
+ ```bash
50
+ brew tap dzirtusss/tap
51
+ brew install karules
52
+ ```
53
+
54
+ ### Via RubyGems
55
+
56
+ ```bash
57
+ gem install karules
58
+ ```
59
+
60
+ ### Manual
61
+
62
+ ```bash
63
+ git clone https://github.com/dzirtusss/karules.git
64
+ cd karules
65
+ gem build karules.gemspec
66
+ gem install karules-0.1.0.gem
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ Create your config file at `~/.config/karules/config.rb`:
72
+
73
+ ```ruby
74
+ require "karules"
75
+
76
+ class MyConfig < KaRules
77
+ def config
78
+ # Define your apps
79
+ apps(
80
+ slack: "^com\\.tinyspeck\\.slackmacgap$",
81
+ terminal: "^com\\.apple\\.Terminal$"
82
+ )
83
+
84
+ # Simple mapping
85
+ group("Caps Lock") do
86
+ m("caps_lock -any", "left_control")
87
+ end
88
+
89
+ # App-specific rules
90
+ group("Slack shortcuts") do
91
+ app_if(:slack) do
92
+ m("h +right_option", "left_arrow")
93
+ m("l +right_option", "right_arrow")
94
+ end
95
+ end
96
+
97
+ # Launch apps
98
+ group("App launcher") do
99
+ m("t +right_command", "!open -a 'Terminal'")
100
+ m("s +right_command", "!open -a 'Safari'")
101
+ end
102
+
103
+ # Modal modes (vim-style)
104
+ group("Tab mode") do
105
+ m("tab", "right_option lazy", to_if_alone: "tab")
106
+ m("j +right_option", "down_arrow")
107
+ m("k +right_option", "up_arrow")
108
+ m("h +right_option", "left_arrow")
109
+ m("l +right_option", "right_arrow")
110
+ end
111
+ end
112
+ end
113
+
114
+ MyConfig.new.call
115
+ ```
116
+
117
+ Then run:
118
+
119
+ ```bash
120
+ karules
121
+ ```
122
+
123
+ It will update your `~/.config/karabiner/karabiner.json` automatically.
124
+
125
+ ## Usage
126
+
127
+ ### Basic Mapping
128
+
129
+ ```ruby
130
+ m("from_key", "to_key")
131
+ ```
132
+
133
+ ### Modifiers
134
+
135
+ **Mandatory modifiers** (must be pressed):
136
+ ```ruby
137
+ m("a +command", "b") # Command+A → B
138
+ ```
139
+
140
+ **Optional modifiers** (can be pressed):
141
+ ```ruby
142
+ m("a -any", "b") # A (with any modifiers) → B
143
+ ```
144
+
145
+ ### Shell Commands
146
+
147
+ ```ruby
148
+ m("t +command", "!open -a 'Terminal'")
149
+ ```
150
+
151
+ ### Application-Specific Rules
152
+
153
+ ```ruby
154
+ apps(slack: "^com\\.tinyspeck\\.slackmacgap$")
155
+
156
+ app_if(:slack) do
157
+ m("j +control", "down_arrow")
158
+ end
159
+ ```
160
+
161
+ ### Complex Mappings
162
+
163
+ ```ruby
164
+ m("a +control",
165
+ to: "b",
166
+ to_if_alone: "escape",
167
+ conditions: some_condition)
168
+ ```
169
+
170
+ ### Modal Modes
171
+
172
+ Create vim-like modal keyboards:
173
+
174
+ ```ruby
175
+ group("Vi Mode") do
176
+ default_mode("vi-mode")
177
+
178
+ # Enter mode
179
+ m("escape", mode_on)
180
+
181
+ # Exit on any non-vi key
182
+ m("escape", mode_off)
183
+
184
+ # Mappings only active in mode
185
+ mode_if do
186
+ m("h", "left_arrow")
187
+ m("j", "down_arrow")
188
+ m("k", "up_arrow")
189
+ m("l", "right_arrow")
190
+ end
191
+ end
192
+ ```
193
+
194
+ ### Custom Karabiner Path
195
+
196
+ ```ruby
197
+ def config
198
+ karabiner_path "~/custom/karabiner.json"
199
+ # ... rest of config
200
+ end
201
+ ```
202
+
203
+ ## Tips
204
+
205
+ **Use groups** to organize your config:
206
+ ```ruby
207
+ group("Navigation") do
208
+ # related mappings
209
+ end
210
+
211
+ group("App Launcher") do
212
+ # related mappings
213
+ end
214
+ ```
215
+
216
+ **Extract common patterns** into methods:
217
+ ```ruby
218
+ def vim_nav(prefix)
219
+ m("h #{prefix}", "left_arrow")
220
+ m("j #{prefix}", "down_arrow")
221
+ m("k #{prefix}", "up_arrow")
222
+ m("l #{prefix}", "right_arrow")
223
+ end
224
+
225
+ group("Vim nav") do
226
+ vim_nav("+option")
227
+ end
228
+ ```
229
+
230
+ **Check the example** config for more ideas:
231
+ - See the [example config](examples/config.rb)
232
+ - Or run: `gem contents karules | grep examples`
233
+
234
+ ## How It Works
235
+
236
+ 1. You write Ruby DSL in `~/.config/karules/config.rb`
237
+ 2. Run `karules` command
238
+ 3. It generates the JSON rules
239
+ 4. Updates your Karabiner config
240
+ 5. Karabiner picks up the changes automatically
241
+
242
+ The DSL is just Ruby, so you can:
243
+ - Use variables and loops
244
+ - Write helper methods
245
+ - Split config into multiple files (with `load`)
246
+ - Generate mappings programmatically
247
+
248
+ ## Troubleshooting
249
+
250
+ **Config not loading?**
251
+
252
+ Make sure your config file is at:
253
+ - `~/.config/karules/config.rb` (default)
254
+ - Or specify: `karules /path/to/config.rb`
255
+
256
+ **Changes not applying?**
257
+
258
+ Karabiner should detect changes automatically. If not:
259
+ - Check Karabiner-EventViewer for errors
260
+ - Verify your JSON at `~/.config/karabiner/karabiner.json`
261
+ - Restart Karabiner-Elements
262
+
263
+ **Syntax errors?**
264
+
265
+ Your config is Ruby code. Check for:
266
+ - Missing `do`/`end` blocks
267
+ - Unmatched quotes
268
+ - Typos in method names
269
+
270
+ ## Comparison with Alternatives
271
+
272
+ **JSON (built-in):** Maximum control but verbose and error-prone
273
+
274
+ **Goku/KarabinerDSL:** Great alternatives! This is just my take with Ruby instead of Clojure/other DSLs. Use whatever works for you.
275
+
276
+ **Why Ruby?** Because I write Ruby daily and wanted something that feels natural to me. If you prefer other languages, check out the alternatives.
277
+
278
+ ## Known Limitations
279
+
280
+ - This is an early version, expect some rough edges
281
+ - Not all Karabiner features are wrapped (but you can use raw hashes for anything)
282
+ - No validation yet (invalid configs will fail at Karabiner level)
283
+ - Examples use my personal workflow - adapt to yours
284
+
285
+ ## Future Ideas
286
+
287
+ - [ ] Config validation before writing
288
+ - [ ] Interactive config generator
289
+ - [ ] More helper methods for common patterns
290
+ - [ ] Better error messages
291
+ - [ ] Support for multiple profiles
292
+
293
+ PRs welcome!
294
+
295
+ ## License
296
+
297
+ MIT - see [LICENSE](LICENSE)
298
+
299
+ ## Credits
300
+
301
+ Built with frustration and coffee by [@dzirtusss](https://github.com/dzirtusss)
302
+
303
+ Inspired by everyone who's ever looked at a Karabiner JSON file and thought "there must be a better way."
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Karabiner configuration
4
+ # Copy this file to ~/.config/karules/config.rb and customize it
5
+
6
+ require "karules"
7
+
8
+ class MyKaRules < KaRules
9
+ def key_mode(key, mode)
10
+ m(
11
+ key,
12
+ to_if_alone: { key_code: key, halt: true },
13
+ to_after_key_up: mode_off(mode),
14
+ to_delayed_action: { to_if_canceled: { key_code: key }, to_if_invoked: mode_on(mode) },
15
+ parameters: {
16
+ "basic.to_if_held_down_threshold_milliseconds": 300,
17
+ "basic.to_delayed_action_delay_milliseconds": 300
18
+ }
19
+ )
20
+ end
21
+
22
+ def config
23
+ # Optional: specify custom Karabiner config path
24
+ # Default: $XDG_CONFIG_HOME/karabiner/karabiner.json or ~/.config/karabiner/karabiner.json
25
+ # karabiner_path "~/custom/path/karabiner.json"
26
+
27
+ apps(slack: "^com\\.tinyspeck\\.slackmacgap$", ghostty: "^com\\.mitchellh\\.ghostty$")
28
+
29
+ group("Caps Lock") do
30
+ # m("caps_lock -any", "left_control", to_if_alone: "escape")
31
+ m("caps_lock -any", "left_control")
32
+ end
33
+
34
+ group("Mouse buttons") do
35
+ m("pointing_button:button5 -any", "f3") # mission control
36
+ # m("pointing_button:button5 -any", "tab +right_command")
37
+ end
38
+
39
+ group("Tmux") do
40
+ app_unless(:ghostty) do
41
+ # Example: Focus terminal app, wait, then send Ctrl+A
42
+ # Replace with your own terminal focus script
43
+ m(
44
+ "a +control",
45
+ [
46
+ "!open -a 'Terminal'",
47
+ { key_code: "vk_none", hold_down_milliseconds: 100 },
48
+ "a +control"
49
+ ]
50
+ )
51
+ end
52
+ end
53
+
54
+ group("Tab mode") do
55
+ m("tab", "right_option lazy", to_if_alone: "tab")
56
+
57
+ m("j +right_option", "down_arrow")
58
+ m("k +right_option", "up_arrow")
59
+
60
+ app_if(:slack) do
61
+ m("h +right_option", "f6 +shift")
62
+ m("l +right_option", "f6")
63
+ m("semicolon +right_option", "right_arrow")
64
+ end
65
+
66
+ m("h +right_option", "left_arrow")
67
+ m("l +right_option", "right_arrow")
68
+
69
+ m("w +right_option", "right_arrow +right_option")
70
+ m("b +right_option", "left_arrow +right_option")
71
+ m("u +right_option", "page_up")
72
+ m("d +right_option", "page_down")
73
+ end
74
+
75
+ group("Mouse mode", enabled: false) do
76
+ default_mode("mouse-mode")
77
+ scroll = "mouse-scroll"
78
+
79
+ step = 1000
80
+ mult1 = 0.5
81
+ mult2 = 2
82
+ wheel = 50
83
+
84
+ # m("fn -any", mode_on, to_if_alone: "fn", to_after_key_up: mode_off)
85
+
86
+ key_mode("d", "mouse-mode")
87
+ mode_if do
88
+ m("left_shift +right_shift", mode_off)
89
+ m("right_shift +left_shift", mode_off)
90
+ end
91
+ m("left_shift +right_shift", mode_on)
92
+ m("right_shift +left_shift", mode_on)
93
+
94
+ mode_if do
95
+ mode_if(scroll) do
96
+ m("j -any", { mouse_key: { vertical_wheel: wheel } })
97
+ m("k -any", { mouse_key: { vertical_wheel: -wheel } })
98
+ m("h -any", { mouse_key: { horizontal_wheel: wheel } })
99
+ m("l -any", { mouse_key: { horizontal_wheel: -wheel } })
100
+ end
101
+
102
+ # normal movement
103
+ m("j -any", { mouse_key: { y: step } })
104
+ m("k -any", { mouse_key: { y: -step } })
105
+ m("h -any", { mouse_key: { x: -step } })
106
+ m("l -any", { mouse_key: { x: step } })
107
+
108
+ # mode modifiers
109
+ m("s -any", mode_on(scroll), to_after_key_up: mode_off(scroll))
110
+ m("c -any", { mouse_key: { speed_multiplier: mult1 } })
111
+ m("f -any", { mouse_key: { speed_multiplier: mult2 } })
112
+
113
+ # buttons
114
+ m("b -any", { pointing_button: "button1" })
115
+ m("spacebar -any", { pointing_button: "button1" })
116
+ m("n -any", { pointing_button: "button2" })
117
+
118
+ # position
119
+ m("u -any", { software_function: { set_mouse_cursor_position: { x: "20%", y: "20%" } } })
120
+ m("i -any", { software_function: { set_mouse_cursor_position: { x: "80%", y: "20%" } } })
121
+ m("o -any", { software_function: { set_mouse_cursor_position: { x: "20%", y: "80%" } } })
122
+ m("p -any", { software_function: { set_mouse_cursor_position: { x: "80%", y: "80%" } } })
123
+ m("m -any", { software_function: { set_mouse_cursor_position: { x: "50%", y: "50%" } } })
124
+ end
125
+ end
126
+
127
+ group("MacOS double CmdQ") do
128
+ default_mode("macos-q-command")
129
+ m("q +command", "q +command", conditions: mode_if)
130
+ m("q +command", mode_on, to_delayed_action: { to_if_canceled: mode_off, to_if_invoked: mode_off })
131
+ end
132
+
133
+ # Example: Switch between terminal windows/tabs
134
+ # Replace with your own terminal switching script
135
+ max = 9
136
+ group("terminal 1-#{max}") do
137
+ app_if(:ghostty) do
138
+ (1..max).each { |i| m("#{i} +left_command", "#{i} +command") }
139
+ end
140
+
141
+ (1..max).each { |i| m("#{i} +left_option", "#{i} +command") }
142
+ end
143
+
144
+ # Example: Application launcher shortcuts
145
+ group("Apps") do
146
+ m("j +right_command", "!open -a 'Terminal'")
147
+ m("k +right_command", "!open -a 'Safari'")
148
+ m("semicolon +right_command", "!open -a 'Mail'")
149
+
150
+ m("f +right_command", "!open -a 'Finder'")
151
+ m("s +right_command", "!open -a 'Slack'")
152
+ m("c +right_command", "!open -a 'Google Chrome'")
153
+ m("n +right_command", "!open -a 'Notes'")
154
+
155
+ # You can also map to other key combinations
156
+ m("t +right_command", "t +control +command +option")
157
+ end
158
+ end
159
+ end
160
+
161
+ MyKaRules.new.call
data/exe/karules ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "karules"
5
+
6
+ # Determine config file path
7
+ config_file = ARGV.first
8
+
9
+ unless config_file
10
+ # Use XDG_CONFIG_HOME or default to ~/.config
11
+ config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
12
+ config_file = File.join(config_home, "karules", "config.rb")
13
+ end
14
+
15
+ # Check if config file exists
16
+ unless File.exist?(config_file)
17
+ warn "Config file not found: #{config_file}"
18
+ warn ""
19
+ warn "Usage: karules [CONFIG_FILE]"
20
+ warn ""
21
+ warn "Create a config file at #{config_file}"
22
+ warn "or specify a custom path as an argument."
23
+ warn ""
24
+ warn "See the example config at:"
25
+ warn " #{File.expand_path('../../examples/config.rb', __dir__)}"
26
+ exit 1
27
+ end
28
+
29
+ # Load and execute the config file
30
+ load config_file
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
4
+ # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
5
+
6
+ require "json"
7
+
8
+ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
9
+ APPLE_KEYS = %w[spotlight].freeze
10
+
11
+ def m(
12
+ from, to = nil, conditions: nil, to_if_alone: nil, to_delayed_action: nil,
13
+ to_if_held_down: nil, parameters: nil, to_after_key_up: nil
14
+ )
15
+ res = {}
16
+ conditions = @default_conditions || conditions
17
+ parameters = @default_parameters || parameters
18
+ res[:conditions] = wrap(conditions) if conditions
19
+ res[:parameters] = parameters if parameters
20
+
21
+ res[:from] = from(from)
22
+
23
+ res[:to] = to(to) if to
24
+ res[:to_if_alone] = to(to_if_alone) if to_if_alone
25
+ res[:to_if_held_down] = to(to_if_held_down) if to_if_held_down
26
+ res[:to_delayed_action] = to_delayed_action if to_delayed_action
27
+ res[:to_after_key_up] = to(to_after_key_up) if to_after_key_up
28
+
29
+ res[:type] = "basic"
30
+ @manipulators << res
31
+ end
32
+
33
+ def from(from)
34
+ return from unless from.is_a?(String)
35
+
36
+ args = from.split
37
+ result =
38
+ if args.first.match?(":")
39
+ k, v = args.first.split(":")
40
+ { k.to_sym => v }
41
+ else
42
+ { key_code: args.first }
43
+ end
44
+ args[1..].each do |mod|
45
+ result[:modifiers] ||= {}
46
+ if mod.start_with?("+")
47
+ result[:modifiers][:mandatory] ||= []
48
+ result[:modifiers][:mandatory] << mod[1..]
49
+ elsif mod.start_with?("-")
50
+ result[:modifiers][:optional] ||= []
51
+ result[:modifiers][:optional] << mod[1..]
52
+ else
53
+ raise("Unknown modifier: #{mod}")
54
+ end
55
+ end
56
+ result
57
+ end
58
+
59
+ def to(to)
60
+ return to.map { |t| to(t) } if to.is_a?(Array)
61
+ return to unless to.is_a?(String)
62
+ return { shell_command: to[1..] } if to.start_with?("!")
63
+
64
+ args = to.split
65
+ result = { key_code: args.first }
66
+ args[1..].each do |mod|
67
+ if mod == "lazy"
68
+ result[:lazy] = true
69
+ elsif mod.start_with?("+")
70
+ result[:modifiers] ||= []
71
+ result[:modifiers] << mod[1..]
72
+ # elsif mod.start_with?("!")
73
+ # result[:shell_command] = mod[1..] + args.split(mod).last
74
+ # break
75
+ else
76
+ raise("Unknown modifier: #{mod}")
77
+ end
78
+ end
79
+ result
80
+ end
81
+
82
+ def v(name, value)
83
+ { set_variable: { name:, value: } }
84
+ end
85
+
86
+ def v_if(name, value, &)
87
+ block_given? ? conditions(v_if(name, value), &) : { name:, type: "variable_if", value: }
88
+ end
89
+
90
+ def v_unless(name, value, &)
91
+ block_given? ? conditions(v_unless(name, value), &) : { name:, type: "variable_unless", value: }
92
+ end
93
+
94
+ def mode_on(name = @default_mode) = v(name, true)
95
+ def mode_off(name = @default_mode)= v(name, false)
96
+ def mode_if(name = @default_mode, &) = v_if(name, true, &)
97
+ def mode_unless(name = @default_mode, &) = v_unless(name, true, &)
98
+
99
+ def apps(**apps)
100
+ @apps = apps
101
+ end
102
+
103
+ def app_if(name, &)
104
+ if block_given?
105
+ conditions(app_if(name), &)
106
+ else
107
+ app = @apps[name] || raise("Unknown app: #{name}")
108
+ { bundle_identifiers: wrap(app), type: "frontmost_application_if" }
109
+ end
110
+ end
111
+
112
+ def app_unless(name, &)
113
+ if block_given?
114
+ conditions(app_unless(name), &)
115
+ else
116
+ app = @apps[name] || raise("Unknown app: #{name}")
117
+ { bundle_identifiers: wrap(app), type: "frontmost_application_unless" }
118
+ end
119
+ end
120
+
121
+ def desc(description)
122
+ @description = description
123
+ end
124
+
125
+ def wrap(obj_or_arr)
126
+ obj_or_arr.is_a?(Array) ? obj_or_arr : [obj_or_arr]
127
+ end
128
+
129
+ def default_mode(name)
130
+ @default_mode = name
131
+ end
132
+
133
+ def conditions(*conditions)
134
+ original_conditions = @default_conditions
135
+ @default_conditions = (@default_conditions || []) + conditions
136
+ yield
137
+ @default_conditions = original_conditions
138
+ end
139
+
140
+ def parameters(**parameters)
141
+ original_parameters = @default_parameters
142
+ @default_parameters = (@default_parameters || {}).merge(parameters)
143
+ yield
144
+ @default_parameters = original_parameters
145
+ end
146
+
147
+ def group(description = "", skip: false, enabled: true)
148
+ return if skip
149
+
150
+ @description = description
151
+ @manipulators = []
152
+ @default_mode = nil
153
+ @default_conditions = nil
154
+ @default_parameters = nil
155
+ yield
156
+ @result << { description: @description, manipulators: @manipulators }
157
+ @result.last[:enabled] = false unless enabled
158
+ end
159
+
160
+ def karabiner_path(path = nil)
161
+ return @karabiner_path if path.nil?
162
+
163
+ @karabiner_path = File.expand_path(path)
164
+ end
165
+
166
+ def generate
167
+ @result = []
168
+ config
169
+ @result = deep_sort(@result)
170
+ end
171
+
172
+ def call
173
+ file = karabiner_path || default_karabiner_path
174
+ json = JSON.parse(File.read(file), symbolize_names: true)
175
+
176
+ json[:profiles][0][:complex_modifications][:rules].replace(generate)
177
+
178
+ File.write(file, json.to_json)
179
+ `karabiner_cli --format-json #{file}`
180
+ end
181
+
182
+ private
183
+
184
+ def default_karabiner_path
185
+ config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
186
+ File.join(config_home, "karabiner", "karabiner.json")
187
+ end
188
+
189
+ def deep_sort(obj)
190
+ case obj
191
+ when Array
192
+ obj.map { |el| deep_sort(el) }
193
+ when Hash
194
+ obj.sort_by { |k, _| k }.to_h.transform_values { |v| deep_sort(v) }
195
+ else
196
+ obj
197
+ end
198
+ end
199
+ end
200
+
201
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
202
+ # rubocop:enable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karules
4
+ VERSION = "0.1.0"
5
+ end
data/lib/karules.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "karules/dsl"
4
+ require_relative "karules/version"
5
+
6
+ # Base class for Karabiner configuration
7
+ # Users should subclass this and override the config method
8
+ class KaRules
9
+ include KaRulesDSL
10
+
11
+ def config
12
+ # Override this method in your configuration file
13
+ # See examples/config.rb for a complete example
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: karules
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergey Tarasov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rubocop
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.69'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.69'
27
+ description: A Ruby DSL for configuring Karabiner-Elements - cleaner, more maintainable
28
+ keyboard customization for macOS
29
+ email:
30
+ - dzirtusss@gmail.com
31
+ executables:
32
+ - karules
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE
38
+ - README.md
39
+ - examples/config.rb
40
+ - exe/karules
41
+ - lib/karules.rb
42
+ - lib/karules/dsl.rb
43
+ - lib/karules/version.rb
44
+ homepage: https://github.com/dzirtusss/karules
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/dzirtusss/karules
49
+ source_code_uri: https://github.com/dzirtusss/karules
50
+ bug_tracker_uri: https://github.com/dzirtusss/karules/issues
51
+ changelog_uri: https://github.com/dzirtusss/karules/blob/main/CHANGELOG.md
52
+ rubygems_mfa_required: 'true'
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.3.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.5.11
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Configure Karabiner-Elements with Ruby DSL
72
+ test_files: []