hiiro 0.1.80 → 0.1.81

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: 505147230ad7ebd846dae0bc672cef7e35252e30f134e88ed7595272866b69f4
4
- data.tar.gz: 339f416cdac0a03bd469c71266619868b508f1e767d90400f948fa5143604110
3
+ metadata.gz: 3f87285ddecfdb583f432a5017f6261299b44090af65809fd4a8840f7c497a0a
4
+ data.tar.gz: 41376a591fe399529e5eb4d2dd33cd01c55eb9f1a9f9f5cc1a56691608e1a631
5
5
  SHA512:
6
- metadata.gz: 52e86d19f59a78f18eead2ba6ebc8906a3090af015d22044141127c43888a698eac524908f351d49980c5398a4e0d68ef8e3167f0bfe60db01cb47a20e2b1c6d
7
- data.tar.gz: 6980d426edd24f936c5e74bf8f081b7d417826e1a16385555dee097c6f6f0d1053595ceaf77d3080d98474e569d20a4fa85111b6b23384c7b10aefb0f238225b
6
+ metadata.gz: f055d30b6555898fee3011e99ed35085c0989bfb786478815c7e42ece61eba17d7e4c5462b788ca220fa1b06432ebfe17f09cf5d4a84143b8f55b0e0d31da782
7
+ data.tar.gz: 40f214d34537a625a9ecea49d9ba57d9c96cf2e6a85c586b9854eabc18960d2b14009743458008c4127b822dc5c4e630db293fc55b41b0384586702ff0398029
data/bin/h-buffer CHANGED
@@ -98,4 +98,25 @@ o.add_subcmd(:clear) { |*args|
98
98
  puts "Cleared #{buffers.count} buffers"
99
99
  }
100
100
 
101
+ o.add_subcmd(:select) do |*args|
102
+ # Get buffer list with content preview
103
+ output = `tmux list-buffers -F '\#{buffer_name}: \#{buffer_sample}'`.strip
104
+
105
+ if output.empty?
106
+ STDERR.puts "No buffers found"
107
+ next
108
+ end
109
+
110
+ lines = output.split("\n").each_with_object({}) do |line, h|
111
+ buffer_name = line.split(':').first
112
+ h[line] = buffer_name
113
+ end
114
+
115
+ selected = Hiiro::Sk.map_select(lines)
116
+
117
+ if selected
118
+ print selected
119
+ end
120
+ end
121
+
101
122
  o.run
data/bin/h-pane CHANGED
@@ -71,4 +71,25 @@ o.add_subcmd(:history) { |pane_id=nil|
71
71
  end
72
72
  }
73
73
 
74
+ o.add_subcmd(:select) do |*args|
75
+ # Get all panes with details
76
+ output = `tmux list-panes -a -F '\#{session_name}:\#{window_index}.\#{pane_index} [\#{pane_current_command}] \#{pane_current_path}'`.strip
77
+
78
+ if output.empty?
79
+ STDERR.puts "No panes found"
80
+ next
81
+ end
82
+
83
+ lines = output.split("\n").each_with_object({}) do |line, h|
84
+ pane_id = line.split.first
85
+ h[line] = pane_id
86
+ end
87
+
88
+ selected = Hiiro::Sk.map_select(lines)
89
+
90
+ if selected
91
+ print selected
92
+ end
93
+ end
94
+
74
95
  o.run
data/bin/h-pr CHANGED
@@ -244,6 +244,130 @@ class PRManager
244
244
  end
245
245
  end
246
246
 
247
+ class PinnedPRManager
248
+ PINNED_FILE = File.join(Dir.home, '.config/hiiro/pinned_prs.yml')
249
+
250
+ def initialize
251
+ ensure_file
252
+ end
253
+
254
+ def ensure_file
255
+ dir = File.dirname(PINNED_FILE)
256
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
257
+ File.write(PINNED_FILE, [].to_yaml) unless File.exist?(PINNED_FILE)
258
+ end
259
+
260
+ def load_pinned
261
+ ensure_file
262
+ YAML.load_file(PINNED_FILE) || []
263
+ end
264
+
265
+ def save_pinned(prs)
266
+ ensure_file
267
+ File.write(PINNED_FILE, prs.to_yaml)
268
+ end
269
+
270
+ def pin(pr_info)
271
+ pinned = load_pinned
272
+ existing = pinned.find { |p| p['number'] == pr_info['number'] }
273
+
274
+ if existing
275
+ existing.merge!(pr_info)
276
+ existing['updated_at'] = Time.now.iso8601
277
+ else
278
+ pr_info['pinned_at'] = Time.now.iso8601
279
+ pinned << pr_info
280
+ end
281
+
282
+ save_pinned(pinned)
283
+ pr_info
284
+ end
285
+
286
+ def unpin(pr_number)
287
+ pinned = load_pinned
288
+ removed = pinned.reject! { |p| p['number'].to_s == pr_number.to_s }
289
+ save_pinned(pinned)
290
+ removed
291
+ end
292
+
293
+ def pinned?(pr_number)
294
+ load_pinned.any? { |p| p['number'].to_s == pr_number.to_s }
295
+ end
296
+
297
+ def fetch_pr_info(pr_number)
298
+ fields = 'number,title,url,headRefName,state,statusCheckRollup'
299
+ output = `gh pr view #{pr_number} --json #{fields} 2>/dev/null`.strip
300
+ return nil if output.empty?
301
+ JSON.parse(output)
302
+ rescue JSON::ParserError
303
+ nil
304
+ end
305
+
306
+ def fetch_current_branch_pr
307
+ fields = 'number,title,url,headRefName,state'
308
+ output = `gh pr view --json #{fields} 2>/dev/null`.strip
309
+ return nil if output.empty?
310
+ JSON.parse(output)
311
+ rescue JSON::ParserError
312
+ nil
313
+ end
314
+
315
+ def fetch_my_prs
316
+ output = `gh pr list --author @me --state open --json number,title,headRefName,url 2>/dev/null`.strip
317
+ return [] if output.empty?
318
+ JSON.parse(output) rescue []
319
+ end
320
+
321
+ def refresh_status(pr)
322
+ info = fetch_pr_info(pr['number'])
323
+ return pr unless info
324
+
325
+ pr['state'] = info['state']
326
+ pr['title'] = info['title']
327
+ pr['checks'] = summarize_checks(info['statusCheckRollup'])
328
+ pr['last_checked'] = Time.now.iso8601
329
+ pr
330
+ end
331
+
332
+ def summarize_checks(rollup)
333
+ return nil unless rollup
334
+
335
+ contexts = rollup.is_a?(Array) ? rollup : []
336
+ return nil if contexts.empty?
337
+
338
+ total = contexts.length
339
+ success = contexts.count { |c| c['conclusion'] == 'SUCCESS' }
340
+ pending = contexts.count { |c| c['status'] == 'PENDING' || c['status'] == 'IN_PROGRESS' }
341
+ failed = contexts.count { |c| c['conclusion'] == 'FAILURE' || c['conclusion'] == 'ERROR' }
342
+
343
+ { 'total' => total, 'success' => success, 'pending' => pending, 'failed' => failed }
344
+ end
345
+
346
+ def display_pinned(pr, idx = nil)
347
+ num = idx ? "#{(idx + 1).to_s.rjust(3)}." : ""
348
+ state_icon = case pr['state']
349
+ when 'MERGED' then '[M]'
350
+ when 'CLOSED' then '[X]'
351
+ else '[O]'
352
+ end
353
+
354
+ checks_str = if pr['checks']
355
+ c = pr['checks']
356
+ if c['failed'] > 0
357
+ " [#{c['success']}/#{c['total']} FAIL:#{c['failed']}]"
358
+ elsif c['pending'] > 0
359
+ " [#{c['success']}/#{c['total']} pending:#{c['pending']}]"
360
+ else
361
+ " [#{c['success']}/#{c['total']}]"
362
+ end
363
+ else
364
+ ""
365
+ end
366
+
367
+ "#{num} #{state_icon} ##{pr['number']} #{pr['title']}#{checks_str}".strip
368
+ end
369
+ end
370
+
247
371
  WATCH_BLOCK = lambda { |original_pr_number=nil, *args|
248
372
  watch = hiiro.get_value(:watch)
249
373
  fail_fast = hiiro.get_value(:fail_fast)
@@ -322,6 +446,221 @@ hiiro.add_subcmd(:current) { manager.current }
322
446
  hiiro.add_subcmd(:open) { |pr_number=nil| manager.open(pr_number) }
323
447
  hiiro.add_subcmd(:view) { |pr_number=nil| manager.view(pr_number) }
324
448
 
449
+ hiiro.add_subcmd(:select) do |*args|
450
+ # Get my open PRs
451
+ output = `gh pr list --author @me --state open --json number,title,headRefName 2>/dev/null`.strip
452
+
453
+ if output.empty?
454
+ STDERR.puts "No open PRs found"
455
+ next
456
+ end
457
+
458
+ prs = JSON.parse(output) rescue []
459
+ if prs.empty?
460
+ STDERR.puts "No open PRs found"
461
+ next
462
+ end
463
+
464
+ lines = prs.each_with_object({}) do |pr, h|
465
+ display = "##{pr['number']} [#{pr['headRefName']}] #{pr['title']}"
466
+ h[display] = pr['number'].to_s
467
+ end
468
+
469
+ selected = Hiiro::Sk.map_select(lines)
470
+
471
+ if selected
472
+ print selected
473
+ end
474
+ end
475
+
476
+ # === PR Pinning ===
477
+
478
+ pinned_manager = PinnedPRManager.new
479
+
480
+ hiiro.add_subcmd(:pin) do |ref = nil, *args|
481
+ pr_info = nil
482
+
483
+ case ref
484
+ when '-', nil
485
+ if ref == '-'
486
+ # Pin current branch's PR
487
+ pr_info = pinned_manager.fetch_current_branch_pr
488
+ unless pr_info
489
+ puts "No PR found for current branch"
490
+ next
491
+ end
492
+ else
493
+ # Select from my PRs
494
+ my_prs = pinned_manager.fetch_my_prs
495
+ if my_prs.empty?
496
+ puts "No open PRs found"
497
+ next
498
+ end
499
+
500
+ lines = my_prs.each_with_object({}) do |pr, h|
501
+ display = "##{pr['number']} [#{pr['headRefName']}] #{pr['title']}"
502
+ h[display] = pr
503
+ end
504
+
505
+ pr_info = Hiiro::Sk.map_select(lines)
506
+ unless pr_info
507
+ puts "No PR selected"
508
+ next
509
+ end
510
+ end
511
+ when /^\d+$/
512
+ # Pin by PR number
513
+ pr_info = pinned_manager.fetch_pr_info(ref)
514
+ unless pr_info
515
+ puts "PR ##{ref} not found"
516
+ next
517
+ end
518
+ else
519
+ puts "Usage: h pr pin [-|PR_NUMBER]"
520
+ puts " - Pin current branch's PR"
521
+ puts " <num> Pin PR by number"
522
+ puts " (none) Select from your open PRs"
523
+ next
524
+ end
525
+
526
+ pinned_manager.pin(pr_info)
527
+ puts "Pinned PR ##{pr_info['number']}: #{pr_info['title']}"
528
+ end
529
+
530
+ hiiro.add_subcmd(:unpin) do |ref = nil, *args|
531
+ pinned = pinned_manager.load_pinned
532
+
533
+ if pinned.empty?
534
+ puts "No pinned PRs"
535
+ next
536
+ end
537
+
538
+ pr_number = nil
539
+
540
+ if ref.nil?
541
+ # Select from pinned PRs
542
+ lines = pinned.each_with_index.each_with_object({}) do |(pr, idx), h|
543
+ h[pinned_manager.display_pinned(pr, idx)] = pr['number'].to_s
544
+ end
545
+
546
+ pr_number = Hiiro::Sk.map_select(lines)
547
+ unless pr_number
548
+ puts "No PR selected"
549
+ next
550
+ end
551
+ elsif ref =~ /^\d+$/
552
+ pr_number = ref
553
+ else
554
+ puts "Usage: h pr unpin [PR_NUMBER]"
555
+ next
556
+ end
557
+
558
+ if pinned_manager.unpin(pr_number)
559
+ puts "Unpinned PR ##{pr_number}"
560
+ else
561
+ puts "PR ##{pr_number} was not pinned"
562
+ end
563
+ end
564
+
565
+ hiiro.add_subcmd(:pinned) do |*args|
566
+ pinned = pinned_manager.load_pinned
567
+
568
+ if pinned.empty?
569
+ puts "No pinned PRs"
570
+ next
571
+ end
572
+
573
+ puts "Pinned PRs:"
574
+ puts
575
+
576
+ pinned.each_with_index do |pr, idx|
577
+ puts pinned_manager.display_pinned(pr, idx)
578
+ end
579
+ end
580
+
581
+ hiiro.add_subcmd(:status) do |*args|
582
+ pinned = pinned_manager.load_pinned
583
+
584
+ if pinned.empty?
585
+ puts "No pinned PRs"
586
+ next
587
+ end
588
+
589
+ puts "Refreshing status for #{pinned.length} pinned PR(s)..."
590
+ puts
591
+
592
+ pinned.each_with_index do |pr, idx|
593
+ pinned_manager.refresh_status(pr)
594
+ puts pinned_manager.display_pinned(pr, idx)
595
+ end
596
+
597
+ pinned_manager.save_pinned(pinned)
598
+ puts
599
+ puts "Status updated at #{Time.now.strftime('%H:%M:%S')}"
600
+ end
601
+
602
+ hiiro.add_subcmd(:'suggest-unpin') do |*args|
603
+ pinned = pinned_manager.load_pinned
604
+
605
+ if pinned.empty?
606
+ puts "No pinned PRs"
607
+ next
608
+ end
609
+
610
+ # Refresh all statuses first
611
+ pinned.each { |pr| pinned_manager.refresh_status(pr) }
612
+ pinned_manager.save_pinned(pinned)
613
+
614
+ merged = pinned.select { |pr| pr['state'] == 'MERGED' }
615
+ closed = pinned.select { |pr| pr['state'] == 'CLOSED' }
616
+
617
+ if merged.empty? && closed.empty?
618
+ puts "No merged or closed PRs to unpin"
619
+ next
620
+ end
621
+
622
+ puts "PRs that can be unpinned:"
623
+ puts
624
+
625
+ (merged + closed).each_with_index do |pr, idx|
626
+ puts pinned_manager.display_pinned(pr, idx)
627
+ end
628
+
629
+ puts
630
+ puts "Run 'h pr auto-unpin' to unpin all merged/closed PRs"
631
+ end
632
+
633
+ hiiro.add_subcmd(:'auto-unpin') do |*args|
634
+ pinned = pinned_manager.load_pinned
635
+
636
+ if pinned.empty?
637
+ puts "No pinned PRs"
638
+ next
639
+ end
640
+
641
+ # Refresh all statuses first
642
+ pinned.each { |pr| pinned_manager.refresh_status(pr) }
643
+
644
+ merged_or_closed = pinned.select { |pr| pr['state'] == 'MERGED' || pr['state'] == 'CLOSED' }
645
+
646
+ if merged_or_closed.empty?
647
+ puts "No merged or closed PRs to unpin"
648
+ pinned_manager.save_pinned(pinned)
649
+ next
650
+ end
651
+
652
+ puts "Unpinning #{merged_or_closed.length} merged/closed PR(s):"
653
+ puts
654
+
655
+ merged_or_closed.each do |pr|
656
+ puts " ##{pr['number']}: #{pr['title']} [#{pr['state']}]"
657
+ pinned_manager.unpin(pr['number'])
658
+ end
659
+
660
+ puts
661
+ puts "Done. #{pinned_manager.load_pinned.length} PR(s) still pinned."
662
+ end
663
+
325
664
  # hiiro.add_default { manager.help }
326
665
 
327
666
  hiiro.run
data/bin/h-project CHANGED
@@ -136,6 +136,32 @@ o.add_subcmd(:edit) { |*args|
136
136
  exec(editor, projects_file)
137
137
  }
138
138
 
139
+ # === SELECT PROJECT ===
140
+
141
+ o.add_subcmd(:select) { |*args|
142
+ dirs = project_dirs
143
+ conf = projects_from_config
144
+
145
+ all_projects = dirs.merge(conf)
146
+
147
+ if all_projects.empty?
148
+ STDERR.puts "No projects found"
149
+ next
150
+ end
151
+
152
+ lines = all_projects.each_with_object({}) do |(name, path), h|
153
+ source = conf.key?(name) ? '[config]' : '[dir]'
154
+ display = format("%-15s %s %s", name, source, path)
155
+ h[display] = path
156
+ end
157
+
158
+ selected = Hiiro::Sk.map_select(lines)
159
+
160
+ if selected
161
+ print selected
162
+ end
163
+ }
164
+
139
165
  # === HELP ===
140
166
 
141
167
  o.add_subcmd(:help) { |*args|
data/bin/h-remind ADDED
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'tempfile'
7
+ require 'hiiro'
8
+
9
+ class ReminderManager
10
+ REMINDERS_FILE = File.join(Dir.home, '.config/hiiro/reminders.yml')
11
+ REMINDER_TEMPLATE = {
12
+ 'message' => '',
13
+ 'interval' => '1h',
14
+ 'sound' => nil,
15
+ 'enabled' => true,
16
+ 'once' => false,
17
+ 'trigger_at' => nil,
18
+ 'last_triggered' => nil,
19
+ 'created_at' => Time.now.iso8601,
20
+ }
21
+
22
+ class Reminder
23
+ attr_accessor :message, :interval, :sound, :enabled, :once, :trigger_at, :last_triggered, :created_at
24
+
25
+ def initialize(message:, interval: '1h', sound: nil, enabled: true, once: false, trigger_at: nil, last_triggered: nil, created_at: Time.now.iso8601)
26
+ @message = message
27
+ @interval = interval
28
+ @sound = sound
29
+ @enabled = enabled
30
+ @once = once
31
+ @trigger_at = trigger_at
32
+ @last_triggered = last_triggered
33
+ @created_at = created_at
34
+ end
35
+
36
+ def self.from_hash(hash)
37
+ new(
38
+ message: hash['message'],
39
+ interval: hash['interval'] || '1h',
40
+ sound: hash['sound'],
41
+ enabled: hash['enabled'] != false,
42
+ once: hash['once'] == true,
43
+ trigger_at: hash['trigger_at'],
44
+ last_triggered: hash['last_triggered'],
45
+ created_at: hash['created_at'] || Time.now.iso8601
46
+ )
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ 'message' => @message,
52
+ 'interval' => @interval,
53
+ 'sound' => @sound,
54
+ 'enabled' => @enabled,
55
+ 'once' => @once,
56
+ 'trigger_at' => @trigger_at,
57
+ 'last_triggered' => @last_triggered,
58
+ 'created_at' => @created_at
59
+ }
60
+ end
61
+
62
+ def to_yaml
63
+ to_h.to_yaml
64
+ end
65
+
66
+ def interval_seconds
67
+ parse_interval(@interval)
68
+ end
69
+
70
+ def parse_interval(str)
71
+ return 3600 unless str # default 1 hour
72
+
73
+ match = str.to_s.match(/^(\d+)([smhdw])$/i)
74
+ return 3600 unless match
75
+
76
+ num = match[1].to_i
77
+ unit = match[2].downcase
78
+
79
+ case unit
80
+ when 's' then num
81
+ when 'm' then num * 60
82
+ when 'h' then num * 3600
83
+ when 'd' then num * 86400
84
+ when 'w' then num * 604800
85
+ else 3600
86
+ end
87
+ end
88
+
89
+ def one_time?
90
+ @once == true
91
+ end
92
+
93
+ def should_trigger?
94
+ return false unless @enabled
95
+
96
+ if one_time?
97
+ # One-time reminder: check if trigger_at time has passed
98
+ return false if @last_triggered # already triggered
99
+ return false unless @trigger_at
100
+
101
+ trigger_time = Time.parse(@trigger_at) rescue nil
102
+ return false unless trigger_time
103
+
104
+ Time.now >= trigger_time
105
+ else
106
+ # Recurring reminder: check interval since last trigger
107
+ return true if @last_triggered.nil?
108
+
109
+ last_time = Time.parse(@last_triggered) rescue Time.at(0)
110
+ Time.now - last_time >= interval_seconds
111
+ end
112
+ end
113
+
114
+ def trigger!
115
+ @last_triggered = Time.now.iso8601
116
+ # For one-time reminders, disable after triggering
117
+ @enabled = false if one_time?
118
+ end
119
+
120
+ def display_string(index = nil)
121
+ num = index ? "#{(index + 1).to_s.rjust(3)}." : ""
122
+ status = @enabled ? "[ON]" : "[OFF]"
123
+ sound_str = @sound ? " (#{@sound})" : ""
124
+
125
+ if one_time?
126
+ if @trigger_at
127
+ trigger_time = Time.parse(@trigger_at) rescue nil
128
+ time_str = trigger_time ? trigger_time.strftime('%Y-%m-%d %H:%M') : @trigger_at
129
+ "#{num} #{status} [ONCE @ #{time_str}] #{@message}#{sound_str}".strip
130
+ else
131
+ "#{num} #{status} [ONCE] #{@message}#{sound_str}".strip
132
+ end
133
+ else
134
+ "#{num} #{status} [#{@interval}] #{@message}#{sound_str}".strip
135
+ end
136
+ end
137
+ end
138
+
139
+ # Parse time argument - returns [is_once, trigger_at, interval]
140
+ def self.parse_time_arg(time_str)
141
+ return [false, nil, '1h'] if time_str.nil?
142
+
143
+ # Check for absolute time format: YYYYmmddHHMM
144
+ if time_str =~ /^(\d{12})$/
145
+ year = $1[0..3].to_i
146
+ month = $1[4..5].to_i
147
+ day = $1[6..7].to_i
148
+ hour = $1[8..9].to_i
149
+ min = $1[10..11].to_i
150
+ trigger_at = Time.new(year, month, day, hour, min).iso8601
151
+ return [true, trigger_at, nil]
152
+ end
153
+
154
+ # Check for relative interval format: 15m, 2h, etc.
155
+ if time_str =~ /^(\d+)([smhdw])$/i
156
+ num = $1.to_i
157
+ unit = $2.downcase
158
+ seconds = case unit
159
+ when 's' then num
160
+ when 'm' then num * 60
161
+ when 'h' then num * 3600
162
+ when 'd' then num * 86400
163
+ when 'w' then num * 604800
164
+ else num * 60
165
+ end
166
+
167
+ # This is a one-time reminder at a relative time
168
+ trigger_at = (Time.now + seconds).iso8601
169
+ return [true, trigger_at, time_str]
170
+ end
171
+
172
+ # Default: treat as recurring interval
173
+ [false, nil, time_str]
174
+ end
175
+
176
+ attr_reader :reminders_file
177
+
178
+ def initialize(reminders_file = REMINDERS_FILE)
179
+ @reminders_file = reminders_file
180
+ end
181
+
182
+ def ensure_reminders_file
183
+ dir = File.dirname(@reminders_file)
184
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
185
+ File.write(@reminders_file, [].to_yaml) unless File.exist?(@reminders_file)
186
+ end
187
+
188
+ def load_reminders
189
+ ensure_reminders_file
190
+ data = YAML.load_file(@reminders_file) || []
191
+ data.map { |hash| Reminder.from_hash(hash) }
192
+ end
193
+
194
+ def save_reminders(reminders)
195
+ ensure_reminders_file
196
+ data = reminders.map(&:to_h)
197
+ File.write(@reminders_file, data.to_yaml)
198
+ end
199
+
200
+ def load_reminder_hash(reminders = nil)
201
+ reminders ||= load_reminders
202
+ reminders.each_with_index.each_with_object({}) do |(reminder, idx), h|
203
+ h[reminder.display_string(idx)] = reminder
204
+ end
205
+ end
206
+
207
+ def edit_reminders(reminders = nil)
208
+ reminders_array = if reminders.nil?
209
+ [REMINDER_TEMPLATE]
210
+ elsif reminders.is_a?(Array)
211
+ reminders.map { |r| r.is_a?(Reminder) ? r.to_h : r }
212
+ else
213
+ [reminders.is_a?(Reminder) ? reminders.to_h : reminders]
214
+ end
215
+
216
+ tmpfile = Tempfile.new(['reminder-edit-', '.yml'])
217
+ tmpfile.write(reminders_array.to_yaml)
218
+ tmpfile.close
219
+
220
+ system(ENV['EDITOR'] || 'vim', tmpfile.path)
221
+
222
+ updated_data = YAML.load_file(tmpfile.path)
223
+ tmpfile.unlink
224
+
225
+ updated_array = updated_data.is_a?(Array) ? updated_data : [updated_data]
226
+ updated_array.map { |hash| Reminder.from_hash(hash) }
227
+ end
228
+
229
+ def find_reminder_by_ref(ref, reminders)
230
+ if ref =~ /^\d+$/
231
+ idx = ref.to_i - 1
232
+ return [idx, reminders[idx]] if idx >= 0 && idx < reminders.length
233
+ end
234
+ [nil, nil]
235
+ end
236
+ end
237
+
238
+ rm = ReminderManager.new
239
+ o = Hiiro.init(*ARGV, plugins: [Pins], reminders_file: rm.reminders_file)
240
+
241
+ o.add_subcmd(:add) do |message = nil, time_arg = nil, sound = nil, *args|
242
+ reminders = rm.load_reminders
243
+
244
+ if message.nil?
245
+ new_reminders = rm.edit_reminders
246
+
247
+ reminders.concat(new_reminders)
248
+ rm.save_reminders(reminders)
249
+
250
+ if new_reminders.length == 1
251
+ puts "Saved reminder ##{reminders.length}: #{new_reminders.first.message}"
252
+ else
253
+ puts "Saved #{new_reminders.length} reminders (#{reminders.length - new_reminders.length + 1}-#{reminders.length})"
254
+ end
255
+ next
256
+ end
257
+
258
+ is_once, trigger_at, interval = ReminderManager.parse_time_arg(time_arg)
259
+
260
+ new_reminder = ReminderManager::Reminder.new(
261
+ message: message,
262
+ interval: interval,
263
+ sound: sound,
264
+ once: is_once,
265
+ trigger_at: trigger_at,
266
+ created_at: Time.now.iso8601
267
+ )
268
+
269
+ reminders << new_reminder
270
+ rm.save_reminders(reminders)
271
+
272
+ if is_once
273
+ trigger_time = Time.parse(trigger_at)
274
+ puts "Saved one-time reminder ##{reminders.length}: #{message} (at #{trigger_time.strftime('%Y-%m-%d %H:%M')})"
275
+ else
276
+ puts "Saved recurring reminder ##{reminders.length}: #{message} (every #{interval || '1h'})"
277
+ end
278
+ end
279
+
280
+ o.add_subcmd(:once) do |message = nil, time_arg = nil, sound = nil, *args|
281
+ reminders = rm.load_reminders
282
+
283
+ if message.nil?
284
+ puts "Usage: h remind once 'message' <time> [sound]"
285
+ puts " time: 15m, 2h, 1d OR YYYYmmddHHMM"
286
+ next
287
+ end
288
+
289
+ time_arg ||= '1h'
290
+ is_once, trigger_at, interval = ReminderManager.parse_time_arg(time_arg)
291
+
292
+ # Force one-time even if parse didn't detect it
293
+ unless trigger_at
294
+ seconds = ReminderManager::Reminder.new(message: '').parse_interval(time_arg)
295
+ trigger_at = (Time.now + seconds).iso8601
296
+ end
297
+
298
+ new_reminder = ReminderManager::Reminder.new(
299
+ message: message,
300
+ interval: interval,
301
+ sound: sound,
302
+ once: true,
303
+ trigger_at: trigger_at,
304
+ created_at: Time.now.iso8601
305
+ )
306
+
307
+ reminders << new_reminder
308
+ rm.save_reminders(reminders)
309
+
310
+ trigger_time = Time.parse(trigger_at)
311
+ puts "Saved one-time reminder ##{reminders.length}: #{message} (at #{trigger_time.strftime('%Y-%m-%d %H:%M')})"
312
+ end
313
+
314
+ o.add_subcmd(:every) do |message = nil, interval = nil, sound = nil, *args|
315
+ reminders = rm.load_reminders
316
+
317
+ if message.nil?
318
+ puts "Usage: h remind every 'message' <interval> [sound]"
319
+ puts " interval: 15m, 2h, 1d, 1w"
320
+ next
321
+ end
322
+
323
+ new_reminder = ReminderManager::Reminder.new(
324
+ message: message,
325
+ interval: interval || '1h',
326
+ sound: sound,
327
+ once: false,
328
+ created_at: Time.now.iso8601
329
+ )
330
+
331
+ reminders << new_reminder
332
+ rm.save_reminders(reminders)
333
+
334
+ puts "Saved recurring reminder ##{reminders.length}: #{message} (every #{interval || '1h'})"
335
+ end
336
+
337
+ o.add_subcmd(:ls) do |*args|
338
+ reminders = rm.load_reminders
339
+ if reminders.empty?
340
+ puts "No reminders saved."
341
+ else
342
+ reminders.each_with_index do |reminder, idx|
343
+ puts reminder.display_string(idx)
344
+ end
345
+ end
346
+ end
347
+
348
+ o.add_subcmd(:list) do |*args|
349
+ o.run_subcmd(:ls, *args)
350
+ end
351
+
352
+ o.add_subcmd(:remove) do |ref = nil, *args|
353
+ reminders = rm.load_reminders
354
+
355
+ if reminders.empty?
356
+ puts "No reminders to remove."
357
+ next
358
+ end
359
+
360
+ idx = nil
361
+ if ref.nil?
362
+ lines = rm.load_reminder_hash(reminders)
363
+
364
+ require 'open3'
365
+ selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
366
+
367
+ if status.success? && !selected.strip.empty?
368
+ reminder = lines[selected.strip]
369
+ idx = reminders.index(reminder)
370
+ end
371
+ else
372
+ idx, _ = rm.find_reminder_by_ref(ref, reminders)
373
+ end
374
+
375
+ if idx.nil?
376
+ puts "Reminder not found"
377
+ next
378
+ end
379
+
380
+ removed = reminders.delete_at(idx)
381
+ rm.save_reminders(reminders)
382
+ puts "Removed reminder: #{removed.message}"
383
+ end
384
+
385
+ o.add_subcmd(:pause) do |ref = nil, *args|
386
+ reminders = rm.load_reminders
387
+
388
+ if reminders.empty?
389
+ puts "No reminders to pause."
390
+ next
391
+ end
392
+
393
+ idx = nil
394
+ if ref.nil?
395
+ enabled_reminders = reminders.select(&:enabled)
396
+ if enabled_reminders.empty?
397
+ puts "No enabled reminders to pause."
398
+ next
399
+ end
400
+
401
+ lines = enabled_reminders.each_with_index.each_with_object({}) do |(r, i), h|
402
+ actual_idx = reminders.index(r)
403
+ h[r.display_string(actual_idx)] = actual_idx
404
+ end
405
+
406
+ require 'open3'
407
+ selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
408
+
409
+ if status.success? && !selected.strip.empty?
410
+ idx = lines[selected.strip]
411
+ end
412
+ else
413
+ idx, _ = rm.find_reminder_by_ref(ref, reminders)
414
+ end
415
+
416
+ if idx.nil?
417
+ puts "Reminder not found"
418
+ next
419
+ end
420
+
421
+ reminders[idx].enabled = false
422
+ rm.save_reminders(reminders)
423
+ puts "Paused reminder: #{reminders[idx].message}"
424
+ end
425
+
426
+ o.add_subcmd(:resume) do |ref = nil, *args|
427
+ reminders = rm.load_reminders
428
+
429
+ if reminders.empty?
430
+ puts "No reminders to resume."
431
+ next
432
+ end
433
+
434
+ idx = nil
435
+ if ref.nil?
436
+ disabled_reminders = reminders.reject(&:enabled)
437
+ if disabled_reminders.empty?
438
+ puts "No paused reminders to resume."
439
+ next
440
+ end
441
+
442
+ lines = disabled_reminders.each_with_index.each_with_object({}) do |(r, i), h|
443
+ actual_idx = reminders.index(r)
444
+ h[r.display_string(actual_idx)] = actual_idx
445
+ end
446
+
447
+ require 'open3'
448
+ selected, status = Open3.capture2('sk', stdin_data: lines.keys.join("\n"))
449
+
450
+ if status.success? && !selected.strip.empty?
451
+ idx = lines[selected.strip]
452
+ end
453
+ else
454
+ idx, _ = rm.find_reminder_by_ref(ref, reminders)
455
+ end
456
+
457
+ if idx.nil?
458
+ puts "Reminder not found"
459
+ next
460
+ end
461
+
462
+ reminders[idx].enabled = true
463
+ rm.save_reminders(reminders)
464
+ puts "Resumed reminder: #{reminders[idx].message}"
465
+ end
466
+
467
+ o.add_subcmd(:check) do |*args|
468
+ reminders = rm.load_reminders
469
+ triggered_count = 0
470
+ verbose = args.include?('-v') || args.include?('--verbose')
471
+
472
+ reminders.each do |reminder|
473
+ next unless reminder.should_trigger?
474
+
475
+ # Build notification options
476
+ notification_args = ['-m', reminder.message, '-t', 'Reminder']
477
+ notification_args += ['-s', reminder.sound] if reminder.sound
478
+
479
+ # Use Hiiro::Notification to show the notification
480
+ notif_hiiro = Hiiro.init(*notification_args)
481
+ Hiiro::Notification.show(notif_hiiro)
482
+
483
+ reminder.trigger!
484
+ triggered_count += 1
485
+
486
+ if verbose
487
+ if reminder.one_time?
488
+ puts "Triggered (one-time): #{reminder.message}"
489
+ else
490
+ puts "Triggered: #{reminder.message}"
491
+ end
492
+ end
493
+ end
494
+
495
+ rm.save_reminders(reminders)
496
+
497
+ if verbose
498
+ enabled_count = reminders.count(&:enabled)
499
+ one_time_done = reminders.count { |r| r.one_time? && !r.enabled }
500
+ puts "Checked #{enabled_count} enabled reminders, triggered #{triggered_count}"
501
+ puts "#{one_time_done} completed one-time reminder(s) (use 'h remind clean' to remove)" if one_time_done > 0
502
+ end
503
+ end
504
+
505
+ o.add_subcmd(:clean) do |*args|
506
+ reminders = rm.load_reminders
507
+ before_count = reminders.length
508
+
509
+ # Remove disabled one-time reminders (already triggered)
510
+ reminders.reject! { |r| r.one_time? && !r.enabled }
511
+
512
+ after_count = reminders.length
513
+ removed = before_count - after_count
514
+
515
+ rm.save_reminders(reminders)
516
+
517
+ if removed > 0
518
+ puts "Removed #{removed} completed one-time reminder(s)"
519
+ else
520
+ puts "No completed one-time reminders to clean up"
521
+ end
522
+ end
523
+
524
+ o.add_subcmd(:howto) do |*args|
525
+ puts <<~HOWTO
526
+ h remind - Reminder notification system
527
+
528
+ SETUP FOR CRON:
529
+ ===============
530
+ Add the following line to your crontab (run 'crontab -e'):
531
+
532
+ * * * * * #{File.expand_path('~/.rbenv/shims/h')} remind check
533
+
534
+ Or if you want verbose logging:
535
+
536
+ * * * * * #{File.expand_path('~/.rbenv/shims/h')} remind check -v >> ~/.config/hiiro/remind.log 2>&1
537
+
538
+ USAGE:
539
+ ======
540
+ # One-time reminders (triggers once, then disables)
541
+ h remind add 'Read the news' 15m # Remind in 15 minutes
542
+ h remind add 'Meeting' 202602131430 # Remind at specific time (YYYYmmddHHMM)
543
+ h remind once 'Call mom' 2h # Explicit one-time in 2 hours
544
+
545
+ # Recurring reminders
546
+ h remind every 'Take a break' 30m # Remind every 30 minutes
547
+ h remind every 'Stand up' 1h basso # Remind hourly with 'basso' sound
548
+
549
+ # Editor mode
550
+ h remind add # Open editor to add reminder(s)
551
+
552
+ # Management
553
+ h remind ls # List all reminders
554
+ h remind remove # Select and remove a reminder
555
+ h remind remove 2 # Remove reminder #2
556
+ h remind clean # Remove completed one-time reminders
557
+
558
+ h remind pause # Select and pause a reminder
559
+ h remind pause 1 # Pause reminder #1
560
+ h remind resume # Select and resume a reminder
561
+ h remind resume 1 # Resume reminder #1
562
+
563
+ h remind check # Check and trigger due reminders
564
+ h remind check -v # Verbose mode
565
+
566
+ TIME FORMATS:
567
+ =============
568
+ Relative (one-time): 15m, 2h, 1d - triggers once after delay
569
+ Absolute (one-time): YYYYmmddHHMM - triggers at specific date/time
570
+ e.g., 202602131430 = Feb 13, 2026 at 14:30
571
+
572
+ Use 'h remind every' to create recurring reminders with intervals.
573
+
574
+ INTERVAL FORMAT (for recurring):
575
+ =================================
576
+ s = seconds (e.g., 30s)
577
+ m = minutes (e.g., 10m)
578
+ h = hours (e.g., 2h)
579
+ d = days (e.g., 1d)
580
+ w = weeks (e.g., 1w)
581
+
582
+ SOUNDS:
583
+ =======
584
+ Custom sounds can be placed in: ~/.config/hiiro/sounds/
585
+ Use the filename (without extension) as the sound name.
586
+
587
+ CONFIG FILE:
588
+ ============
589
+ Reminders are stored in: #{ReminderManager::REMINDERS_FILE}
590
+ HOWTO
591
+ end
592
+
593
+ o.add_subcmd(:path) do |*args|
594
+ print rm.reminders_file
595
+ end
596
+
597
+ o.add_subcmd(:editall) do |*args|
598
+ reminders_before = rm.load_reminders
599
+ system(ENV['EDITOR'] || 'vim', rm.reminders_file)
600
+
601
+ begin
602
+ rm.load_reminders
603
+ rescue => e
604
+ puts "ERROR: Unable to read updated file...reverting."
605
+ rm.save_reminders(reminders_before)
606
+ end
607
+ end
608
+
609
+ begin
610
+ o.run
611
+ rescue => e
612
+ require 'pry'
613
+ binding.pry
614
+ end
data/bin/h-sha CHANGED
@@ -2,9 +2,56 @@
2
2
 
3
3
  require 'hiiro'
4
4
 
5
- BASE_DIR = File.join(Dir.home, 'work/carrot')
5
+ Hiiro.init(*ARGV, plugins: [Pins]) { |hiiro|
6
6
 
7
- Hiiro.init(*ARGV, plugins: [Tmux, Pins, Project]) { |hiiro|
7
+ hiiro.add_subcmd(:select) do |*args|
8
+ # Get git log with format: sha - subject
9
+ log_output = `git log --oneline -100 #{args.join(' ')}`.strip
10
+
11
+ if log_output.empty?
12
+ STDERR.puts "No commits found"
13
+ next
14
+ end
15
+
16
+ lines = log_output.split("\n").each_with_object({}) do |line, h|
17
+ sha = line.split.first
18
+ h[line] = sha
19
+ end
20
+
21
+ selected = Hiiro::Sk.map_select(lines)
22
+
23
+ if selected
24
+ print selected
25
+ end
26
+ end
27
+
28
+ hiiro.add_subcmd(:ls) do |*args|
29
+ system('git', 'log', '--oneline', *args)
30
+ end
31
+
32
+ hiiro.add_subcmd(:show) do |sha = nil, *args|
33
+ if sha.nil?
34
+ sha = `h sha select`.strip
35
+ next if sha.empty?
36
+ end
37
+
38
+ system('git', 'show', sha, *args)
39
+ end
40
+
41
+ hiiro.add_subcmd(:copy) do |sha = nil, *args|
42
+ if sha.nil?
43
+ sha = `h sha select`.strip
44
+ next if sha.empty?
45
+ end
46
+
47
+ require 'open3'
48
+ Open3.popen3('pbcopy') do |stdin, stdout, stderr, wait_thr|
49
+ stdin.write(sha)
50
+ stdin.close
51
+ wait_thr.value
52
+ end
53
+ puts "Copied #{sha} to clipboard"
54
+ end
8
55
 
9
56
  }.run
10
57
 
data/bin/h-window CHANGED
@@ -68,4 +68,25 @@ o.add_subcmd(:history) { |window_id=nil|
68
68
  end
69
69
  }
70
70
 
71
+ o.add_subcmd(:select) do |*args|
72
+ # Get all windows with details
73
+ output = `tmux list-windows -a -F '\#{session_name}:\#{window_index} \#{window_name} [\#{window_panes} panes]'`.strip
74
+
75
+ if output.empty?
76
+ STDERR.puts "No windows found"
77
+ next
78
+ end
79
+
80
+ lines = output.split("\n").each_with_object({}) do |line, h|
81
+ window_id = line.split.first
82
+ h[line] = window_id
83
+ end
84
+
85
+ selected = Hiiro::Sk.map_select(lines)
86
+
87
+ if selected
88
+ print selected
89
+ end
90
+ end
91
+
71
92
  o.run
data/bin/h-wtree CHANGED
@@ -12,5 +12,27 @@ Hiiro.run(*ARGV) do |h|
12
12
  h.add_subcmd(:remove) { |*args| system('git', 'worktree', 'remove', *args) }
13
13
  h.add_subcmd(:repair) { |*args| system('git', 'worktree', 'repair', *args) }
14
14
  h.add_subcmd(:unlock) { |*args| system('git', 'worktree', 'unlock', *args) }
15
+
16
+ h.add_subcmd(:select) do |*args|
17
+ # Get worktree list
18
+ output = `git worktree list`.strip
19
+
20
+ if output.empty?
21
+ STDERR.puts "No worktrees found"
22
+ next
23
+ end
24
+
25
+ lines = output.split("\n").each_with_object({}) do |line, hash|
26
+ # Format: /path/to/worktree sha [branch]
27
+ path = line.split.first
28
+ hash[line] = path
29
+ end
30
+
31
+ selected = Hiiro::Sk.map_select(lines)
32
+
33
+ if selected
34
+ print selected
35
+ end
36
+ end
15
37
  end
16
38
 
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.80"
2
+ VERSION = "0.1.81"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.80
4
+ version: 0.1.81
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-02-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pry
@@ -82,6 +81,7 @@ files:
82
81
  - bin/h-plugin
83
82
  - bin/h-pr
84
83
  - bin/h-project
84
+ - bin/h-remind
85
85
  - bin/h-serve
86
86
  - bin/h-session
87
87
  - bin/h-sha
@@ -134,7 +134,6 @@ metadata:
134
134
  homepage_uri: https://github.com/unixsuperhero/hiiro
135
135
  source_code_uri: https://github.com/unixsuperhero/hiiro
136
136
  changelog_uri: https://github.com/unixsuperhero/hiiro/blob/main/CHANGELOG.md
137
- post_install_message:
138
137
  rdoc_options: []
139
138
  require_paths:
140
139
  - lib
@@ -149,8 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
149
148
  - !ruby/object:Gem::Version
150
149
  version: '0'
151
150
  requirements: []
152
- rubygems_version: 3.3.7
153
- signing_key:
151
+ rubygems_version: 3.6.9
154
152
  specification_version: 4
155
153
  summary: A lightweight CLI framework for Ruby
156
154
  test_files: []