dockedit 1.0.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.
data/dockedit ADDED
@@ -0,0 +1,1461 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # dockedit - A script to edit the macOS Dock
5
+ # Usage: dockedit <subcommand> [options] [args]
6
+
7
+ require 'rexml/document'
8
+ require 'fileutils'
9
+ require 'optparse'
10
+ require 'uri'
11
+ require 'stringio'
12
+
13
+ # frozen_string_literal: true
14
+
15
+ module DockEdit
16
+ # Current dockedit gem version.
17
+ VERSION = '1.0.0'
18
+ end
19
+
20
+
21
+ # frozen_string_literal: true
22
+
23
+ module DockEdit
24
+ # Constants used across the dockedit implementation.
25
+ module Constants
26
+ # Absolute path to the Dock preferences plist.
27
+ #
28
+ # In tests this constant is overridden to point at a temporary copy.
29
+ DOCK_PLIST = File.expand_path('~/Library/Preferences/com.apple.dock.plist')
30
+
31
+ # Mapping of short folder names to full filesystem paths.
32
+ #
33
+ # Keys are regular expressions matched against user input; values are
34
+ # expanded paths.
35
+ PATH_SHORTCUTS = {
36
+ /^desktop$/i => File.expand_path('~/Desktop'),
37
+ /^downloads$/i => File.expand_path('~/Downloads'),
38
+ /^(home|~)$/i => File.expand_path('~'),
39
+ /^library$/i => File.expand_path('~/Library'),
40
+ /^documents$/i => File.expand_path('~/Documents'),
41
+ /^(applications|apps)$/i => '/Applications',
42
+ /^sites$/i => File.expand_path('~/Sites')
43
+ }.freeze
44
+
45
+ # Mapping of show-as string aliases to integer values used in the plist.
46
+ #
47
+ # 1 = fan, 2 = grid, 3 = list, 4 = auto.
48
+ SHOW_AS_VALUES = {
49
+ 'f' => 1, 'fan' => 1,
50
+ 'g' => 2, 'grid' => 2,
51
+ 'l' => 3, 'list' => 3,
52
+ 'a' => 4, 'auto' => 4
53
+ }.freeze
54
+
55
+ # Mapping of display-as string aliases to integer values used in the plist.
56
+ #
57
+ # 0 = stack, 1 = folder.
58
+ DISPLAY_AS_VALUES = {
59
+ 's' => 0, 'stack' => 0,
60
+ 'f' => 1, 'folder' => 1
61
+ }.freeze
62
+ end
63
+ end
64
+
65
+
66
+ # frozen_string_literal: true
67
+
68
+ module DockEdit
69
+ # Handles fuzzy matching for application names and Dock items.
70
+ #
71
+ # Matching is based on a simple scoring system that prefers exact matches,
72
+ # then "starts with", then substring and abbreviation matches.
73
+ class Matcher
74
+ # Calculate a fuzzy match score between two strings.
75
+ #
76
+ # A lower score means a better match. +nil+ indicates no match at all.
77
+ #
78
+ # @param query [String] User-entered search text.
79
+ # @param target [String] Candidate string to score against.
80
+ # @return [Integer, nil] Score where 0 is best, or +nil+ when there is no match.
81
+ def self.match_score(query, target)
82
+ return nil if target.nil? || target.empty?
83
+
84
+ query = query.downcase
85
+ target = target.downcase
86
+
87
+ # Exact match - best score
88
+ return 0 if target == query
89
+
90
+ # Starts with query
91
+ return 1 if target.start_with?(query)
92
+
93
+ # Contains match - score based on position
94
+ if target.include?(query)
95
+ return 2 + target.index(query)
96
+ end
97
+
98
+ # Abbreviation match (e.g., "vscode" matches "Visual Studio Code")
99
+ query_chars = query.chars
100
+ target_pos = 0
101
+ matched = true
102
+
103
+ query_chars.each do |char|
104
+ found = false
105
+ while target_pos < target.length
106
+ if target[target_pos] == char
107
+ found = true
108
+ target_pos += 1
109
+ break
110
+ end
111
+ target_pos += 1
112
+ end
113
+ unless found
114
+ matched = false
115
+ break
116
+ end
117
+ end
118
+
119
+ return 100 + target.length if matched
120
+
121
+ nil
122
+ end
123
+
124
+ # Check whether +target+ fuzzily matches +query+.
125
+ #
126
+ # @param query [String]
127
+ # @param target [String]
128
+ # @return [Boolean] +true+ if {#match_score} returns a non-nil score.
129
+ def self.fuzzy_match?(query, target)
130
+ !match_score(query, target).nil?
131
+ end
132
+
133
+ # Find the best-matching item index in a Dock array.
134
+ #
135
+ # This scans a list of Dock tile `<dict>` elements and finds the index
136
+ # whose label, bundle identifier, or (optionally) URL path best matches
137
+ # +query+.
138
+ #
139
+ # @param items_array [Array<REXML::Element>] Array of tile `<dict>` elements.
140
+ # @param query [String] Search term (app name, bundle id, or path fragment).
141
+ # @param check_url [Boolean] Whether to also consider the folder URL path.
142
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`, where
143
+ # +index+ is the best entry index or +nil+ when nothing matches.
144
+ def self.find_item_index(items_array, query, check_url: false)
145
+ best_score = nil
146
+ best_name = nil
147
+ best_index = nil
148
+
149
+ items_array.each_with_index do |item_dict, index|
150
+ next unless item_dict.is_a?(REXML::Element) && item_dict.name == 'dict'
151
+
152
+ tile_data = PlistReader.get_tile_data(item_dict)
153
+ next unless tile_data
154
+
155
+ file_label = PlistReader.get_tile_value(tile_data, 'file-label')
156
+ bundle_id = PlistReader.get_tile_value(tile_data, 'bundle-identifier')
157
+
158
+ scores = []
159
+ scores << match_score(query, file_label)
160
+ scores << match_score(query, bundle_id)
161
+
162
+ # For folders, also check URL path
163
+ if check_url
164
+ url_string = PlistReader.get_file_data_url(tile_data)
165
+ if url_string
166
+ # Extract path from file:// URL and decode
167
+ path = URI.decode_www_form_component(url_string.sub(%r{^file://}, '').chomp('/'))
168
+ basename = File.basename(path)
169
+ scores << match_score(query, path)
170
+ scores << match_score(query, basename)
171
+ end
172
+ end
173
+
174
+ current_score = scores.compact.min
175
+ next unless current_score
176
+
177
+ name = file_label || bundle_id || 'Unknown'
178
+ name_length = name.length
179
+
180
+ if best_score.nil? || current_score < best_score ||
181
+ (current_score == best_score && name_length < (best_name&.length || 999))
182
+ best_score = current_score
183
+ best_name = name
184
+ best_index = index
185
+ end
186
+ end
187
+
188
+ [best_index, best_name]
189
+ end
190
+
191
+ # Backwards-compatible alias for finding app indices.
192
+ #
193
+ # @param apps_array [Array<REXML::Element>] App tile `<dict>` elements.
194
+ # @param query [String] Search term.
195
+ # @return [Array<(Integer, String)>] See {#find_item_index}.
196
+ def self.find_app_index(apps_array, query)
197
+ find_item_index(apps_array, query, check_url: false)
198
+ end
199
+ end
200
+ end
201
+
202
+
203
+ # frozen_string_literal: true
204
+
205
+ module DockEdit
206
+ # Utility functions for handling user-supplied filesystem paths.
207
+ module PathUtils
208
+ include Constants
209
+
210
+ # Expand a path shortcut or user path into a full filesystem path.
211
+ #
212
+ # Recognizes shortcuts such as "desktop", "downloads", "home", "~",
213
+ # "applications", etc., falling back to +File.expand_path+.
214
+ #
215
+ # @param path [String] Raw path or shortcut.
216
+ # @return [String] Expanded absolute path with trailing slash removed.
217
+ def self.expand_path_shortcut(path)
218
+ PATH_SHORTCUTS.each do |pattern, expanded|
219
+ return expanded if path.match?(pattern)
220
+ end
221
+ # Not a shortcut, expand ~ and return
222
+ File.expand_path(path).chomp('/')
223
+ end
224
+
225
+ # Determine whether the given path refers to a folder (not an app bundle).
226
+ #
227
+ # This respects path shortcuts and returns +true+ only for existing
228
+ # directories that do not end in ".app".
229
+ #
230
+ # @param path [String] Raw path or shortcut.
231
+ # @return [Boolean]
232
+ def self.folder_path?(path)
233
+ expanded = expand_path_shortcut(path)
234
+ File.directory?(expanded) && !expanded.end_with?('.app')
235
+ end
236
+
237
+ # Check whether the given string looks like an explicit app bundle path.
238
+ #
239
+ # @param path [String]
240
+ # @return [Boolean] +true+ if the string ends with ".app" and contains a '/'.
241
+ def self.explicit_app_path?(path)
242
+ path.end_with?('.app') && path.include?('/')
243
+ end
244
+ end
245
+ end
246
+
247
+
248
+ # frozen_string_literal: true
249
+
250
+ module DockEdit
251
+ # Handles reading and parsing Dock and app plist data.
252
+ class PlistReader
253
+ include Constants
254
+
255
+ # Extract the +tile-data+ `<dict>` from a Dock tile `<dict>`.
256
+ #
257
+ # @param app_dict [REXML::Element] Tile `<dict>` element.
258
+ # @return [REXML::Element, nil] The nested +tile-data+ dict, or +nil+.
259
+ def self.get_tile_data(app_dict)
260
+ current_key = nil
261
+ app_dict.elements.each do |elem|
262
+ if elem.name == 'key'
263
+ current_key = elem.text
264
+ elsif elem.name == 'dict' && current_key == 'tile-data'
265
+ return elem
266
+ end
267
+ end
268
+ nil
269
+ end
270
+
271
+ # Look up a string value in a tile-data `<dict>`.
272
+ #
273
+ # @param tile_data [REXML::Element] Tile-data `<dict>`.
274
+ # @param key_name [String] Name of the key to retrieve.
275
+ # @return [String, nil] The associated string value, or +nil+.
276
+ def self.get_tile_value(tile_data, key_name)
277
+ current_key = nil
278
+ tile_data.elements.each do |elem|
279
+ if elem.name == 'key'
280
+ current_key = elem.text
281
+ elsif current_key == key_name
282
+ return elem.text if elem.name == 'string'
283
+ return nil
284
+ end
285
+ end
286
+ nil
287
+ end
288
+
289
+ # Extract the `_CFURLString` from the nested +file-data+ dict.
290
+ #
291
+ # @param tile_data [REXML::Element] Tile-data `<dict>`.
292
+ # @return [String, nil] The URL string, or +nil+ if none is present.
293
+ def self.get_file_data_url(tile_data)
294
+ current_key = nil
295
+ tile_data.elements.each do |elem|
296
+ if elem.name == 'key'
297
+ current_key = elem.text
298
+ elsif elem.name == 'dict' && current_key == 'file-data'
299
+ return get_tile_value(elem, '_CFURLString')
300
+ end
301
+ end
302
+ nil
303
+ end
304
+
305
+ # Get the `persistent-apps` array from a Dock plist document.
306
+ #
307
+ # @param doc [REXML::Document]
308
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
309
+ def self.get_persistent_apps(doc)
310
+ get_plist_array(doc, 'persistent-apps')
311
+ end
312
+
313
+ # Get the `persistent-others` array from a Dock plist document.
314
+ #
315
+ # @param doc [REXML::Document]
316
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
317
+ def self.get_persistent_others(doc)
318
+ get_plist_array(doc, 'persistent-others')
319
+ end
320
+
321
+ # Get a named array from the Dock plist root `<dict>`.
322
+ #
323
+ # @param doc [REXML::Document]
324
+ # @param array_name [String] Name of the array key.
325
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
326
+ def self.get_plist_array(doc, array_name)
327
+ root_dict = doc.root.elements['dict']
328
+ return nil unless root_dict
329
+
330
+ current_key = nil
331
+ root_dict.elements.each do |elem|
332
+ if elem.name == 'key'
333
+ current_key = elem.text
334
+ elsif elem.name == 'array' && current_key == array_name
335
+ return elem
336
+ end
337
+ end
338
+
339
+ nil
340
+ end
341
+
342
+ # Load and parse the Dock plist as XML.
343
+ #
344
+ # The file at {DockEdit::Constants::DOCK_PLIST} is converted to XML form
345
+ # using +plutil+ before being read.
346
+ #
347
+ # @return [REXML::Document] Parsed Dock plist document.
348
+ def self.load_dock_plist
349
+ unless system("plutil -convert xml1 '#{DOCK_PLIST}' 2>/dev/null")
350
+ $stderr.puts "Error: Failed to convert Dock plist to XML"
351
+ exit 1
352
+ end
353
+
354
+ plist_content = File.read(DOCK_PLIST)
355
+ REXML::Document.new(plist_content)
356
+ end
357
+
358
+ # Read and parse +Info.plist+ from an app bundle.
359
+ #
360
+ # The plist is copied to a temporary file and converted to XML before
361
+ # parsing. Selected keys are extracted into a flat Ruby hash.
362
+ #
363
+ # @param app_path [String] Absolute path to an `.app` bundle.
364
+ # @return [Hash, nil] Hash of plist keys to values, or +nil+ if the
365
+ # plist cannot be read.
366
+ def self.read_app_info(app_path)
367
+ info_plist = File.join(app_path, 'Contents', 'Info.plist')
368
+ return nil unless File.exist?(info_plist)
369
+
370
+ # Convert to XML and read
371
+ temp_plist = "/tmp/dockedit_info_#{$$}.plist"
372
+ FileUtils.cp(info_plist, temp_plist)
373
+ system("plutil -convert xml1 '#{temp_plist}' 2>/dev/null")
374
+
375
+ content = File.read(temp_plist)
376
+ File.delete(temp_plist) if File.exist?(temp_plist)
377
+
378
+ doc = REXML::Document.new(content)
379
+ root_dict = doc.root.elements['dict']
380
+ return nil unless root_dict
381
+
382
+ info = {}
383
+ current_key = nil
384
+
385
+ root_dict.elements.each do |elem|
386
+ if elem.name == 'key'
387
+ current_key = elem.text
388
+ elsif current_key
389
+ case elem.name
390
+ when 'string'
391
+ info[current_key] = elem.text
392
+ when 'array'
393
+ # For arrays, get first string element
394
+ first_string = elem.elements['string']
395
+ info[current_key] = first_string.text if first_string
396
+ end
397
+ current_key = nil
398
+ end
399
+ end
400
+
401
+ info
402
+ end
403
+ end
404
+ end
405
+
406
+
407
+ # frozen_string_literal: true
408
+
409
+ module DockEdit
410
+ # Handles writing Dock plist data and restarting the Dock process.
411
+ class PlistWriter
412
+ include Constants
413
+
414
+ # Write the modified Dock plist and restart the Dock.
415
+ #
416
+ # The provided document is pretty-printed, written to
417
+ # {DockEdit::Constants::DOCK_PLIST}, converted back to binary format,
418
+ # and the Dock process is restarted. Success messages are printed and
419
+ # the process exits with status 0. On error, a message is printed and
420
+ # the process exits with status 1.
421
+ #
422
+ # @param doc [REXML::Document] Modified Dock plist document.
423
+ # @param success_messages [String, Array<String>] Message or messages to
424
+ # print after a successful write.
425
+ # @return [void]
426
+ def self.write_plist_and_restart(doc, success_messages)
427
+ formatter = REXML::Formatters::Pretty.new(2)
428
+ formatter.compact = true
429
+
430
+ output = StringIO.new
431
+ output << %{<?xml version="1.0" encoding="UTF-8"?>\n}
432
+ output << %{<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n}
433
+ formatter.write(doc.root, output)
434
+ output << "\n"
435
+
436
+ begin
437
+ File.write(DOCK_PLIST, output.string)
438
+ rescue => e
439
+ $stderr.puts "Error: Failure to update Dock plist - #{e.message}"
440
+ exit 1
441
+ end
442
+
443
+ # Convert back to binary and restart Dock
444
+ unless system("plutil -convert binary1 '#{DOCK_PLIST}' 2>/dev/null")
445
+ $stderr.puts "Error: Failure to update Dock plist"
446
+ exit 1
447
+ end
448
+
449
+ system('killall Dock')
450
+
451
+ # Handle single message or array of messages
452
+ messages = success_messages.is_a?(Array) ? success_messages : [success_messages]
453
+ messages.each { |msg| puts msg }
454
+ exit 0
455
+ end
456
+ end
457
+ end
458
+
459
+
460
+ # frozen_string_literal: true
461
+
462
+ module DockEdit
463
+ # Handles finding applications on disk using Spotlight (mdfind).
464
+ #
465
+ # This class is used by commands that accept an application name (e.g. "Safari")
466
+ # and need to resolve it to a full `.app` bundle path.
467
+ class AppFinder
468
+ # Find an application bundle on disk using Spotlight.
469
+ #
470
+ # The search is limited to `/Applications` and `/System/Applications`.
471
+ # Results are scored using {DockEdit::Matcher.match_score} and the best
472
+ # matching `.app` path is returned.
473
+ #
474
+ # @param query [String] Human-friendly app name to search for.
475
+ # @return [String, nil] Absolute path to the best matching `.app`, or +nil+
476
+ # if no match is found.
477
+ def self.find_app_on_disk(query)
478
+ # Search in /Applications and /System/Applications
479
+ result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemKind == "Application" && kMDItemDisplayName == "*#{query}*"cd' 2>/dev/null`.strip
480
+
481
+ if result.empty?
482
+ # Fallback to filename search
483
+ result = `mdfind -onlyin /Applications -onlyin /System/Applications 'kMDItemFSName == "*#{query}*.app"cd' 2>/dev/null`.strip
484
+ end
485
+
486
+ return nil if result.empty?
487
+
488
+ apps = result.split("\n").select { |p| p.end_with?('.app') }
489
+ return nil if apps.empty?
490
+
491
+ # Score and sort by best match (shortest name wins on equal score)
492
+ scored = apps.map do |path|
493
+ name = File.basename(path, '.app')
494
+ score = Matcher.match_score(query, name)
495
+ [path, name, score || 999, name.length]
496
+ end
497
+
498
+ # Sort by score, then by name length
499
+ scored.sort_by! { |_, _, score, len| [score, len] }
500
+
501
+ scored.first&.first
502
+ end
503
+ end
504
+ end
505
+
506
+
507
+ # frozen_string_literal: true
508
+
509
+ module DockEdit
510
+ # Factory for creating Dock tile elements.
511
+ #
512
+ # These helpers construct the REXML structures that represent apps,
513
+ # folders, and spacer tiles inside the Dock plist.
514
+ class TileFactory
515
+ # Create a spacer tile element.
516
+ #
517
+ # @param small [Boolean] When +true+, create a half-size spacer.
518
+ # @return [REXML::Element] Newly created tile `<dict>` element.
519
+ def self.create_spacer_tile(small: false)
520
+ tile_type = small ? 'small-spacer-tile' : 'spacer-tile'
521
+
522
+ spacer = REXML::Element.new('dict')
523
+
524
+ key1 = REXML::Element.new('key')
525
+ key1.text = 'tile-data'
526
+ spacer.add(key1)
527
+
528
+ tile_data = REXML::Element.new('dict')
529
+ label_key = REXML::Element.new('key')
530
+ label_key.text = 'file-label'
531
+ tile_data.add(label_key)
532
+ label_string = REXML::Element.new('string')
533
+ label_string.text = ''
534
+ tile_data.add(label_string)
535
+ spacer.add(tile_data)
536
+
537
+ key2 = REXML::Element.new('key')
538
+ key2.text = 'tile-type'
539
+ spacer.add(key2)
540
+
541
+ type_string = REXML::Element.new('string')
542
+ type_string.text = tile_type
543
+ spacer.add(type_string)
544
+
545
+ spacer
546
+ end
547
+
548
+ # Create an app tile element.
549
+ #
550
+ # @param app_path [String] Absolute path to the `.app` bundle.
551
+ # @param app_info [Hash] Parsed plist info from {PlistReader.read_app_info}.
552
+ # @return [REXML::Element] Newly created tile `<dict>` element.
553
+ def self.create_app_tile(app_path, app_info)
554
+ app_dict = REXML::Element.new('dict')
555
+
556
+ # tile-data key
557
+ td_key = REXML::Element.new('key')
558
+ td_key.text = 'tile-data'
559
+ app_dict.add(td_key)
560
+
561
+ # tile-data dict
562
+ tile_data = REXML::Element.new('dict')
563
+
564
+ # bundle-identifier
565
+ add_plist_key_value(tile_data, 'bundle-identifier', 'string', app_info['CFBundleIdentifier'])
566
+
567
+ # dock-extra
568
+ add_plist_key_value(tile_data, 'dock-extra', 'false', nil)
569
+
570
+ # file-data dict
571
+ fd_key = REXML::Element.new('key')
572
+ fd_key.text = 'file-data'
573
+ tile_data.add(fd_key)
574
+
575
+ file_data = REXML::Element.new('dict')
576
+ add_plist_key_value(file_data, '_CFURLString', 'string', "file://#{URI.encode_www_form_component(app_path).gsub('%2F', '/')}/")
577
+ add_plist_key_value(file_data, '_CFURLStringType', 'integer', '15')
578
+ tile_data.add(file_data)
579
+
580
+ # file-label
581
+ label = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
582
+ add_plist_key_value(tile_data, 'file-label', 'string', label)
583
+
584
+ # file-mod-date
585
+ add_plist_key_value(tile_data, 'file-mod-date', 'integer', '0')
586
+
587
+ # file-type
588
+ add_plist_key_value(tile_data, 'file-type', 'integer', '41')
589
+
590
+ # is-beta
591
+ add_plist_key_value(tile_data, 'is-beta', 'false', nil)
592
+
593
+ # parent-mod-date
594
+ add_plist_key_value(tile_data, 'parent-mod-date', 'integer', '0')
595
+
596
+ app_dict.add(tile_data)
597
+
598
+ # tile-type key
599
+ tt_key = REXML::Element.new('key')
600
+ tt_key.text = 'tile-type'
601
+ app_dict.add(tt_key)
602
+
603
+ tt_value = REXML::Element.new('string')
604
+ tt_value.text = 'file-tile'
605
+ app_dict.add(tt_value)
606
+
607
+ app_dict
608
+ end
609
+
610
+ # Create a folder tile element.
611
+ #
612
+ # @param folder_path [String] Absolute path to the folder.
613
+ # @param show_as [Integer] Show-as value (1=fan, 2=grid, 3=list, 4=auto).
614
+ # @param display_as [Integer] Display-as value (0=stack, 1=folder).
615
+ # @return [REXML::Element] Newly created folder tile `<dict>` element.
616
+ def self.create_folder_tile(folder_path, show_as: 4, display_as: 1)
617
+ folder_dict = REXML::Element.new('dict')
618
+
619
+ # tile-data key
620
+ td_key = REXML::Element.new('key')
621
+ td_key.text = 'tile-data'
622
+ folder_dict.add(td_key)
623
+
624
+ # tile-data dict
625
+ tile_data = REXML::Element.new('dict')
626
+
627
+ # arrangement (0 = by name)
628
+ add_plist_key_value(tile_data, 'arrangement', 'integer', '0')
629
+
630
+ # displayas (0 = stack, 1 = folder)
631
+ add_plist_key_value(tile_data, 'displayas', 'integer', display_as.to_s)
632
+
633
+ # file-data dict
634
+ fd_key = REXML::Element.new('key')
635
+ fd_key.text = 'file-data'
636
+ tile_data.add(fd_key)
637
+
638
+ file_data = REXML::Element.new('dict')
639
+ encoded_path = URI.encode_www_form_component(folder_path).gsub('%2F', '/')
640
+ add_plist_key_value(file_data, '_CFURLString', 'string', "file://#{encoded_path}/")
641
+ add_plist_key_value(file_data, '_CFURLStringType', 'integer', '15')
642
+ tile_data.add(file_data)
643
+
644
+ # file-label
645
+ label = File.basename(folder_path)
646
+ add_plist_key_value(tile_data, 'file-label', 'string', label)
647
+
648
+ # file-mod-date
649
+ add_plist_key_value(tile_data, 'file-mod-date', 'integer', '0')
650
+
651
+ # file-type
652
+ add_plist_key_value(tile_data, 'file-type', 'integer', '2')
653
+
654
+ # parent-mod-date
655
+ add_plist_key_value(tile_data, 'parent-mod-date', 'integer', '0')
656
+
657
+ # preferreditemsize (-1 = default)
658
+ add_plist_key_value(tile_data, 'preferreditemsize', 'integer', '-1')
659
+
660
+ # showas (1=fan, 2=grid, 3=list, 4=auto)
661
+ add_plist_key_value(tile_data, 'showas', 'integer', show_as.to_s)
662
+
663
+ folder_dict.add(tile_data)
664
+
665
+ # tile-type key
666
+ tt_key = REXML::Element.new('key')
667
+ tt_key.text = 'tile-type'
668
+ folder_dict.add(tt_key)
669
+
670
+ tt_value = REXML::Element.new('string')
671
+ tt_value.text = 'directory-tile'
672
+ folder_dict.add(tt_value)
673
+
674
+ folder_dict
675
+ end
676
+
677
+ # Helper to add a key/value pair to a plist `<dict>` element.
678
+ #
679
+ # @param dict [REXML::Element] Target `<dict>` element.
680
+ # @param key_name [String] Key to add.
681
+ # @param value_type [String] Name of the value element type (e.g. "string").
682
+ # @param value [String, nil] Optional text value for the element.
683
+ # @return [void]
684
+ def self.add_plist_key_value(dict, key_name, value_type, value)
685
+ key = REXML::Element.new('key')
686
+ key.text = key_name
687
+ dict.add(key)
688
+
689
+ val_elem = REXML::Element.new(value_type)
690
+ val_elem.text = value if value
691
+ dict.add(val_elem)
692
+ end
693
+ end
694
+ end
695
+
696
+
697
+ # frozen_string_literal: true
698
+
699
+ module DockEdit
700
+ # Handles updating folder tile properties in the Dock plist.
701
+ #
702
+ # These helpers mutate existing folder tiles to change view and style
703
+ # without recreating them.
704
+ class FolderUpdater
705
+ # Update the +showas+ value for an existing folder tile.
706
+ #
707
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
708
+ # @param show_as [Integer] New show-as value (1=fan, 2=grid, 3=list, 4=auto).
709
+ # @return [Boolean] +true+ if the value was updated or added.
710
+ def self.update_folder_showas(folder_dict, show_as)
711
+ update_folder_integer_key(folder_dict, 'showas', show_as)
712
+ end
713
+
714
+ # Update the +displayas+ value for an existing folder tile.
715
+ #
716
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
717
+ # @param display_as [Integer] New display-as value (0=stack, 1=folder).
718
+ # @return [Boolean] +true+ if the value was updated or added.
719
+ def self.update_folder_displayas(folder_dict, display_as)
720
+ update_folder_integer_key(folder_dict, 'displayas', display_as)
721
+ end
722
+
723
+ # Update an integer key inside folder tile-data.
724
+ #
725
+ # If the key already exists its integer value is replaced; otherwise a new
726
+ # key/value pair is appended.
727
+ #
728
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
729
+ # @param key_name [String] Name of the integer key inside +tile-data+.
730
+ # @param value [Integer] New integer value to set.
731
+ # @return [Boolean] +true+ if the value was updated or added, +false+ if
732
+ # no +tile-data+ section could be found.
733
+ def self.update_folder_integer_key(folder_dict, key_name, value)
734
+ tile_data = PlistReader.get_tile_data(folder_dict)
735
+ return false unless tile_data
736
+
737
+ # Find and update the key
738
+ current_key = nil
739
+ tile_data.elements.each do |elem|
740
+ if elem.name == 'key' && elem.text == key_name
741
+ current_key = elem
742
+ elsif current_key && elem.name == 'integer'
743
+ elem.text = value.to_s
744
+ return true
745
+ end
746
+ end
747
+
748
+ # If key doesn't exist, add it
749
+ TileFactory.add_plist_key_value(tile_data, key_name, 'integer', value.to_s)
750
+ true
751
+ end
752
+ end
753
+ end
754
+
755
+
756
+ # frozen_string_literal: true
757
+
758
+ module DockEdit
759
+ # Utility functions for parsing and formatting Dock-related values.
760
+ module Parsers
761
+ include Constants
762
+
763
+ # Parse a show-as argument into an integer value.
764
+ #
765
+ # Accepts symbolic forms like "fan", "grid", "list", "auto" (and their
766
+ # single-letter aliases) and returns the corresponding integer constant.
767
+ #
768
+ # @param value [String, nil] Raw user input.
769
+ # @return [Integer, nil] Parsed show-as value, or +nil+ if +value+ is +nil+.
770
+ def self.parse_show_as(value)
771
+ return nil if value.nil?
772
+
773
+ key = value.downcase
774
+ SHOW_AS_VALUES[key] || 4
775
+ end
776
+
777
+ # Convert a numeric show-as value into a human-readable name.
778
+ #
779
+ # @param value [Integer] Numeric show-as value.
780
+ # @return [String] One of "fan", "grid", "list", or "auto".
781
+ def self.show_as_name(value)
782
+ case value
783
+ when 1 then 'fan'
784
+ when 2 then 'grid'
785
+ when 3 then 'list'
786
+ when 4 then 'auto'
787
+ else 'auto'
788
+ end
789
+ end
790
+
791
+ # Parse a display-as argument into an integer value.
792
+ #
793
+ # Accepts "stack"/"s" and "folder"/"f".
794
+ #
795
+ # @param value [String, nil] Raw user input.
796
+ # @return [Integer, nil] Parsed display-as value, or +nil+ if invalid or +nil+.
797
+ def self.parse_display_as(value)
798
+ return nil if value.nil?
799
+
800
+ key = value.downcase
801
+ DISPLAY_AS_VALUES[key]
802
+ end
803
+
804
+ # Convert a numeric display-as value into a human-readable name.
805
+ #
806
+ # @param value [Integer] Numeric display-as value.
807
+ # @return [String] Either "stack" or "folder".
808
+ def self.display_as_name(value)
809
+ case value
810
+ when 0 then 'stack'
811
+ when 1 then 'folder'
812
+ else 'stack'
813
+ end
814
+ end
815
+ end
816
+ end
817
+
818
+
819
+ # frozen_string_literal: true
820
+
821
+ module DockEdit
822
+ # Main class for managing Dock state and high-level operations.
823
+ #
824
+ # A {Dock} instance wraps a parsed Dock plist and provides helpers for
825
+ # locating and mutating app and folder tiles.
826
+ class Dock
827
+ attr_reader :doc, :apps_array, :others_array
828
+
829
+ # Create a new Dock wrapper around the current Dock plist.
830
+ #
831
+ # The plist is loaded via {PlistReader.load_dock_plist} and the
832
+ # `persistent-apps` and `persistent-others` arrays are extracted.
833
+ #
834
+ # @raise [SystemExit] Exits with status 1 if the arrays cannot be found.
835
+ def initialize
836
+ @doc = PlistReader.load_dock_plist
837
+ @apps_array = PlistReader.get_persistent_apps(@doc)
838
+ @others_array = PlistReader.get_persistent_others(@doc)
839
+
840
+ unless @apps_array && @others_array
841
+ $stderr.puts "Error: Could not find dock arrays in plist"
842
+ exit 1
843
+ end
844
+ end
845
+
846
+ # Persist changes to the Dock plist and restart the Dock.
847
+ #
848
+ # @param messages [String, Array<String>] Message or messages to print.
849
+ # @return [void]
850
+ def save(messages)
851
+ PlistWriter.write_plist_and_restart(@doc, messages)
852
+ end
853
+
854
+ # Find an application tile by name or bundle identifier.
855
+ #
856
+ # @param query [String] Search term.
857
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`
858
+ # from the apps array, or `[nil, nil]` if not found.
859
+ def find_app(query)
860
+ app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
861
+ Matcher.find_app_index(app_dicts, query)
862
+ end
863
+
864
+ # Find a folder tile by name, bundle identifier, or path.
865
+ #
866
+ # @param query [String] Search term (name or path).
867
+ # @param check_url [Boolean] Whether to match against the folder URL path.
868
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`
869
+ # from the folders array, or `[nil, nil]` if not found.
870
+ def find_folder(query, check_url: false)
871
+ folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
872
+ Matcher.find_item_index(folder_dicts, query, check_url: check_url)
873
+ end
874
+
875
+ # Find a tile in either the apps or folders section.
876
+ #
877
+ # Apps are searched first; if no match is found, folders are checked.
878
+ #
879
+ # @param query [String] Search term.
880
+ # @param check_url [Boolean] Whether to match against folder URL paths.
881
+ # @return [Array] A triple of `[array, element, display_name]`, or
882
+ # `[nil, nil, nil]` if nothing matches.
883
+ def find_item(query, check_url: false)
884
+ # Try apps first
885
+ index, name = find_app(query)
886
+ if index
887
+ app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
888
+ return [@apps_array, app_dicts[index], name]
889
+ end
890
+
891
+ # Try folders
892
+ index, name = find_folder(query, check_url: check_url)
893
+ if index
894
+ folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
895
+ return [@others_array, folder_dicts[index], name]
896
+ end
897
+
898
+ [nil, nil, nil]
899
+ end
900
+
901
+ # Return all app tile `<dict>` elements.
902
+ #
903
+ # @return [Array<REXML::Element>]
904
+ def get_app_dicts
905
+ @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
906
+ end
907
+
908
+ # Return all folder tile `<dict>` elements.
909
+ #
910
+ # @return [Array<REXML::Element>]
911
+ def get_folder_dicts
912
+ @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
913
+ end
914
+ end
915
+ end
916
+
917
+
918
+ # frozen_string_literal: true
919
+
920
+ module DockEdit
921
+ # Command handlers for `dockedit` subcommands.
922
+ #
923
+ # Each method in this module implements the behavior for a single
924
+ # subcommand, operating on the Dock plist via {DockEdit::Dock}.
925
+ module Commands
926
+ # Implementation of the `space` subcommand.
927
+ #
928
+ # Inserts one or more spacer tiles in the apps section of the Dock.
929
+ #
930
+ # @param args [Array<String>] Command-line arguments for the subcommand.
931
+ # @return [void]
932
+ def self.space(args)
933
+ options = { small: false, after: [] }
934
+
935
+ parser = CLI.space_parser(options)
936
+ parser.order!(args)
937
+
938
+ small = options[:small]
939
+ after_apps = options[:after]
940
+ spacer_type = small ? 'Small space' : 'Space'
941
+
942
+ dock = Dock.new
943
+ messages = []
944
+
945
+ if after_apps.empty?
946
+ # Add single space at end
947
+ spacer = TileFactory.create_spacer_tile(small: small)
948
+ dock.apps_array.add(spacer)
949
+ messages << "#{spacer_type} added to end of Dock."
950
+ else
951
+ # Process each --after app
952
+ after_apps.each do |after_app|
953
+ # Re-fetch app_dicts each time since array changes after insert
954
+ app_dicts = dock.get_app_dicts
955
+
956
+ index, name = dock.find_app(after_app)
957
+ unless index
958
+ $stderr.puts "Error: App '#{after_app}' not found in Dock"
959
+ exit 1
960
+ end
961
+
962
+ spacer = TileFactory.create_spacer_tile(small: small)
963
+ dock.apps_array.insert_after(app_dicts[index], spacer)
964
+ messages << "#{spacer_type} inserted after '#{name}'."
965
+ end
966
+ end
967
+
968
+ dock.save(messages)
969
+ end
970
+
971
+ # Implementation of the `add` subcommand.
972
+ #
973
+ # Adds applications and/or folders to the Dock, optionally positioning
974
+ # them relative to an existing item and updating folder view/style.
975
+ #
976
+ # @param args [Array<String>] Command-line arguments for the subcommand.
977
+ # @return [void]
978
+ def self.add(args)
979
+ options = { after: nil, show_as: nil, display_as: nil }
980
+
981
+ parser = CLI.add_parser(options)
982
+ parser.permute!(args)
983
+
984
+ after_target = options[:after]
985
+ show_as = options[:show_as]
986
+ display_as = options[:display_as]
987
+
988
+ if args.empty?
989
+ $stderr.puts parser
990
+ exit 1
991
+ end
992
+
993
+ dock = Dock.new
994
+ messages = []
995
+ last_inserted_app_element = nil
996
+ last_inserted_folder_element = nil
997
+
998
+ # Find initial insertion point if --after specified
999
+ if after_target
1000
+ after_array, after_element, _after_name = dock.find_item(after_target, check_url: true)
1001
+
1002
+ if after_element
1003
+ if after_array == dock.apps_array
1004
+ last_inserted_app_element = after_element
1005
+ else
1006
+ last_inserted_folder_element = after_element
1007
+ end
1008
+ else
1009
+ $stderr.puts "Error: '#{after_target}' not found in Dock"
1010
+ exit 1
1011
+ end
1012
+ end
1013
+
1014
+ # Process each argument
1015
+ args.each do |item_query|
1016
+ # Determine if this is a folder or app
1017
+ if PathUtils.folder_path?(item_query)
1018
+ # It's a folder
1019
+ folder_path = PathUtils.expand_path_shortcut(item_query)
1020
+
1021
+ unless File.directory?(folder_path)
1022
+ $stderr.puts "Error: Folder '#{folder_path}' not found"
1023
+ exit 1
1024
+ end
1025
+
1026
+ folder_name = File.basename(folder_path)
1027
+
1028
+ # Check if folder is already in dock
1029
+ existing_index, existing_name = dock.find_folder(folder_path, check_url: true)
1030
+
1031
+ if existing_index
1032
+ # If show_as or display_as was specified, update the existing folder
1033
+ updates = []
1034
+ folder_dicts = dock.get_folder_dicts
1035
+ if show_as
1036
+ FolderUpdater.update_folder_showas(folder_dicts[existing_index], show_as)
1037
+ updates << "view=#{Parsers.show_as_name(show_as)}"
1038
+ end
1039
+ if display_as
1040
+ FolderUpdater.update_folder_displayas(folder_dicts[existing_index], display_as)
1041
+ updates << "style=#{Parsers.display_as_name(display_as)}"
1042
+ end
1043
+
1044
+ if updates.any?
1045
+ messages << "'#{existing_name}' updated (#{updates.join(', ')})."
1046
+ else
1047
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
1048
+ end
1049
+ next
1050
+ end
1051
+
1052
+ # Create the folder tile (use defaults if not specified)
1053
+ folder_tile = TileFactory.create_folder_tile(folder_path, show_as: show_as || 4, display_as: display_as || 1)
1054
+
1055
+ if last_inserted_folder_element
1056
+ dock.others_array.insert_after(last_inserted_folder_element, folder_tile)
1057
+ else
1058
+ dock.others_array.add(folder_tile)
1059
+ end
1060
+
1061
+ last_inserted_folder_element = folder_tile
1062
+ messages << "'#{folder_name}' folder added to Dock."
1063
+
1064
+ elsif PathUtils.explicit_app_path?(item_query)
1065
+ # Explicit app path given
1066
+ app_path = File.expand_path(item_query)
1067
+
1068
+ unless File.exist?(File.join(app_path, 'Contents', 'Info.plist'))
1069
+ $stderr.puts "Error: Application path '#{app_path}' not found"
1070
+ exit 1
1071
+ end
1072
+
1073
+ app_info = PlistReader.read_app_info(app_path)
1074
+ unless app_info && app_info['CFBundleIdentifier']
1075
+ $stderr.puts "Error: Could not read app info from '#{app_path}'"
1076
+ exit 1
1077
+ end
1078
+
1079
+ app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
1080
+
1081
+ # Check if app is already in dock
1082
+ existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
1083
+
1084
+ if existing_index
1085
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
1086
+ next
1087
+ end
1088
+
1089
+ # Create the app tile
1090
+ app_tile = TileFactory.create_app_tile(app_path, app_info)
1091
+
1092
+ if last_inserted_app_element
1093
+ dock.apps_array.insert_after(last_inserted_app_element, app_tile)
1094
+ last_inserted_app_element = app_tile
1095
+ else
1096
+ dock.apps_array.add(app_tile)
1097
+ end
1098
+
1099
+ messages << "'#{app_name}' added to Dock."
1100
+
1101
+ else
1102
+ # Search for app by name
1103
+ app_path = AppFinder.find_app_on_disk(item_query)
1104
+ unless app_path
1105
+ $stderr.puts "Error: App '#{item_query}' not found"
1106
+ exit 1
1107
+ end
1108
+
1109
+ app_info = PlistReader.read_app_info(app_path)
1110
+ unless app_info && app_info['CFBundleIdentifier']
1111
+ $stderr.puts "Error: Could not read app info from '#{app_path}'"
1112
+ exit 1
1113
+ end
1114
+
1115
+ app_name = app_info['CFBundleName'] || app_info['CFBundleDisplayName'] || File.basename(app_path, '.app')
1116
+
1117
+ # Check if app is already in dock
1118
+ existing_index, existing_name = dock.find_app(app_info['CFBundleIdentifier'])
1119
+
1120
+ if existing_index
1121
+ $stderr.puts "Warning: '#{existing_name}' is already in the Dock, skipping"
1122
+ next
1123
+ end
1124
+
1125
+ # Create the app tile
1126
+ app_tile = TileFactory.create_app_tile(app_path, app_info)
1127
+
1128
+ if last_inserted_app_element
1129
+ dock.apps_array.insert_after(last_inserted_app_element, app_tile)
1130
+ last_inserted_app_element = app_tile
1131
+ else
1132
+ dock.apps_array.add(app_tile)
1133
+ end
1134
+
1135
+ messages << "'#{app_name}' added to Dock."
1136
+ end
1137
+ end
1138
+
1139
+ if messages.empty?
1140
+ $stderr.puts "No items were added to the Dock"
1141
+ exit 1
1142
+ end
1143
+
1144
+ dock.save(messages)
1145
+ end
1146
+
1147
+ # Implementation of the `remove` subcommand.
1148
+ #
1149
+ # Removes apps and/or folders from the Dock by name, bundle identifier,
1150
+ # or path.
1151
+ #
1152
+ # @param args [Array<String>] Command-line arguments for the subcommand.
1153
+ # @return [void]
1154
+ def self.remove(args)
1155
+ parser = CLI.remove_parser({})
1156
+ parser.order!(args)
1157
+
1158
+ if args.empty?
1159
+ $stderr.puts parser
1160
+ exit 1
1161
+ end
1162
+
1163
+ dock = Dock.new
1164
+ messages = []
1165
+
1166
+ # Process each argument
1167
+ args.each do |item_query|
1168
+ found = false
1169
+
1170
+ # Determine if this looks like a path
1171
+ is_path = item_query.include?('/')
1172
+
1173
+ # First check persistent-apps
1174
+ index, name = dock.find_app(item_query)
1175
+
1176
+ if index
1177
+ app_dicts = dock.get_app_dicts
1178
+ dock.apps_array.delete(app_dicts[index])
1179
+ messages << "'#{name}' removed from Dock."
1180
+ found = true
1181
+ end
1182
+
1183
+ # If not found in apps, check persistent-others (folders)
1184
+ unless found
1185
+ index, name = dock.find_folder(item_query, check_url: is_path)
1186
+
1187
+ if index
1188
+ folder_dicts = dock.get_folder_dicts
1189
+ dock.others_array.delete(folder_dicts[index])
1190
+ messages << "'#{name}' removed from Dock."
1191
+ found = true
1192
+ end
1193
+ end
1194
+
1195
+ unless found
1196
+ $stderr.puts "Warning: '#{item_query}' not found in Dock, skipping"
1197
+ end
1198
+ end
1199
+
1200
+ if messages.empty?
1201
+ $stderr.puts "No items were removed from the Dock"
1202
+ exit 1
1203
+ end
1204
+
1205
+ dock.save(messages)
1206
+ end
1207
+
1208
+ # Implementation of the `move` subcommand.
1209
+ #
1210
+ # Moves an existing Dock item after another item in the same section.
1211
+ #
1212
+ # @param args [Array<String>] Command-line arguments for the subcommand.
1213
+ # @return [void]
1214
+ def self.move(args)
1215
+ # Accept either order: move --after TARGET ITEM or move ITEM --after TARGET
1216
+ options = { after: nil }
1217
+ parser = CLI.move_parser(options)
1218
+ parser.permute!(args)
1219
+
1220
+ # Now, args should contain the non-option arguments (either [item] or [target, item] or [item, target])
1221
+ after_target = options[:after]
1222
+
1223
+ # Accept either order: --after TARGET ITEM or ITEM --after TARGET
1224
+ item_query = nil
1225
+ if after_target && args.length == 1
1226
+ item_query = args.first
1227
+ elsif after_target && args.length == 2
1228
+ # Try to infer which is the item and which is the target
1229
+ if args[0].downcase == after_target.downcase
1230
+ item_query = args[1]
1231
+ elsif args[1].downcase == after_target.downcase
1232
+ item_query = args[0]
1233
+ else
1234
+ # Default: treat first as item
1235
+ item_query = args[0]
1236
+ end
1237
+ else
1238
+ $stderr.puts parser
1239
+ $stderr.puts "\nError: You must specify an item to move and a target with --after."
1240
+ exit 1
1241
+ end
1242
+
1243
+ if !after_target || !item_query
1244
+ $stderr.puts parser
1245
+ $stderr.puts "\nError: You must specify an item to move and a target with --after."
1246
+ exit 1
1247
+ end
1248
+
1249
+ dock = Dock.new
1250
+
1251
+ # Find the item to move (check apps first, then folders)
1252
+ move_array, move_element, move_name = dock.find_item(item_query, check_url: true)
1253
+
1254
+ unless move_element
1255
+ $stderr.puts "Error: '#{item_query}' not found in Dock"
1256
+ exit 1
1257
+ end
1258
+
1259
+ # Find the target (check apps first, then folders)
1260
+ after_array, after_element, after_name = dock.find_item(after_target, check_url: true)
1261
+
1262
+ unless after_element
1263
+ $stderr.puts "Error: '#{after_target}' not found in Dock"
1264
+ exit 1
1265
+ end
1266
+
1267
+ # Check if they're the same item
1268
+ if move_element == after_element
1269
+ $stderr.puts "Error: Cannot move an item after itself"
1270
+ exit 1
1271
+ end
1272
+
1273
+ # Check if moving between arrays (apps <-> folders) - not allowed
1274
+ if move_array != after_array
1275
+ $stderr.puts "Error: Cannot move items between apps and folders sections"
1276
+ exit 1
1277
+ end
1278
+
1279
+ # Remove from current position
1280
+ move_array.delete(move_element)
1281
+
1282
+ # Insert after target
1283
+ move_array.insert_after(after_element, move_element)
1284
+
1285
+ dock.save("'#{move_name}' moved after '#{after_name}'.")
1286
+ end
1287
+ end
1288
+ end
1289
+
1290
+
1291
+ # frozen_string_literal: true
1292
+
1293
+ module DockEdit
1294
+ # Command-line interface and option parsing for the `dockedit` executable.
1295
+ #
1296
+ # This class is responsible for parsing global arguments and subcommands and
1297
+ # delegating to the appropriate handlers in {DockEdit::Commands}.
1298
+ class CLI
1299
+ # Build the option parser for the `add` subcommand.
1300
+ #
1301
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
1302
+ # @return [OptionParser] Configured parser instance.
1303
+ def self.add_parser(options)
1304
+ OptionParser.new do |opts|
1305
+ opts.banner = "Usage: dockedit add [options] <app_or_folder> [...]"
1306
+ opts.separator ""
1307
+ opts.separator "Examples:"
1308
+ opts.separator " dockedit add Safari Terminal"
1309
+ opts.separator " dockedit add ~/Downloads --show grid --display stack"
1310
+ opts.separator " dockedit add --after Safari Notes"
1311
+ opts.separator " dockedit add ~/Sites --display folder --show grid"
1312
+ opts.on('-a', '--after ITEM', 'Insert after specified app/folder (fuzzy match)') { |app| options[:after] = app }
1313
+ opts.on('--show TYPE', '--view TYPE', 'Folder view: fan/f, grid/g, list/l, auto/a (default: auto)') { |t| options[:show_as] = Parsers.parse_show_as(t) }
1314
+ opts.on('--display TYPE', 'Folder style: folder/f, stack/s (default: folder)') { |t| options[:display_as] = Parsers.parse_display_as(t) }
1315
+ end
1316
+ end
1317
+
1318
+ # Build the option parser for the `space` subcommand.
1319
+ #
1320
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
1321
+ # @return [OptionParser] Configured parser instance.
1322
+ def self.space_parser(options)
1323
+ OptionParser.new do |opts|
1324
+ opts.banner = "Usage: dockedit space [options]"
1325
+ opts.separator ""
1326
+ opts.separator "Examples:"
1327
+ opts.separator " dockedit space"
1328
+ opts.separator " dockedit space --small"
1329
+ opts.separator " dockedit space --after Safari"
1330
+ opts.separator " dockedit space --small --after Terminal --after Safari"
1331
+ opts.on('-s', '--small', '--half', 'Insert a small/half-size space') { options[:small] = true }
1332
+ opts.on('-a', '--after APP', 'Insert after specified app (fuzzy match, repeatable)') { |app| options[:after] << app }
1333
+ end
1334
+ end
1335
+
1336
+ # Build the option parser for the `move` subcommand.
1337
+ #
1338
+ # @param options [Hash] Mutable options hash to be populated by OptionParser.
1339
+ # @return [OptionParser] Configured parser instance.
1340
+ def self.move_parser(options)
1341
+ OptionParser.new do |opts|
1342
+ opts.banner = "Usage: dockedit move --after <target> <item_to_move> OR dockedit move <item_to_move> --after <target>"
1343
+ opts.separator ""
1344
+ opts.separator "Examples:"
1345
+ opts.separator " dockedit move --after Terminal Safari"
1346
+ opts.separator " dockedit move Safari --after Terminal"
1347
+ opts.on('-a', '--after ITEM', 'Move after specified app/folder (required, fuzzy match)') { |app| options[:after] = app }
1348
+ end
1349
+ end
1350
+
1351
+ # Build the option parser for the `remove` subcommand.
1352
+ #
1353
+ # @param _options [Hash] Present for API symmetry; currently unused.
1354
+ # @return [OptionParser] Configured parser instance.
1355
+ def self.remove_parser(_options = {})
1356
+ OptionParser.new do |opts|
1357
+ opts.banner = "Usage: dockedit remove <app_or_folder> [...]"
1358
+ opts.separator ""
1359
+ opts.separator "Examples:"
1360
+ opts.separator " dockedit remove Safari Terminal"
1361
+ opts.separator " dockedit remove ~/Downloads"
1362
+ opts.separator " dockedit remove --help"
1363
+ end
1364
+ end
1365
+
1366
+ # Build the top-level option parser for the `dockedit` command.
1367
+ #
1368
+ # This parser prints a summary of all subcommands and global options.
1369
+ #
1370
+ # @return [OptionParser]
1371
+ def self.main_parser
1372
+ OptionParser.new do |opts|
1373
+ opts.banner = "Usage: dockedit <subcommand> [options] [args]"
1374
+ opts.separator ""
1375
+ opts.separator "Subcommands:"
1376
+ opts.separator " add [-a|--after <item>] [--show-as TYPE] <item>... Add app(s)/folder(s)"
1377
+ opts.separator " move -a|--after <item> <item> Move an item after another"
1378
+ opts.separator " remove <item>... Remove app(s)/folder(s)"
1379
+ opts.separator " space [-s|--small] [-a|--after <app>] Insert space(s)"
1380
+ opts.separator " help [subcommand] Show help for a subcommand"
1381
+ opts.separator ""
1382
+ opts.separator "Folder shortcuts: desktop, downloads, home, library, documents, applications, sites"
1383
+ opts.separator ""
1384
+ opts.separator "Examples:"
1385
+ opts.separator " dockedit add Safari Terminal"
1386
+ opts.separator " dockedit add ~/Downloads --show grid --display stack"
1387
+ opts.separator " dockedit add Notes --after Safari"
1388
+ opts.separator " dockedit space --small --after Safari"
1389
+ opts.separator " dockedit move --after Terminal Safari"
1390
+ opts.separator " dockedit move Safari --after Terminal"
1391
+ opts.separator " dockedit help add"
1392
+ opts.separator ""
1393
+ opts.separator "Options:"
1394
+ opts.on('-h', '--help', 'Show this help') do
1395
+ puts opts
1396
+ exit 0
1397
+ end
1398
+ end
1399
+ end
1400
+
1401
+ # Main entry point for the `dockedit` CLI.
1402
+ #
1403
+ # Reads +ARGV+, dispatches to subcommands, and exits with an appropriate
1404
+ # status code.
1405
+ #
1406
+ # @return [void]
1407
+ def self.run
1408
+ global_parser = main_parser
1409
+
1410
+ if ARGV.empty?
1411
+ puts global_parser
1412
+ exit 0
1413
+ end
1414
+
1415
+ subcommand = ARGV.shift
1416
+
1417
+ case subcommand
1418
+ when '-v', '--version'
1419
+ puts DockEdit::VERSION
1420
+ exit 0
1421
+ when 'add'
1422
+ Commands.add(ARGV)
1423
+ when 'move'
1424
+ Commands.move(ARGV)
1425
+ when 'remove'
1426
+ Commands.remove(ARGV)
1427
+ when 'space'
1428
+ Commands.space(ARGV)
1429
+ when 'help', '-h', '--help'
1430
+ help_target = ARGV.shift
1431
+ case help_target
1432
+ when nil
1433
+ puts main_parser
1434
+ when 'add'
1435
+ puts add_parser({})
1436
+ when 'move'
1437
+ puts move_parser({})
1438
+ when 'remove'
1439
+ puts remove_parser({})
1440
+ when 'space'
1441
+ puts space_parser({})
1442
+ else
1443
+ $stderr.puts "Unknown subcommand for help: #{help_target}"
1444
+ $stderr.puts "Valid subcommands: add, move, remove, space"
1445
+ exit 1
1446
+ end
1447
+ exit 0
1448
+ else
1449
+ $stderr.puts "Unknown subcommand: #{subcommand}"
1450
+ $stderr.puts "Run 'dockedit --help' for usage"
1451
+ exit 1
1452
+ end
1453
+ end
1454
+ end
1455
+ end
1456
+
1457
+
1458
+
1459
+ if __FILE__ == $0
1460
+ DockEdit::CLI.run
1461
+ end