hiiro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/plugins/task.rb ADDED
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Task
4
+ def self.load(hiiro)
5
+ hiiro.log "Plugin loaded: #{name}"
6
+
7
+ hiiro.load_plugin(Tmux)
8
+ attach_methods(hiiro)
9
+ add_subcommands(hiiro)
10
+ end
11
+
12
+ def self.add_subcommands(hiiro)
13
+ hiiro.add_subcmd(:task) do |*args|
14
+ tasks = hiiro.task_manager
15
+
16
+ runner_map = {
17
+ edit: ->(*sargs) { system(ENV['EDITOR'] || 'nvim', __FILE__) },
18
+ list: ->(*sargs) { tasks.list_trees },
19
+ ls: ->(*sargs) { tasks.list_trees },
20
+ start: ->(task_name, tree=nil) { tasks.start_task(task_name, tree:) },
21
+ switch: ->(task_name) { tasks.switch_task(task_name) },
22
+ app: ->(*sargs) { tasks.open_app(*sargs) },
23
+ path: ->(app_name=nil, task=nil) { tasks.app_path(app_name, task: task) },
24
+ cd: ->(*sargs) { tasks.cd_app(*sargs) },
25
+ apps: ->(*sargs) { tasks.list_configured_apps },
26
+ status: ->(*sargs) { tasks.status },
27
+ save: ->(*sargs) { tasks.save_current },
28
+ stop: ->(*sargs) { tasks.stop_current },
29
+ }
30
+
31
+ case args
32
+ in []
33
+ tasks.help
34
+ in ['edit']
35
+ runner_map[:edit].call
36
+ in ['list'] | ['ls']
37
+ tasks.list_trees
38
+ in ['start', task_name]
39
+ tasks.start_task(task_name)
40
+ in ['start', task_name, tree]
41
+ tasks.start_task(task_name, tree: tree)
42
+ in ['app', app_name]
43
+ tasks.open_app(app_name)
44
+ in ['apps']
45
+ tasks.list_configured_apps
46
+ in ['save']
47
+ tasks.save_current
48
+ in ['status'] | ['st']
49
+ tasks.status
50
+ in ['stop']
51
+ tasks.stop_current
52
+ in ['stop', task_name]
53
+ tasks.stop_task(task_name)
54
+ in [subcmd, *sargs]
55
+ match = runner_map.keys.find { |full_subcmd| full_subcmd.to_s.start_with?(subcmd) }
56
+
57
+ if match
58
+ runner_map[match].call(*sargs)
59
+ else
60
+ puts "Unknown task subcommand: #{args.inspect}"
61
+ tasks.help
62
+ end
63
+ else
64
+ puts "Unknown task subcommand: #{args.inspect}"
65
+ tasks.help
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.attach_methods(hiiro)
71
+ hiiro.instance_eval do
72
+ def task_manager
73
+ @task_manager ||= Task::TaskManager.new(self)
74
+ end
75
+ end
76
+ end
77
+
78
+ class TaskManager
79
+ attr_reader :hiiro
80
+
81
+ def initialize(hiiro)
82
+ @hiiro = hiiro
83
+ end
84
+
85
+ def help
86
+ puts "Usage: h task <subcommand> [args]"
87
+ puts
88
+ puts "Subcommands:"
89
+ puts " list, ls List all worktrees and their active tasks"
90
+ puts " start TASK Start a task (reuses available worktree or creates new)"
91
+ puts " switch TASK Switch to an existing task"
92
+ puts " app APP_NAME Open a tmux window for an app in current worktree"
93
+ puts " apps List configured apps from apps.yml"
94
+ puts " save Save current tmux session info for this task"
95
+ puts " status, st Show current task status"
96
+ puts " stop Stop working on current task (worktree becomes available)"
97
+ end
98
+
99
+ # List all worktrees and their active tasks
100
+ def list_trees
101
+ puts "Git worktrees:"
102
+ puts
103
+
104
+ if trees.empty?
105
+ puts " (no worktrees found)"
106
+ puts
107
+ puts " Start a task with 'h task start TASK_NAME' to create one."
108
+ return
109
+ end
110
+
111
+ current = current_task
112
+ active, available = trees.partition { |tree_name| task_for_tree(tree_name) }
113
+
114
+ active.each do |tree_name|
115
+ task = task_for_tree(tree_name)
116
+ marker = (current && current[:tree] == tree_name) ? "*" : " "
117
+ puts format("%s %-20s => %s", marker, tree_name, task)
118
+ end
119
+
120
+ puts if active.any? && available.any?
121
+
122
+ available.each do |tree_name|
123
+ puts format(" %-20s (available)", tree_name)
124
+ end
125
+ end
126
+
127
+ # Start working on a task
128
+ def start_task(task_name, tree: nil)
129
+ # Check if task already exists as a worktree
130
+ existing_tree = tree_for_task(task_name)
131
+ if existing_tree
132
+ puts "Task '#{task_name}' already active in tree '#{existing_tree}'"
133
+ puts "Switching to existing session..."
134
+ switch_to_task(task_name, existing_tree)
135
+ return true
136
+ end
137
+
138
+ # If a specific tree was requested, verify it exists and isn't reserved
139
+ if tree
140
+ if !trees.include?(tree)
141
+ puts "ERROR: Worktree '#{tree}' not found"
142
+ return false
143
+ end
144
+ if RESERVED_WORKTREES.key?(tree)
145
+ puts "ERROR: Worktree '#{tree}' is reserved and cannot be used for tasks"
146
+ return false
147
+ end
148
+ end
149
+
150
+ # Find an available worktree to reuse, or create a new one
151
+ available_tree = tree || find_available_tree
152
+
153
+ if available_tree
154
+ # Rename the available worktree to the task name
155
+ old_path = tree_path(available_tree)
156
+ new_path = File.join(File.dirname(old_path), task_name)
157
+
158
+ if available_tree != task_name
159
+ puts "Renaming worktree '#{available_tree}' to '#{task_name}'..."
160
+ result = system('git', '-C', main_repo_path, 'worktree', 'move', old_path, new_path)
161
+ unless result
162
+ puts "ERROR: Failed to rename worktree"
163
+ return false
164
+ end
165
+ clear_worktree_cache
166
+ end
167
+
168
+ final_tree_name = task_name
169
+ final_tree_path = new_path
170
+ else
171
+ # No available worktree, create a new one
172
+ puts "Creating new worktree for '#{task_name}'..."
173
+ new_path = File.join(Dir.home, 'work', task_name)
174
+
175
+ # Create worktree from main branch (detached to avoid branch conflicts)
176
+ result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
177
+ unless result
178
+ puts "ERROR: Failed to create worktree"
179
+ return false
180
+ end
181
+ clear_worktree_cache
182
+
183
+ final_tree_name = task_name
184
+ final_tree_path = new_path
185
+ end
186
+
187
+ # Associate task with tree
188
+ assign_task_to_tree(task_name, final_tree_name)
189
+
190
+ # Create/switch to tmux session
191
+ session_name = session_name_for(task_name)
192
+
193
+ Dir.chdir(final_tree_path)
194
+ hiiro.start_tmux_session(session_name)
195
+
196
+ save_task_metadata(task_name, tree: final_tree_name, session: session_name)
197
+
198
+ puts "Started task '#{task_name}' in worktree '#{final_tree_name}'"
199
+ true
200
+ end
201
+
202
+ # Start working on a task
203
+ def switch_task(task_name)
204
+ tree, task = assignments.find { |tree, task| task.start_with?(task_name) } || []
205
+
206
+ unless task
207
+ puts "No task matching #{task_name} found."
208
+ return false
209
+ end
210
+
211
+ switch_to_task(task, tree)
212
+
213
+ puts "Started task '#{task}' in tree '#{tree}'"
214
+ true
215
+ end
216
+
217
+ # Open an app window within the current tree
218
+ def open_app(app_name)
219
+ current = current_task
220
+ unless current
221
+ puts "ERROR: Not currently in a task session"
222
+ puts "Use 'h task start TASK_NAME' first"
223
+ return false
224
+ end
225
+
226
+ tree = current[:tree]
227
+ result = find_app_path(tree, app_name)
228
+
229
+ case result
230
+ in nil
231
+ puts "ERROR: App '#{app_name}' not found"
232
+ puts
233
+ list_apps(tree)
234
+ return false
235
+ in [:ambiguous, matches]
236
+ puts "ERROR: '#{app_name}' matches multiple apps:"
237
+ matches.each { |m| puts " #{m}" }
238
+ puts
239
+ puts "Be more specific."
240
+ return false
241
+ in [resolved_name, app_path]
242
+ # Create new tmux window with app directory as base
243
+ system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
244
+ puts "Opened '#{resolved_name}' in new window (#{app_path})"
245
+ true
246
+ end
247
+ end
248
+
249
+ # Open an app window within the current tree
250
+ def cd_app(app_name=nil)
251
+ current = current_task
252
+ unless current
253
+ puts "ERROR: Not currently in a task session"
254
+ puts "Use 'h task start TASK_NAME' first"
255
+ return false
256
+ end
257
+
258
+ tree = current[:tree]
259
+
260
+ result = []
261
+ if app_name.to_s == ''
262
+ result = ['root', tree_path(tree)]
263
+ else
264
+ result = find_app_path(tree, app_name)
265
+ end
266
+
267
+ case result
268
+ in nil
269
+ puts "ERROR: App '#{app_name}' not found"
270
+ puts
271
+ list_apps(tree)
272
+ return false
273
+ in [:ambiguous, matches]
274
+ puts "ERROR: '#{app_name}' matches multiple apps:"
275
+ matches.each { |m| puts " #{m}" }
276
+ puts
277
+ puts "Be more specific."
278
+ return false
279
+ in [resolved_name, app_path]
280
+ # Create new tmux window with app directory as base
281
+ pane = ENV['TMUX_PANE']
282
+ if pane
283
+ puts "PANE: #{pane}"
284
+ puts command: ['tmux', 'send-keys', '-t', pane, "cd #{app_path}\n"].join(' ')
285
+ system('tmux', 'send-keys', '-t', pane, "cd #{app_path}\n")
286
+ else
287
+ puts command: ['tmux', 'send-keys', "cd #{app_path}\n"].join(' ')
288
+ system('tmux', 'send-keys', "cd #{app_path}\n")
289
+ end
290
+ puts "Opened '#{resolved_name}' in new window (#{app_path})"
291
+ true
292
+ end
293
+ end
294
+
295
+ # Save current tmux session state
296
+ def save_current
297
+ current = current_task
298
+ unless current
299
+ puts "ERROR: Not currently in a task session"
300
+ return false
301
+ end
302
+
303
+ task_name = current[:task]
304
+ tree = current[:tree]
305
+ session = current[:session]
306
+
307
+ # Capture tmux window info
308
+ windows = capture_tmux_windows(session)
309
+
310
+ save_task_metadata(task_name,
311
+ tree: tree,
312
+ session: session,
313
+ windows: windows,
314
+ saved_at: Time.now.iso8601
315
+ )
316
+
317
+ puts "Saved task '#{task_name}' state (#{windows.count} windows)"
318
+ true
319
+ end
320
+
321
+ # Show current task status
322
+ def status
323
+ current = current_task
324
+ unless current
325
+ puts "Not currently in a task session"
326
+ return
327
+ end
328
+
329
+ puts "Current task: #{current[:task]}"
330
+ puts "Worktree: #{current[:tree]}"
331
+ puts "Path: #{tree_path(current[:tree])}"
332
+ puts "Session: #{current[:session]}"
333
+
334
+ meta = task_metadata(current[:task])
335
+ if meta && meta['saved_at']
336
+ puts "Last saved: #{meta['saved_at']}"
337
+ end
338
+ end
339
+
340
+ # Stop working on current task (disassociate from worktree)
341
+ def stop_current
342
+ current = current_task
343
+ unless current
344
+ puts "Not currently in a task session"
345
+ return false
346
+ end
347
+
348
+ stop_task(current[:task])
349
+ end
350
+
351
+ # Stop working on a task (disassociate from worktree)
352
+ def stop_task(task_name)
353
+ tree = tree_for_task(task_name)
354
+ task_name = task_for_tree(tree)
355
+
356
+ if RESERVED_WORKTREES.key?(tree)
357
+ puts "Cannot stop reserved task '#{task_name}'"
358
+ return false
359
+ end
360
+
361
+ unassign_task_from_tree(tree)
362
+ puts "Stopped task '#{task_name}' (worktree '#{tree}' now available for reuse)"
363
+ true
364
+ end
365
+
366
+ def list_configured_apps
367
+ if apps_config.any?
368
+ puts "Configured apps (#{apps_config_file}):"
369
+ puts
370
+ apps_config.each do |name, path|
371
+ puts format(" %-20s => %s", name, path)
372
+ end
373
+ else
374
+ puts "No apps configured."
375
+ puts
376
+ puts "Create #{apps_config_file} with format:"
377
+ puts " app_name: relative/path/from/repo"
378
+ puts
379
+ puts "Example:"
380
+ puts " partners: partners/partners"
381
+ puts " admin: admin_portal/admin"
382
+ end
383
+ end
384
+
385
+ def app_path(app_name, task: nil)
386
+ tree_root = task ? tree_path(tree_for_task(task)) : `git rev-parse --show-toplevel`.strip
387
+
388
+ if app_name.nil?
389
+ print tree_root
390
+ exit 0
391
+ end
392
+
393
+ matching_apps = find_all_apps(app_name)
394
+ longest_app_name = printable_apps.keys.max_by(&:length).length + 2
395
+
396
+ case matching_apps.count
397
+ when 0
398
+ puts "ERROR: No matches found"
399
+ puts
400
+ puts "Possible Apps:"
401
+ puts printable_apps.keys.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
402
+ exit 1
403
+ when 1
404
+ print File.join(tree_root, apps_config[matching_apps.first])
405
+ exit 0
406
+ else
407
+ puts "Multiple matches found:"
408
+ puts matching_apps.sort.map{|k| format("%#{longest_app_name}s => %s", k, printable_apps[k]) }
409
+ exit 1
410
+ end
411
+ end
412
+
413
+ private
414
+
415
+ def printable_apps
416
+ apps_config.transform_keys(&:to_s)
417
+ end
418
+
419
+ # Find worktrees using git worktree list
420
+ def trees
421
+ worktree_info.keys.sort
422
+ end
423
+
424
+ # Parse git worktree list output into { name => path } hash
425
+ def worktree_info
426
+ @worktree_info ||= begin
427
+ output = `git -C #{main_repo_path} worktree list --porcelain 2>/dev/null`
428
+ info = {}
429
+ current_path = nil
430
+
431
+ output.lines.each do |line|
432
+ line = line.strip
433
+ if line.start_with?('worktree ')
434
+ current_path = line.sub('worktree ', '')
435
+ elsif line == 'bare'
436
+ # Skip bare repo
437
+ current_path = nil
438
+ elsif line.start_with?('branch ') || line == 'detached'
439
+ # Capture worktree (both named branches and detached HEAD)
440
+ if current_path && current_path != main_repo_path
441
+ name = File.basename(current_path)
442
+ info[name] = current_path
443
+ end
444
+ current_path = nil
445
+ end
446
+ end
447
+
448
+ info
449
+ end
450
+ end
451
+
452
+ def clear_worktree_cache
453
+ @worktree_info = nil
454
+ end
455
+
456
+ # Get the main repo path (where we run git worktree commands from)
457
+ def main_repo_path
458
+ File.join(Dir.home, 'work', '.bare')
459
+ end
460
+
461
+ def tree_path(tree_name)
462
+ worktree_info[tree_name] || File.join(Dir.home, 'work', tree_name)
463
+ end
464
+
465
+ # Worktrees with permanent task assignments (worktree => task)
466
+ RESERVED_WORKTREES = { 'carrot' => 'master' }.freeze
467
+
468
+ # Find an available tree (one without an active task)
469
+ def find_available_tree
470
+ trees.find { |tree| task_for_tree(tree).nil? && !RESERVED_WORKTREES.key?(tree) }
471
+ end
472
+
473
+ # Get the task currently assigned to a tree
474
+ def task_for_tree(tree_name)
475
+ assignments[tree_name]
476
+ end
477
+
478
+ # Get the tree a task is assigned to
479
+ def tree_for_task(task_name)
480
+ assignment_for_task(task_name)&.first
481
+ end
482
+
483
+ def assignment_for_task(partial)
484
+ assignments.find { |tree, task| task.start_with?(partial) }
485
+ end
486
+
487
+ def find_task(partial)
488
+ assignments.values.find { |task| task.start_with?(partial) }
489
+ end
490
+
491
+ # Assign a task to a tree
492
+ def assign_task_to_tree(task_name, tree_name)
493
+ data = assignments
494
+ data[tree_name] = task_name
495
+ save_assignments(data)
496
+ end
497
+
498
+ # Unassign task from tree
499
+ def unassign_task_from_tree(tree_name)
500
+ return if RESERVED_WORKTREES.key?(tree_name)
501
+ data = assignments.dup
502
+ data.delete(tree_name)
503
+ save_assignments(data)
504
+ end
505
+
506
+ # Tree -> Task assignments
507
+ def assignments
508
+ @assignments ||= load_assignments
509
+ end
510
+
511
+ def load_assignments
512
+ data = if File.exist?(assignments_file)
513
+ YAML.safe_load_file(assignments_file) || {}
514
+ else
515
+ {}
516
+ end
517
+ # Always include reserved worktree assignments
518
+ RESERVED_WORKTREES.merge(data)
519
+ end
520
+
521
+ def save_assignments(data)
522
+ FileUtils.mkdir_p(task_dir)
523
+ File.write(assignments_file, YAML.dump(data))
524
+ @assignments = data
525
+ end
526
+
527
+ def assignments_file
528
+ File.join(task_dir, 'assignments.yml')
529
+ end
530
+
531
+ # Task metadata
532
+ def task_metadata(task_name)
533
+ file = task_metadata_file(task_name)
534
+ return nil unless File.exist?(file)
535
+ YAML.safe_load_file(file)
536
+ end
537
+
538
+ def save_task_metadata(task_name, **data)
539
+ FileUtils.mkdir_p(task_dir)
540
+ existing = task_metadata(task_name) || {}
541
+ merged = existing.merge(data.transform_keys(&:to_s))
542
+ File.write(task_metadata_file(task_name), YAML.dump(merged))
543
+ end
544
+
545
+ def task_metadata_file(task_name)
546
+ safe_name = task_name.gsub(/[^a-zA-Z0-9_-]/, '_')
547
+ File.join(task_dir, "task_#{safe_name}.yml")
548
+ end
549
+
550
+ def task_dir
551
+ File.join(Dir.home, '.config', 'hiiro', 'tasks')
552
+ end
553
+
554
+ # Session name for a task
555
+ def session_name_for(task_name)
556
+ task_name
557
+ end
558
+
559
+ # Detect current task from tmux session name
560
+ def current_task
561
+ return nil unless ENV['TMUX']
562
+
563
+ session = `tmux display-message -p '#S'`.strip
564
+
565
+ task_name = session
566
+ tree = tree_for_task(task_name)
567
+
568
+ return nil unless tree
569
+
570
+ { task: task_name, tree: tree, session: session }
571
+ end
572
+
573
+ def switch_to_task(task_name, tree)
574
+ session = session_name_for(task_name)
575
+ tree_path = tree_path(tree)
576
+ Dir.chdir(tree_path)
577
+ hiiro.start_tmux_session(session)
578
+ end
579
+
580
+ # Apps config from ~/.config/hiiro/apps.yml
581
+ # Format: { "app_name" => "relative/path/from/repo/root" }
582
+ def apps_config
583
+ @apps_config ||= load_apps_config
584
+ end
585
+
586
+ def load_apps_config
587
+ return {} unless File.exist?(apps_config_file)
588
+ YAML.safe_load_file(apps_config_file) || {}
589
+ end
590
+
591
+ def apps_config_file
592
+ File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
593
+ end
594
+
595
+ # Find app by partial match (must be unique)
596
+ def find_app(partial)
597
+ matches = find_all_apps(partial)
598
+
599
+ case matches.count
600
+ when 0
601
+ nil
602
+ when 1
603
+ matches.first
604
+ else
605
+ # Check for exact match among multiple partial matches
606
+ exact = matches.find { |name| name == partial }
607
+ exact ? [exact] : matches
608
+ end
609
+ end
610
+
611
+ def find_all_apps(partial)
612
+ apps_config.keys.select { |name| name.start_with?(partial) }
613
+ end
614
+
615
+ # App discovery within a tree
616
+ def find_app_path(tree, app_name)
617
+ tree_root = tree_path(tree)
618
+
619
+ # First, check apps.yml config
620
+ result = find_app(app_name)
621
+
622
+ case result
623
+ when String
624
+ # Single match - use configured path
625
+ return [result, File.join(tree_root, apps_config[result])]
626
+ when Array
627
+ # Multiple matches - return them for error reporting
628
+ return [:ambiguous, result]
629
+ end
630
+
631
+ # Fallback: directory discovery if not in config
632
+ # Look for exact match first
633
+ exact = File.join(tree_root, app_name)
634
+ return [app_name, exact] if Dir.exist?(exact)
635
+
636
+ # Look for nested app dirs (monorepo pattern: app/app)
637
+ nested = File.join(tree_root, app_name, app_name)
638
+ return [app_name, nested] if Dir.exist?(nested)
639
+
640
+ # Fuzzy match on directories
641
+ pattern = File.join(tree_root, '*')
642
+ match = Dir.glob(pattern).find { |path|
643
+ File.basename(path).start_with?(app_name) && File.directory?(path)
644
+ }
645
+ return [File.basename(match), match] if match
646
+
647
+ nil
648
+ end
649
+
650
+ def list_apps(tree)
651
+ if apps_config.any?
652
+ puts "Configured apps (from apps.yml):"
653
+ apps_config.each do |name, path|
654
+ puts format(" %-20s => %s", name, path)
655
+ end
656
+ else
657
+ puts "No apps configured. Create ~/.config/hiiro/apps.yml"
658
+ puts "Format:"
659
+ puts " app_name: relative/path/from/repo"
660
+ puts
661
+ puts "Directories in tree:"
662
+ tree_root = tree_path(tree)
663
+ pattern = File.join(tree_root, '*')
664
+ Dir.glob(pattern).select { |p| File.directory?(p) }.each do |path|
665
+ puts " #{File.basename(path)}"
666
+ end
667
+ end
668
+ end
669
+
670
+ # Capture tmux window state
671
+ def capture_tmux_windows(session)
672
+ output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}'`
673
+ output.lines.map(&:strip).map { |line|
674
+ idx, name, path = line.split(':')
675
+ { 'index' => idx, 'name' => name, 'path' => path }
676
+ }
677
+ end
678
+ end
679
+ end
data/plugins/tmux.rb ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Tmux
4
+ def self.load(hiiro)
5
+ hiiro.log "Plugin loaded: #{name}"
6
+
7
+ attach_methods(hiiro)
8
+ end
9
+
10
+ def self.attach_methods(hiiro)
11
+ hiiro.instance_eval do
12
+ def start_tmux_session(session_name)
13
+ session_name = session_name.to_s
14
+
15
+ unless system('tmux', 'has-session', '-t', session_name)
16
+ system('tmux', 'new', '-d', '-A', '-s', session_name)
17
+ end
18
+
19
+ if ENV['TMUX']
20
+ system('tmux', 'switchc', '-t', session_name)
21
+ elsif ENV['NVIM']
22
+ puts "Can't attach to tmux inside a vim terminal"
23
+ else
24
+ system('tmux', 'new', '-A', '-s', session_name)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end