booker 1.3.0 → 2.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/lib/booker.rb CHANGED
@@ -1,483 +1,14 @@
1
- # parse booker's command line args
2
- require "yaml"
3
- require "find"
4
- require "json"
5
- require "optparse"
6
- require "shellwords"
7
- require "fileutils"
8
- require_relative "bookmarks"
9
- require_relative "config"
10
- require_relative "consts"
11
-
12
- # get booker opening command
13
- class Booker
14
- @version = "1.3.0"
15
- @@version = @version
16
-
17
- class << self
18
- attr_reader :version
19
- end
20
-
21
- include Browser
22
-
23
- def initialize(args)
24
- parse args
25
- end
26
-
27
- def parse(args)
28
- show_bookmarks if args.none?
29
-
30
- if args.first&.start_with?("-")
31
- dispatch_option(args)
32
- exit 0
33
- end
34
-
35
- bookmark_ids, other_args = args.partition { |a| /^[0-9_]+$/.match?(a) }
36
- open_bookmark(bookmark_ids) unless bookmark_ids.empty?
37
-
38
- unless other_args.empty?
39
- if other_args.length == 1 && domain.match(other_args.first)
40
- puts "opening website: ".grn + other_args.first
41
- openweb(prep(other_args.first))
42
- else
43
- open_search(other_args.join(" ").strip)
44
- end
45
- end
46
- end
47
-
48
- def option_parser
49
- @option_parser ||= OptionParser.new do |opts|
50
- opts.banner = "Usage: booker [options] [arguments]"
51
- opts.separator ""
52
- opts.separator "Main options:"
53
- opts.on("-b", "--bookmark", "explicitly open bookmark") { @mode = :bookmark }
54
- opts.on("-i", "--install", "install: all|bookmarks|completion|config|safari") { @mode = :install }
55
- opts.on("-s", "--search", "explicitly search arguments") { @mode = :search }
56
- opts.separator ""
57
- opts.separator "Other options:"
58
- opts.on("-c", "--complete", "show tab completions") { @mode = :complete }
59
- opts.on("-v", "--version", "print version") {
60
- puts @@version
61
- exit 0
62
- }
63
- opts.on_tail("-h", "--help", "show help") {
64
- puts opts
65
- exit 0
66
- }
67
- end
68
- end
69
-
70
- def dispatch_option(args)
71
- option_parser.parse!(args)
72
-
73
- case @mode
74
- when :bookmark
75
- pexit "Error: ".red + "booker --bookmark expects bookmark id", 1 if args.empty?
76
- open_bookmark(args)
77
- when :install
78
- args.empty? ? install(%w[completion config bookmarks]) : install(args)
79
- when :search
80
- pexit "Error: ".red + "--search requires an argument", 1 if args.empty?
81
- open_search(args.join(" "))
82
- when :complete
83
- Bookmarks.new(args.join(" ")).autocomplete
84
- end
85
- rescue OptionParser::InvalidOption => e
86
- pexit "Error: ".red + e.message, 1
87
- end
88
-
89
- def pexit(msg, sig)
90
- puts msg
91
- exit sig
92
- end
93
-
94
- def openweb(url)
95
- # Pass URL directly to browser without invoking shell
96
- # This avoids issues with special characters like parentheses
97
- browser_cmd = browse.strip
98
-
99
- # Redirect stdout/stderr to suppress GTK warnings
100
- success = system(browser_cmd, url, out: "/dev/null", err: "/dev/null")
101
-
102
- unless success
103
- puts "Warning: ".yel + "Failed to open URL (exit code: #{$?.exitstatus})"
104
- end
105
- end
106
-
107
- # an array of ints, as bookmark ids
108
- def open_bookmark(bm)
109
- id = bm.shift
110
- url = Bookmarks.new.bookmark_url(id)
111
- pexit "Failure:".red + " bookmark #{id} not found", 1 if url.nil?
112
- puts "opening bookmark ".grn + url
113
- openweb(url) # No wrap() needed - system() handles it
114
- open_bookmark bm unless bm.empty?
115
- end
116
-
117
- def open_search(term)
118
- puts "searching ".grn + term
119
- search = BConfig.new.searcher
120
- term = term.tr(" ", "+")
121
- openweb(search + term) # No shell escape needed - it's a URL
122
- end
123
-
124
- def show_bookmarks
125
- puts "Bookmarks:".grn + " (usage: booker <id> or booker <search>)"
126
- puts ""
127
-
128
- bm = Bookmarks.new("") # Get all bookmarks
129
- allurls = bm.instance_variable_get(:@allurls)
130
-
131
- if allurls.empty?
132
- puts "No bookmarks found.".red
133
- puts "Run: ".yel + "booker --install bookmarks".cyan
134
- exit 0
135
- end
136
-
137
- # Calculate responsive column widths
138
- term_width = `tput cols`.to_i
139
- term_width = 100 if term_width == 0 # fallback
140
-
141
- id_width = 10
142
- remaining = term_width - id_width - 3 # 3 spaces between columns
143
-
144
- folder_width = [remaining * 0.20, 15].max.to_i
145
- title_width = [remaining * 0.30, 20].max.to_i
146
- url_width = [remaining * 0.50, 30].max.to_i
147
-
148
- # Display bookmarks in a readable format
149
- allurls.each do |bookmark|
150
- id_str = bookmark.id.to_s.window(id_width).grn
151
-
152
- # Clean up folder display
153
- folder = bookmark.folder.gsub(/^\|/, "") # Remove leading |
154
- folder = (folder == "/") ? "[root]" : folder.chomp("/") # Show [root] for top-level
155
- folder_str = folder.window(folder_width).blu
156
-
157
- title_str = bookmark.title.window(title_width).yel
158
- url_str = bookmark.url.window(url_width)
159
-
160
- puts "#{id_str} #{folder_str} #{title_str} #{url_str}"
161
- end
162
-
163
- puts ""
164
- puts "Found #{allurls.length} bookmarks".grn
165
- puts ""
166
- puts "Examples:".yel
167
- puts " #{"booker #{allurls.first.id}".ljust(20).cyan} # Open first bookmark"
168
- puts " #{"booker github".ljust(20).cyan} # Search bookmarks for 'github'"
169
- puts " #{"booker --help".ljust(20).cyan} # Show help"
170
-
171
- exit 0
172
- end
173
-
174
- def install(args)
175
- target = args.shift
176
- exit 0 if target.nil?
177
-
178
- # 'all' expands to the full install list (including opt-in safari)
179
- if /^all$/i.match?(target)
180
- args = %w[completion config bookmarks safari] + args
181
- target = args.shift
182
- end
183
-
184
- if /comp/i.match?(target) # completion installation
185
- install_completion
186
- elsif /book/i.match?(target) # bookmarks installation
187
- install_bookmarks
188
- elsif /conf/i.match?(target) # default config file generation
189
- install_config
190
- elsif /safari/i.match?(target) # opt-in Safari FDA setup (macOS only)
191
- install_safari
192
- else # unknown argument passed into install
193
- pexit "Failure: ".red + "unknown installation option (#{target})", 1
194
- end
195
-
196
- install(args) # recurse til done
197
- end
198
-
199
- def install_completion
200
- # check if zsh is even installed for this user
201
- begin
202
- fpath = `zsh -c 'echo $fpath'`.split(" ")
203
- rescue
204
- pexit "Failure: ".red + "zsh is probably not installed, could not find $fpath", 1
205
- end
206
-
207
- # Try user-writable directories first, then system directories
208
- user_home = ENV["HOME"]
209
- writable_dirs = fpath.select do |fp|
210
- fp.start_with?(user_home) && File.directory?(fp) && File.writable?(fp)
211
- end
212
-
213
- # If no user-writable directories, try to create one
214
- if writable_dirs.empty?
215
- user_completion_dir = File.join(user_home, ".zsh", "completion")
216
- begin
217
- FileUtils.mkdir_p(user_completion_dir)
218
- writable_dirs << user_completion_dir
219
- puts "Created user completion directory: #{user_completion_dir}".yel
220
-
221
- # Auto-configure .zshrc if it exists
222
- zshrc = File.join(user_home, ".zshrc")
223
- if File.exist?(zshrc)
224
- zshrc_content = File.read(zshrc)
225
- if zshrc_content.include?(".zsh/completion")
226
- puts "~/.zshrc already configured".grn
227
- else
228
- File.open(zshrc, "a") do |f|
229
- f.puts "\n# Booker completion"
230
- f.puts "fpath=(~/.zsh/completion $fpath)"
231
- f.puts "autoload -Uz compinit && compinit"
232
- end
233
- puts "Added completion to ~/.zshrc".grn
234
- puts "Run: ".yel + "source ~/.zshrc".cyan + " to activate"
235
- end
236
- else
237
- puts "Add this to your ~/.zshrc: ".yel + "fpath=(~/.zsh/completion $fpath)".cyan
238
- end
239
- rescue => e
240
- # Couldn't create user dir, try system dirs as fallback
241
- end
242
- end
243
-
244
- # Try writable directories first, then all directories as fallback
245
- dirs_to_try = writable_dirs + fpath.reject { |fp| writable_dirs.include?(fp) }
246
-
247
- success = false
248
- dirs_to_try.each do |fp|
249
- next unless File.directory?(fp)
250
-
251
- begin
252
- completion_file = File.join(fp, "_booker")
253
- File.write(completion_file, COMPLETION)
254
- system "zsh -c 'autoload -U _booker'"
255
- puts "Success: ".grn + "installed zsh autocompletion in #{fp}"
256
- success = true
257
- break
258
- rescue => e
259
- # Try next directory silently
260
- end
261
- end
262
-
263
- unless success
264
- puts "Warning: ".yel + "Could not install ZSH completion to any directory in $fpath"
265
- puts "Try manually: ".grn + "mkdir -p ~/.zsh/completion && booker --install completion"
266
- end
267
- end
268
-
269
- def install_bookmarks
270
- # locate bookmarks file, show user, write to config?
271
- puts "searching for browser bookmarks..."
272
- begin
273
- bms = [] # look for bookmarks with type info
274
-
275
- # Search for Chrome bookmarks
276
- ["Library/Application Support/Google/Chrome",
277
- "AppData/Local/Google/Chrome/User Data/Default",
278
- ".config/chromium/Default",
279
- ".config/google-chrome/Default",
280
- "snap/chromium/common/chromium",
281
- "snap/chromium/current/.config/chromium"].each do |f|
282
- home = File.join(ENV["HOME"], f)
283
- next if !FileTest.directory?(home)
284
- Find.find(home) do |file|
285
- if /chrom.*bookmarks/i.match?(file)
286
- bms << {path: file, type: :chrome}
287
- end
288
- end
289
- end
290
-
291
- # Search for Firefox bookmarks
292
- firefox_paths = [
293
- ".mozilla/firefox", # Linux
294
- "snap/firefox/common/.mozilla/firefox", # Linux (snap)
295
- "Library/Application Support/Firefox", # macOS
296
- "AppData/Roaming/Mozilla/Firefox" # Windows
297
- ]
298
-
299
- firefox_paths.each do |f|
300
- firefox_base = File.join(ENV["HOME"], f)
301
- next if !FileTest.directory?(firefox_base)
302
-
303
- profiles_ini = File.join(firefox_base, "profiles.ini")
304
- next if !File.exist?(profiles_ini)
305
-
306
- # Parse profiles.ini to find Firefox profiles
307
- parse_firefox_profiles(profiles_ini, firefox_base).each do |db_path|
308
- bms << {path: db_path, type: :firefox}
309
- end
310
- end
311
-
312
- # Search for Safari bookmarks (macOS only)
313
- safari_path = File.join(ENV["HOME"], "Library/Safari/Bookmarks.plist")
314
- bms << {path: safari_path, type: :safari} if File.exist?(safari_path)
315
-
316
- if bms.empty? # no bookmarks found
317
- puts "Failure: ".red + "bookmarks file could not be found."
318
- raise
319
- elsif bms.length == 1
320
- # Auto-select if only one source found
321
- selected = bms.first[:path]
322
- puts "Found bookmark source: #{bookmark_type_label(bms.first[:type])} #{selected}".yel
323
- puts "Selected: ".yel + selected
324
- BConfig.new.write(:bookmarks, selected)
325
- puts "Success: ".grn + "config file updated with your bookmarks"
326
- else # have user select a file
327
- puts "select bookmarks source: "
328
-
329
- # Offer "ALL" as first option if multiple sources found
330
- puts "0".grn + " - " + "[ALL SOURCES]".cyan + " (search across all browsers)"
331
- offset = 1
332
-
333
- bms.each_with_index do |bm, i|
334
- puts (i + offset).to_s.grn + " - " + bookmark_type_label(bm[:type], color: true) + " " + bm[:path]
335
- end
336
-
337
- input = gets
338
- raise "No input provided" if input.nil?
339
- selection = input.chomp.to_i
340
-
341
- if selection == 0
342
- # User selected "ALL" - save array of all paths
343
- all_paths = bms.map { |bm| bm[:path] }
344
- puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
345
- BConfig.new.write(:bookmarks, all_paths)
346
- puts "Success: ".grn + "config file updated to search all bookmark sources"
347
- else
348
- # User selected single source
349
- actual_index = selection - 1
350
- selected = bms[actual_index][:path]
351
- puts "Selected: ".yel + selected
352
- BConfig.new.write(:bookmarks, selected)
353
- puts "Success: ".grn + "config file updated with your bookmarks"
354
- end
355
- end
356
- rescue => e
357
- puts e.message
358
- pexit "Failure: ".red + "could not add bookmarks to config file ~/.booker", 1
359
- end
360
- end
361
-
362
- def bookmark_type_label(type, color: false)
363
- label = {chrome: "[Chrome]", firefox: "[Firefox]", safari: "[Safari]"}[type] || "[?]"
364
- return label unless color
365
- case type
366
- when :chrome then label.yel
367
- when :firefox then label.blu
368
- when :safari then label.cyan
369
- else label
370
- end
371
- end
372
-
373
- def parse_firefox_profiles(ini_path, firefox_base)
374
- profiles = []
375
- current_profile = {}
376
-
377
- File.readlines(ini_path).each do |line|
378
- line = line.strip
379
-
380
- # New profile section
381
- if line.start_with?("[Profile")
382
- # Save previous profile if it had a path
383
- if current_profile[:path]
384
- db_path = File.join(firefox_base, current_profile[:path], "places.sqlite")
385
- profiles << db_path if File.exist?(db_path)
386
- end
387
- current_profile = {}
388
-
389
- # Parse key=value pairs
390
- elsif line.include?("=")
391
- key, value = line.split("=", 2).map(&:strip)
392
- current_profile[:path] = value if key == "Path"
393
- current_profile[:name] = value if key == "Name"
394
- end
395
- end
396
-
397
- # Don't forget the last profile
398
- if current_profile[:path]
399
- db_path = File.join(firefox_base, current_profile[:path], "places.sqlite")
400
- profiles << db_path if File.exist?(db_path)
401
- end
402
-
403
- profiles
404
- end
405
-
406
- def install_config
407
- BConfig.new.write
408
- puts "Success: ".grn + "example config file written to ~/.booker"
409
- rescue
410
- pexit "Failure: ".red + "could not write example config file to ~/.booker", 1
411
- end
412
-
413
- # Opt-in Safari setup: walks through granting Full Disk Access so booker
414
- # can read ~/Library/Safari/Bookmarks.plist. Not included in the default
415
- # --install flow because it requires a TCC permission grant.
416
- def install_safari
417
- plist = File.join(ENV["HOME"], "Library/Safari/Bookmarks.plist")
418
- fda_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
419
-
420
- unless RUBY_PLATFORM.include?("darwin")
421
- puts "Skip: ".yel + "Safari support is macOS-only."
422
- return
423
- end
424
-
425
- unless File.exist?(plist)
426
- puts "Skip: ".yel + "Safari bookmarks not found at #{plist}"
427
- puts "Hint: ".grn + "launch Safari at least once, then re-run."
428
- return
429
- end
430
-
431
- if safari_readable?(plist)
432
- puts "PASS: ".grn + "Safari bookmarks are already readable."
433
- return
434
- end
435
-
436
- puts "Safari stores bookmarks at:"
437
- puts " #{plist}".cyan
438
- puts
439
- puts "That file is protected by macOS TCC. Grant Full Disk Access to one of:"
440
- puts
441
- puts " [A] ".cyan + "Your terminal app".yel + " (simplest; inherited by any tool)"
442
- puts " [B] ".cyan + "/usr/bin/plutil only".yel + " (narrower scope)"
443
- puts
444
- print "Pick [A] or [B] (default A): "
445
- $stdout.flush
446
- choice = $stdin.gets.to_s.strip.upcase
447
- choice = "A" if choice.empty?
448
- pexit "Error: ".red + "invalid choice.", 1 unless %w[A B].include?(choice)
449
-
450
- puts
451
- puts "Opening the Full Disk Access pane..."
452
- system("open", fda_url)
453
- puts
454
-
455
- puts "In the pane that just opened:"
456
- if choice == "A"
457
- puts " 1. Click ".yel + "+".cyan + ", add your terminal app from /Applications"
458
- puts " 2. Toggle it ".yel + "on".cyan
459
- puts " 3. ".yel + "Fully quit".cyan + " the terminal (Cmd+Q), reopen it,"
460
- puts " and re-run ".yel + "booker --install safari".cyan + " to verify."
461
- else
462
- puts " 1. Click ".yel + "+".cyan
463
- puts " 2. Press ".yel + "Cmd+Shift+G".cyan + " (opens 'Go to Folder')".yel
464
- puts " 3. Type ".yel + "/usr/bin/plutil".cyan + " and press Return".yel
465
- puts " 4. Click ".yel + "Open".cyan + ", then toggle it ".yel + "on".cyan
466
- puts
467
- print "Press Return once you've added plutil and toggled it on... "
468
- $stdout.flush
469
- $stdin.gets
470
-
471
- if safari_readable?(plist)
472
- puts "PASS: ".grn + "Safari bookmarks are now readable."
473
- else
474
- puts "FAIL: ".red + "still can't read the bookmarks file."
475
- puts "Try fully quitting the terminal (Cmd+Q) and re-running."
476
- end
477
- end
478
- end
479
-
480
- def safari_readable?(plist)
481
- system("plutil", "-lint", "-s", plist, out: File::NULL, err: File::NULL)
482
- end
483
- end
1
+ # frozen_string_literal: true
2
+
3
+ # booker - search, browse and open browser bookmarks from the command line
4
+ #
5
+ # Load order matters in one place: Bookmarks::BROWSERS names the parser classes
6
+ # while its class body runs, so parsers has to come first.
7
+
8
+ require_relative "booker/version"
9
+ require_relative "booker/output"
10
+ require_relative "booker/config"
11
+ require_relative "booker/parsers"
12
+ require_relative "booker/bookmarks"
13
+ require_relative "booker/picker"
14
+ require_relative "booker/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: booker
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Warner
@@ -15,76 +15,55 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.7'
18
+ version: '2.21'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '2.7'
25
+ version: '2.21'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: sqlite3
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.7'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '1.7'
40
- - !ruby/object:Gem::Dependency
41
- name: rexml
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
32
+ version: '2.9'
33
+ - - ">="
45
34
  - !ruby/object:Gem::Version
46
- version: '3.2'
35
+ version: 2.9.5
47
36
  type: :runtime
48
37
  prerelease: false
49
38
  version_requirements: !ruby/object:Gem::Requirement
50
39
  requirements:
51
40
  - - "~>"
52
41
  - !ruby/object:Gem::Version
53
- version: '3.2'
54
- - !ruby/object:Gem::Dependency
55
- name: rspec
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
42
+ version: '2.9'
43
+ - - ">="
59
44
  - !ruby/object:Gem::Version
60
- version: '3.13'
61
- type: :development
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '3.13'
45
+ version: 2.9.5
68
46
  - !ruby/object:Gem::Dependency
69
- name: standard
47
+ name: rexml
70
48
  requirement: !ruby/object:Gem::Requirement
71
49
  requirements:
72
50
  - - "~>"
73
51
  - !ruby/object:Gem::Version
74
- version: '1.0'
75
- type: :development
52
+ version: '3.4'
53
+ type: :runtime
76
54
  prerelease: false
77
55
  version_requirements: !ruby/object:Gem::Requirement
78
56
  requirements:
79
57
  - - "~>"
80
58
  - !ruby/object:Gem::Version
81
- version: '1.0'
59
+ version: '3.4'
82
60
  description: |2
83
61
  Search, browse, and open bookmarks from the command line. Supports Chrome,
84
- Chromium, Firefox, and Safari. Browse all bookmarks interactively, or
85
- search by keyword. ZSH users get tab completion through bookmark matches.
86
- Can also open websites directly or search with your preferred search
87
- engine.
62
+ Chromium, Firefox, and Safari. With fzf installed, booker and booker
63
+ <search> open a fuzzy picker over the matches - nothing to install and the
64
+ same in every shell. Zsh, bash, and fish users can also add tab completion
65
+ through bookmark matches. Can open websites directly or search with your
66
+ preferred search engine.
88
67
  email: jeremywrnr@gmail.com
89
68
  executables:
90
69
  - booker
@@ -92,15 +71,26 @@ extensions: []
92
71
  extra_rdoc_files: []
93
72
  files:
94
73
  - bin/booker
74
+ - completions/_booker
75
+ - completions/booker.bash
76
+ - completions/booker.fish
95
77
  - lib/booker.rb
96
- - lib/bookmarks.rb
97
- - lib/config.rb
98
- - lib/consts.rb
99
- homepage: http://github.com/jeremywrnr/booker
78
+ - lib/booker/bookmarks.rb
79
+ - lib/booker/cli.rb
80
+ - lib/booker/config.rb
81
+ - lib/booker/installer.rb
82
+ - lib/booker/output.rb
83
+ - lib/booker/parsers.rb
84
+ - lib/booker/picker.rb
85
+ - lib/booker/version.rb
86
+ homepage: https://github.com/jeremywrnr/booker
100
87
  licenses:
101
88
  - MIT
102
- metadata: {}
103
- post_install_message: 'To add zsh completion run: booker --install'
89
+ metadata:
90
+ source_code_uri: https://github.com/jeremywrnr/booker
91
+ bug_tracker_uri: https://github.com/jeremywrnr/booker/issues
92
+ changelog_uri: https://github.com/jeremywrnr/booker/blob/main/CHANGELOG.md
93
+ rubygems_mfa_required: 'true'
104
94
  rdoc_options: []
105
95
  require_paths:
106
96
  - lib
@@ -108,14 +98,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
108
98
  requirements:
109
99
  - - ">="
110
100
  - !ruby/object:Gem::Version
111
- version: '0'
101
+ version: '3.2'
112
102
  required_rubygems_version: !ruby/object:Gem::Requirement
113
103
  requirements:
114
104
  - - ">="
115
105
  - !ruby/object:Gem::Version
116
106
  version: '0'
117
107
  requirements: []
118
- rubygems_version: 4.0.8
108
+ rubygems_version: 4.0.18
119
109
  specification_version: 4
120
110
  summary: CLI bookmark manager for Chrome, Firefox, and Safari
121
111
  test_files: []