karules 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 276d1cb288bdf01c66130cebd08d2ffb2723f7e2b8fce6cbe6ffa97a0aa430d5
4
- data.tar.gz: 5dd19e077072c671737e8fef8093e1f05b97a871a38c850bb24cfb0859e80935
3
+ metadata.gz: f8598b4746e38d1ede0dfeef75a5c4eddaf8807f4684db40c8caf9a4361d6a07
4
+ data.tar.gz: 8ad72a2d0032d080df898bff9041afcdd4546beed36f69f10fd0092d524dada6
5
5
  SHA512:
6
- metadata.gz: 9933c4488162a5fd9e792c3dce4e220982d3a1709d6c9c511e123d8d0a798ba9a7c10c9925750fca3c4a1a5def0568c1a646b535d79f6f312a4530b02b76acfc
7
- data.tar.gz: 2fc49300015041c4ea3dd9ade8b6dbbb0823669316013a7af98f14ffeefbeef0d733ea41ea3eb896ab4afcff103940076dddb259a046e4f8d2616c0e6ce4495e
6
+ metadata.gz: 543bf369b4c3001e2a8f87c31f5e7e5fe36dfad40101a0736dc1d48e764845390f43ea137358d18206b58e742a457caadef580004d92827345e8af73c18baea2
7
+ data.tar.gz: a4b1004847086e33a791f5c77a9d1af88159ed5ad5c76d3fdd26ced1fb028c14a10dc065cda12cdf68aa2c4b6b73c52ce24a7836b96654daa512a98daf64df20
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2025-01-23
9
+
10
+ ### Added
11
+ - `--version` / `-v` flag to show gem version
12
+ - `--help` / `-h` flag to show usage information
13
+ - `init` command to generate sample config file
14
+ - `--validate` / `--check` flag to validate config syntax without applying
15
+ - `--dry-run` flag to preview changes without writing to Karabiner
16
+ - `--verbose` flag for detailed output
17
+ - `--no-backup` flag to skip automatic config backup
18
+ - Automatic config file detection in multiple locations:
19
+ - `$XDG_CONFIG_HOME/karules/config.rb`
20
+ - `~/.config/karules/config.rb`
21
+ - `~/.karules.rb`
22
+ - `./karules.rb`
23
+ - Automatic backup of existing Karabiner config before changes
24
+ - Success message after config update showing file path
25
+ - Better error messages and help text
26
+
27
+ ### Changed
28
+ - CLI now uses OptionParser for robust argument handling
29
+ - Config file path is now optional (auto-detected)
30
+ - Improved error messages when config file not found
31
+
8
32
  ## [0.1.0] - 2025-01-23
9
33
 
10
34
  ### Added
@@ -28,4 +52,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
52
  - Group organization with descriptions
29
53
  - Deep hash sorting for consistent JSON output
30
54
 
31
- [0.1.0]: https://github.com/avsej/karules/releases/tag/v0.1.0
55
+ [0.2.0]: https://github.com/dzirtusss/karules/releases/tag/v0.2.0
56
+ [0.1.0]: https://github.com/dzirtusss/karules/releases/tag/v0.1.0
data/README.md CHANGED
@@ -44,7 +44,7 @@ Much better.
44
44
 
45
45
  ## Installation
46
46
 
47
- ### Via Homebrew (soon)
47
+ ### Via Homebrew (recommended)
48
48
 
49
49
  ```bash
50
50
  brew tap dzirtusss/tap
@@ -57,15 +57,6 @@ brew install karules
57
57
  gem install karules
58
58
  ```
59
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
60
  ## Configuration
70
61
 
71
62
  Create your config file at `~/.config/karules/config.rb`:
@@ -122,6 +113,74 @@ karules
122
113
 
123
114
  It will update your `~/.config/karabiner/karabiner.json` automatically.
124
115
 
116
+ ## CLI Commands
117
+
118
+ ### Generate a config file
119
+
120
+ ```bash
121
+ karules init
122
+ ```
123
+
124
+ Creates `~/.config/karules/config.rb` with examples. Edit this file to add your mappings.
125
+
126
+ ### Apply your config
127
+
128
+ ```bash
129
+ karules # Auto-detect config file
130
+ karules my-config.rb # Use specific file
131
+ ```
132
+
133
+ ### Validate config
134
+
135
+ ```bash
136
+ karules --validate # Check syntax without applying
137
+ ```
138
+
139
+ ### Preview changes
140
+
141
+ ```bash
142
+ karules --dry-run # See what would change
143
+ ```
144
+
145
+ ### Show version
146
+
147
+ ```bash
148
+ karules --version
149
+ ```
150
+
151
+ ### Get help
152
+
153
+ ```bash
154
+ karules --help
155
+ ```
156
+
157
+ ### All options
158
+
159
+ ```
160
+ Usage: karules [COMMAND|CONFIG_FILE] [OPTIONS]
161
+
162
+ Commands:
163
+ init Generate a sample config file
164
+ run [FILE] Load and apply config (default)
165
+
166
+ Options:
167
+ -v, --version Show version
168
+ -h, --help Show this help
169
+ --verbose Show detailed output
170
+ --dry-run Preview changes without applying
171
+ --validate Validate config syntax only
172
+ --no-backup Skip backup of existing config
173
+ ```
174
+
175
+ ### Config file auto-detection
176
+
177
+ karules automatically searches for config files in this order:
178
+ 1. Explicit path argument
179
+ 2. `$XDG_CONFIG_HOME/karules/config.rb`
180
+ 3. `~/.config/karules/config.rb`
181
+ 4. `~/.karules.rb`
182
+ 5. `./karules.rb`
183
+
125
184
  ## Usage
126
185
 
127
186
  ### Basic Mapping
data/examples/config.rb CHANGED
@@ -40,14 +40,7 @@ class MyKaRules < KaRules
40
40
  app_unless(:ghostty) do
41
41
  # Example: Focus terminal app, wait, then send Ctrl+A
42
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
- )
43
+ m("a +control", ["!open -a 'Terminal'", { key_code: "vk_none", hold_down_milliseconds: 100 }, "a +control"])
51
44
  end
52
45
  end
53
46
 
data/exe/karules CHANGED
@@ -1,30 +1,278 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "English"
5
+ require "fileutils"
4
6
  require "karules"
7
+ require "optparse"
5
8
 
6
- # Determine config file path
7
- config_file = ARGV.first
9
+ # CLI handler for karules
10
+ class KarulesCLI
11
+ attr_reader :options
8
12
 
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
13
+ BASIC_CONFIG_TEMPLATE = <<~RUBY
14
+ # frozen_string_literal: true
15
+
16
+ require "karules"
17
+
18
+ class MyKaRules < KaRules
19
+ def config
20
+ # Define your application bundle identifiers
21
+ apps(
22
+ terminal: "^com\\\\.apple\\\\.Terminal$",
23
+ safari: "^com\\\\.apple\\\\.Safari$"
24
+ )
25
+
26
+ # Simple mapping example
27
+ group("Caps Lock to Control") do
28
+ m("caps_lock -any", "left_control")
29
+ end
30
+
31
+ # App launcher example
32
+ group("Quick App Launcher") do
33
+ m("t +right_command", "!open -a 'Terminal'")
34
+ m("s +right_command", "!open -a 'Safari'")
35
+ end
36
+
37
+ # Modal mode example (vim-style navigation)
38
+ group("Tab Navigation Mode") do
39
+ m("tab", "right_option lazy", to_if_alone: "tab")
40
+ m("h +right_option", "left_arrow")
41
+ m("j +right_option", "down_arrow")
42
+ m("k +right_option", "up_arrow")
43
+ m("l +right_option", "right_arrow")
44
+ end
45
+
46
+ # Add more groups and mappings here
47
+ end
48
+ end
49
+
50
+ MyKaRules.new.call
51
+ RUBY
52
+ private_constant :BASIC_CONFIG_TEMPLATE
53
+
54
+ def initialize
55
+ @options = { verbose: false, dry_run: false, validate: false, backup: true }
56
+ end
57
+
58
+ def run(args)
59
+ parse_options(args)
60
+
61
+ command = args.shift || "run"
62
+
63
+ case command
64
+ when "init"
65
+ init_config
66
+ when "run", nil
67
+ run_config(args.first)
68
+ when "version", "--version", "-v"
69
+ show_version
70
+ when "help", "--help", "-h"
71
+ show_help
72
+ else
73
+ run_config(command) # Treat as config file path
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def parse_options(args)
80
+ parser =
81
+ OptionParser.new do |opts|
82
+ opts.banner = "Usage: karules [COMMAND|CONFIG_FILE] [OPTIONS]"
83
+ opts.separator("")
84
+ opts.separator("Commands:")
85
+ opts.separator(" init Generate a sample config file")
86
+ opts.separator(" run [FILE] Load and apply config (default command)")
87
+ opts.separator("")
88
+ opts.separator("Options:")
89
+
90
+ opts.on("-v", "--version", "Show version") do
91
+ show_version
92
+ exit(0)
93
+ end
94
+
95
+ opts.on("-h", "--help", "Show this help") do
96
+ puts(opts)
97
+ exit(0)
98
+ end
99
+
100
+ opts.on("--verbose", "Show detailed output") do
101
+ @options[:verbose] = true
102
+ end
103
+
104
+ opts.on("--dry-run", "Preview changes without applying") do
105
+ @options[:dry_run] = true
106
+ end
107
+
108
+ opts.on("--validate", "--check", "Validate config syntax without applying") do
109
+ @options[:validate] = true
110
+ end
111
+
112
+ opts.on("--no-backup", "Skip backup of existing Karabiner config") do
113
+ @options[:backup] = false
114
+ end
115
+ end
116
+ parser.parse!(args)
117
+ end
118
+
119
+ def show_version
120
+ puts("karules #{Karules::VERSION}")
121
+ end
122
+
123
+ def show_help
124
+ puts(<<~HELP)
125
+ karules - Configure Karabiner-Elements with Ruby DSL
126
+
127
+ Usage:
128
+ karules [COMMAND|CONFIG_FILE] [OPTIONS]
129
+
130
+ Commands:
131
+ init Generate a sample config file
132
+ run [FILE] Load and apply config (default)
133
+
134
+ Options:
135
+ -v, --version Show version
136
+ -h, --help Show this help
137
+ --verbose Show detailed output
138
+ --dry-run Preview changes without applying
139
+ --validate Validate config syntax only
140
+ --no-backup Skip backup of existing config
141
+
142
+ Examples:
143
+ karules # Use default config
144
+ karules init # Generate sample config
145
+ karules my-config.rb # Use specific config
146
+ karules --dry-run # Preview changes
147
+ karules --validate # Check syntax only
148
+
149
+ Config file locations (searched in order):
150
+ 1. Explicit path argument
151
+ 2. $XDG_CONFIG_HOME/karules/config.rb
152
+ 3. ~/.config/karules/config.rb
153
+ 4. ~/.karules.rb
154
+ 5. ./karules.rb
155
+ HELP
156
+ end
157
+
158
+ def init_config
159
+ config_dir = File.join(ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config")), "karules")
160
+ config_file = File.join(config_dir, "config.rb")
161
+
162
+ if File.exist?(config_file) # rubocop:disable Style/MissingElse
163
+ warn("Config file already exists: #{config_file}")
164
+ warn("Remove it first or edit it manually.")
165
+ exit(1)
166
+ end
167
+
168
+ FileUtils.mkdir_p(config_dir)
169
+
170
+ # Get example config from gem
171
+ gem_root = File.expand_path("../..", __dir__)
172
+ example_file = File.join(gem_root, "examples", "config.rb")
173
+
174
+ if File.exist?(example_file)
175
+ FileUtils.cp(example_file, config_file)
176
+ else
177
+ # Fallback: create basic config
178
+ File.write(config_file, BASIC_CONFIG_TEMPLATE)
179
+ end
180
+
181
+ puts("✓ Created config file: #{config_file}")
182
+ puts("")
183
+ puts("Edit this file to customize your keyboard mappings.")
184
+ puts("Then run: karules")
185
+ end
186
+
187
+ def run_config(explicit_path)
188
+ config_file = find_config_file(explicit_path)
189
+
190
+ log("Using config: #{config_file}")
191
+
192
+ if @options[:validate] # rubocop:disable Style/MissingElse
193
+ validate_config(config_file)
194
+ puts("✓ Config syntax is valid")
195
+ exit(0)
196
+ end
197
+
198
+ # Set environment variables for DSL
199
+ ENV["KARULES_DRY_RUN"] = "1" if @options[:dry_run]
200
+ ENV["KARULES_BACKUP"] = @options[:backup] ? "1" : "0"
201
+ ENV["KARULES_VERBOSE"] = "1" if @options[:verbose]
202
+
203
+ # Load and execute the config
204
+ load(config_file)
205
+
206
+ if @options[:dry_run]
207
+ puts("")
208
+ puts("✓ Dry run complete - no changes were made")
209
+ puts(" Run without --dry-run to apply changes")
210
+ else
211
+ config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
212
+ karabiner_file = File.join(config_home, "karabiner", "karabiner.json")
213
+ puts("")
214
+ puts("✓ Karabiner config updated successfully")
215
+ puts(" Config file: #{karabiner_file}")
216
+ end
217
+ end
218
+
219
+ def find_config_file(explicit_path)
220
+ if explicit_path # rubocop:disable Style/MissingElse
221
+ return explicit_path if File.exist?(explicit_path)
222
+
223
+ warn("Config file not found: #{explicit_path}")
224
+ exit(1)
225
+ end
226
+
227
+ # Auto-detect config file
228
+ candidates = [
229
+ File.join(ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config")), "karules", "config.rb"),
230
+ File.expand_path("~/.config/karules/config.rb"),
231
+ File.expand_path("~/.karules.rb"),
232
+ File.expand_path("./karules.rb")
233
+ ]
234
+
235
+ config_file = candidates.find { |f| File.exist?(f) }
236
+
237
+ unless config_file
238
+ warn("No config file found. Searched:")
239
+ candidates.each { |f| warn(" - #{f}") }
240
+ warn("")
241
+ warn("Create a config file with: karules init")
242
+ warn("Or specify a path: karules /path/to/config.rb")
243
+ exit(1)
244
+ end
245
+
246
+ config_file
247
+ end
248
+
249
+ def validate_config(config_file)
250
+ log("Validating config syntax...")
251
+
252
+ # Try to load the file and check for syntax errors
253
+ begin
254
+ # Use ruby -c to check syntax
255
+ output = `ruby -c "#{config_file}" 2>&1`
256
+ exit_code = $CHILD_STATUS.exitstatus
257
+
258
+ if exit_code != 0 # rubocop:disable Style/MissingElse
259
+ warn("Syntax error in config file:")
260
+ warn(output)
261
+ exit(1)
262
+ end
263
+
264
+ log("Syntax check passed")
265
+ rescue StandardError => e
266
+ warn("Error validating config: #{e.message}")
267
+ exit(1)
268
+ end
269
+ end
14
270
 
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
271
+ def log(message)
272
+ puts(message) if @options[:verbose]
273
+ end
27
274
  end
28
275
 
29
- # Load and execute the config file
30
- load config_file
276
+ # Run the CLI
277
+ cli = KarulesCLI.new
278
+ cli.run(ARGV.dup)
data/lib/karules/dsl.rb CHANGED
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
3
+ require "fileutils"
4
4
  # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
5
5
 
6
6
  require "json"
7
7
 
8
8
  module KaRulesDSL # rubocop:disable Metrics/ModuleLength
9
9
  APPLE_KEYS = %w[spotlight].freeze
10
+ private_constant :APPLE_KEYS
10
11
 
11
12
  def m(
12
13
  from, to = nil, conditions: nil, to_if_alone: nil, to_delayed_action: nil,
@@ -50,7 +51,7 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
50
51
  result[:modifiers][:optional] ||= []
51
52
  result[:modifiers][:optional] << mod[1..]
52
53
  else
53
- raise("Unknown modifier: #{mod}")
54
+ raise(ArgumentError, "Unknown modifier: #{mod}")
54
55
  end
55
56
  end
56
57
  result
@@ -73,7 +74,7 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
73
74
  # result[:shell_command] = mod[1..] + args.split(mod).last
74
75
  # break
75
76
  else
76
- raise("Unknown modifier: #{mod}")
77
+ raise(ArgumentError, "Unknown modifier: #{mod}")
77
78
  end
78
79
  end
79
80
  result
@@ -104,7 +105,7 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
104
105
  if block_given?
105
106
  conditions(app_if(name), &)
106
107
  else
107
- app = @apps[name] || raise("Unknown app: #{name}")
108
+ app = @apps[name] || raise(ArgumentError, "Unknown app: #{name}")
108
109
  { bundle_identifiers: wrap(app), type: "frontmost_application_if" }
109
110
  end
110
111
  end
@@ -113,7 +114,7 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
113
114
  if block_given?
114
115
  conditions(app_unless(name), &)
115
116
  else
116
- app = @apps[name] || raise("Unknown app: #{name}")
117
+ app = @apps[name] || raise(ArgumentError, "Unknown app: #{name}")
117
118
  { bundle_identifiers: wrap(app), type: "frontmost_application_unless" }
118
119
  end
119
120
  end
@@ -170,10 +171,20 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
170
171
  end
171
172
 
172
173
  def call
174
+ # Generate rules (this also loads the config which may set karabiner_path)
175
+ rules = generate
176
+
177
+ # Now we can determine the correct file path
173
178
  file = karabiner_path || default_karabiner_path
179
+
180
+ # Backup existing file if requested (unless in dry-run mode)
181
+ backup_file(file) if should_backup? && !dry_run?
182
+
174
183
  json = JSON.parse(File.read(file), symbolize_names: true)
175
184
 
176
- json[:profiles][0][:complex_modifications][:rules].replace(generate)
185
+ json[:profiles][0][:complex_modifications][:rules].replace(rules)
186
+
187
+ return if dry_run? # Don't write in dry-run mode
177
188
 
178
189
  File.write(file, json.to_json)
179
190
  `karabiner_cli --format-json #{file}`
@@ -181,6 +192,22 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
181
192
 
182
193
  private
183
194
 
195
+ def dry_run?
196
+ ENV["KARULES_DRY_RUN"] == "1"
197
+ end
198
+
199
+ def should_backup?
200
+ ENV["KARULES_BACKUP"] != "0"
201
+ end
202
+
203
+ def backup_file(file)
204
+ return unless File.exist?(file)
205
+
206
+ backup_path = "#{file}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
207
+ FileUtils.cp(file, backup_path)
208
+ puts("Backed up existing config to: #{backup_path}") if ENV["KARULES_VERBOSE"] == "1"
209
+ end
210
+
184
211
  def default_karabiner_path
185
212
  config_home = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
186
213
  File.join(config_home, "karabiner", "karabiner.json")
@@ -191,7 +218,8 @@ module KaRulesDSL # rubocop:disable Metrics/ModuleLength
191
218
  when Array
192
219
  obj.map { |el| deep_sort(el) }
193
220
  when Hash
194
- obj.sort_by { |k, _| k }.to_h.transform_values { |v| deep_sort(v) }
221
+ obj.sort_by { |k, _| k }
222
+ .to_h.transform_values { |v| deep_sort(v) }
195
223
  else
196
224
  obj
197
225
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Karules
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
+ public_constant :VERSION
5
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: karules
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Tarasov
@@ -10,6 +10,34 @@ bindir: exe
10
10
  cert_chain: []
11
11
  date: 2025-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
13
41
  - !ruby/object:Gem::Dependency
14
42
  name: rubocop
15
43
  requirement: !ruby/object:Gem::Requirement