hiiro 0.1.10 → 0.1.12

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: 02e725d64b462fad99b912e4c4772f62601362a0bd1f16eb8949cfb6d22c914e
4
- data.tar.gz: da6b9e03278b930b82cb730e4fc564d7cc6370614475b8fb784632e1ae2b92c9
3
+ metadata.gz: b4e36b4e41c8423664f340bf38dc96796bb459a9edb161b4c2e937837504bb23
4
+ data.tar.gz: c4374d147f3ddb4620349697d14701e0c50c0e3ea74034aa0f9cad6eeae162c9
5
5
  SHA512:
6
- metadata.gz: 16ae1c908d049742e950cc6b7ee7a90c2c80efb44fb289050ea6dc9589d7ae9f955aea1fe68a5eb24daa389bbf1da82c2422628bb2cbbc68ee7a56a3c6326935
7
- data.tar.gz: 8591e60b64c1d924b3653439193d9f28a9381a45fde675759eccf3f8604fcf0f0a869b54b749834b080bb30a02a393456fea7b353e3d5f13a17e9947caffe1fc
6
+ metadata.gz: 820400fc28e1137fce98dc50687c1aeb4543c9b072bca58419c9de5c133c057601e0a9c8b56ff8f6225c36c3e0531d3039cb0011a7bcb8b1bc64e604f0c2f256
7
+ data.tar.gz: 0abdd6cde316418cbf24ea129cd9988f59561fd25c14e15ee7e4227f7d6a82be3a098e295a7ccde5a899580124af4e92e0a8bed3531f99c89c521c7c9976cc5c
data/bin/h-branch ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "hiiro"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ Hiiro.load_env
8
+ hiiro = Hiiro.init(*ARGV, plugins: [Task])
9
+
10
+ class BranchManager
11
+ attr_reader :hiiro
12
+
13
+ def initialize(hiiro)
14
+ @hiiro = hiiro
15
+ end
16
+
17
+ def help
18
+ puts "Usage: h branch <subcommand> [args]"
19
+ puts
20
+ puts "Subcommands:"
21
+ puts " save Record current branch for this task"
22
+ puts " history List branch history (oldest to newest)"
23
+ puts " history --worktree=X Filter by worktree"
24
+ puts " history --task=X Filter by task"
25
+ puts " history --session=X Filter by tmux session"
26
+ puts " current Show current branch info"
27
+ end
28
+
29
+ def save
30
+ branch_name = current_branch
31
+ unless branch_name
32
+ puts "ERROR: Not in a git repository"
33
+ return false
34
+ end
35
+
36
+ entry = build_entry(branch_name)
37
+ unless entry[:task]
38
+ puts "WARNING: Not in a task session, saving without task info"
39
+ end
40
+
41
+ data = load_data
42
+ data['branches'] ||= []
43
+
44
+ # Check if this branch is already recorded for this task
45
+ existing = data['branches'].find do |b|
46
+ b['name'] == branch_name && b['task'] == entry[:task]
47
+ end
48
+
49
+ if existing
50
+ # Update existing entry
51
+ existing.merge!(entry.transform_keys(&:to_s))
52
+ existing['updated_at'] = Time.now.iso8601
53
+ puts "Updated branch '#{branch_name}' for task '#{entry[:task]}'"
54
+ else
55
+ # Add new entry
56
+ data['branches'] << entry.transform_keys(&:to_s).merge('created_at' => Time.now.iso8601)
57
+ puts "Saved branch '#{branch_name}' for task '#{entry[:task]}'"
58
+ end
59
+
60
+ save_data(data)
61
+ show_entry(entry)
62
+ true
63
+ end
64
+
65
+ def history(args = [])
66
+ filters = parse_filters(args)
67
+ data = load_data
68
+ branches = data['branches'] || []
69
+
70
+ if branches.empty?
71
+ puts "No branches recorded."
72
+ puts "Use 'h branch save' to record the current branch."
73
+ return
74
+ end
75
+
76
+ # Apply filters
77
+ branches = filter_entries(branches, filters)
78
+
79
+ if branches.empty?
80
+ puts "No branches match the given filters."
81
+ return
82
+ end
83
+
84
+ # Sort by created_at (oldest first)
85
+ branches = branches.sort_by { |b| b['created_at'] || '' }
86
+
87
+ puts "Branch history (oldest to newest):"
88
+ puts
89
+ branches.each_with_index do |branch, idx|
90
+ puts format_entry(branch, idx + 1)
91
+ end
92
+ end
93
+
94
+ def current
95
+ branch_name = current_branch
96
+ unless branch_name
97
+ puts "ERROR: Not in a git repository"
98
+ return false
99
+ end
100
+
101
+ entry = build_entry(branch_name)
102
+ puts "Current branch info:"
103
+ puts
104
+ show_entry(entry)
105
+ end
106
+
107
+ private
108
+
109
+ def current_branch
110
+ branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
111
+ branch.empty? ? nil : branch
112
+ end
113
+
114
+ def build_entry(branch_name)
115
+ task_info = hiiro.task_manager.send(:current_task)
116
+ tmux_info = capture_tmux_info
117
+
118
+ {
119
+ name: branch_name,
120
+ worktree: task_info&.[](:tree),
121
+ task: task_info&.[](:task),
122
+ tmux: tmux_info
123
+ }
124
+ end
125
+
126
+ def capture_tmux_info
127
+ return nil unless ENV['TMUX']
128
+
129
+ {
130
+ 'session' => `tmux display-message -p '#S'`.strip,
131
+ 'window' => `tmux display-message -p '#W'`.strip,
132
+ 'pane' => ENV['TMUX_PANE']
133
+ }
134
+ end
135
+
136
+ def parse_filters(args)
137
+ filters = {}
138
+ args.each do |arg|
139
+ case arg
140
+ when /^--worktree=(.+)$/
141
+ filters[:worktree] = $1
142
+ when /^--task=(.+)$/
143
+ filters[:task] = $1
144
+ when /^--session=(.+)$/
145
+ filters[:session] = $1
146
+ when /^-w(.+)$/
147
+ filters[:worktree] = $1
148
+ when /^-t(.+)$/
149
+ filters[:task] = $1
150
+ when /^-s(.+)$/
151
+ filters[:session] = $1
152
+ end
153
+ end
154
+ filters
155
+ end
156
+
157
+ def filter_entries(entries, filters)
158
+ entries.select do |entry|
159
+ next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
160
+ next false if filters[:task] && !entry['task']&.include?(filters[:task])
161
+ next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
162
+ true
163
+ end
164
+ end
165
+
166
+ def format_entry(entry, num)
167
+ lines = []
168
+ lines << "#{num}. #{entry['name']}"
169
+ lines << " Task: #{entry['task'] || '(none)'}"
170
+ lines << " Worktree: #{entry['worktree'] || '(none)'}"
171
+ if entry['tmux']
172
+ lines << " Tmux: #{entry['tmux']['session']}/#{entry['tmux']['window']}"
173
+ end
174
+ lines << " Created: #{entry['created_at']}"
175
+ lines.join("\n")
176
+ end
177
+
178
+ def show_entry(entry)
179
+ puts " Branch: #{entry[:name]}"
180
+ puts " Task: #{entry[:task] || '(none)'}"
181
+ puts " Worktree: #{entry[:worktree] || '(none)'}"
182
+ if entry[:tmux]
183
+ puts " Tmux session: #{entry[:tmux]['session']}"
184
+ puts " Tmux window: #{entry[:tmux]['window']}"
185
+ puts " Tmux pane: #{entry[:tmux]['pane']}"
186
+ end
187
+ end
188
+
189
+ def data_file
190
+ File.join(Dir.home, '.config', 'hiiro', 'branches.yml')
191
+ end
192
+
193
+ def load_data
194
+ return {} unless File.exist?(data_file)
195
+ YAML.safe_load_file(data_file) || {}
196
+ end
197
+
198
+ def save_data(data)
199
+ FileUtils.mkdir_p(File.dirname(data_file))
200
+ File.write(data_file, YAML.dump(data))
201
+ end
202
+ end
203
+
204
+ manager = BranchManager.new(hiiro)
205
+
206
+ hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
207
+ hiiro.add_subcmd(:save) { manager.save }
208
+ hiiro.add_subcmd(:history) { |*args| manager.history(args) }
209
+ hiiro.add_subcmd(:current) { manager.current }
210
+
211
+ hiiro.add_default { manager.help }
212
+
213
+ hiiro.run
data/bin/h-link ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ require "hiiro"
8
+
9
+ LINKS_FILE = File.join(Dir.home, '.config/hiiro/links.yml')
10
+
11
+ o = Hiiro.init(*ARGV, plugins: [Tmux, Pins], links_file: LINKS_FILE)
12
+
13
+ def ensure_links_file
14
+ dir = File.dirname(LINKS_FILE)
15
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
16
+ File.write(LINKS_FILE, [].to_yaml) unless File.exist?(LINKS_FILE)
17
+ end
18
+
19
+ def load_links
20
+ ensure_links_file
21
+ YAML.load_file(LINKS_FILE) || []
22
+ end
23
+
24
+ def save_links(links)
25
+ ensure_links_file
26
+ File.write(LINKS_FILE, links.to_yaml)
27
+ end
28
+
29
+ def find_link_by_ref(ref, links)
30
+ if ref =~ /^\d+$/
31
+ idx = ref.to_i - 1
32
+ return [idx, links[idx]] if idx >= 0 && idx < links.length
33
+ else
34
+ links.each_with_index do |link, idx|
35
+ return [idx, link] if link['shorthand'] == ref
36
+ end
37
+ end
38
+ [nil, nil]
39
+ end
40
+
41
+ o.add_subcmd(:save) do |*args|
42
+ if args.empty?
43
+ puts "Usage: h link save <url> [description...]"
44
+ exit 1
45
+ end
46
+
47
+ url = args.shift
48
+ description = args.join(' ')
49
+
50
+ links = load_links
51
+ links << {
52
+ 'url' => url,
53
+ 'description' => description,
54
+ 'shorthand' => nil,
55
+ 'created_at' => Time.now.iso8601
56
+ }
57
+ save_links(links)
58
+
59
+ puts "Saved link ##{links.length}: #{url}"
60
+ end
61
+
62
+ o.add_subcmd(:ls) do |*args|
63
+ links = load_links
64
+ if links.empty?
65
+ puts "No links saved."
66
+ else
67
+ links.each_with_index do |link, idx|
68
+ num = (idx + 1).to_s.rjust(3)
69
+ shorthand = link['shorthand'] ? " [#{link['shorthand']}]" : ""
70
+ desc = link['description'].to_s.empty? ? "" : " - #{link['description']}"
71
+ puts "#{num}.#{shorthand} #{link['url']}#{desc}"
72
+ end
73
+ end
74
+ end
75
+
76
+ o.add_subcmd(:list) do |*args|
77
+ o.run_subcmd(:ls, *args)
78
+ end
79
+
80
+ o.add_subcmd(:search) do |*args|
81
+ if args.empty?
82
+ puts "Usage: h link search <term> [term...]"
83
+ exit 1
84
+ end
85
+
86
+ links = load_links
87
+ terms = args.map(&:downcase)
88
+
89
+ matches = links.each_with_index.select do |link, idx|
90
+ searchable = [
91
+ link['url'],
92
+ link['description'],
93
+ link['shorthand']
94
+ ].compact.join(' ').downcase
95
+
96
+ terms.any? { |term| searchable.include?(term) }
97
+ end
98
+
99
+ if matches.empty?
100
+ puts "No links found matching: #{args.join(' ')}"
101
+ else
102
+ matches.each do |link, idx|
103
+ num = (idx + 1).to_s.rjust(3)
104
+ shorthand = link['shorthand'] ? " [#{link['shorthand']}]" : ""
105
+ desc = link['description'].to_s.empty? ? "" : " - #{link['description']}"
106
+ puts "#{num}.#{shorthand} #{link['url']}#{desc}"
107
+ end
108
+ end
109
+ end
110
+
111
+ o.add_subcmd(:select) do |*args|
112
+ links = load_links
113
+ if links.empty?
114
+ STDERR.puts "No links saved."
115
+ exit 1
116
+ end
117
+
118
+ lines = links.each_with_index.map do |link, idx|
119
+ num = (idx + 1).to_s.rjust(3)
120
+ desc = link['description'].to_s.empty? ? "" : " - #{link['description']}"
121
+ "#{num}. #{link['url']}#{desc}"
122
+ end
123
+
124
+ require 'open3'
125
+ selected, status = Open3.capture2('sk', stdin_data: lines.join("\n"))
126
+
127
+ if status.success? && !selected.strip.empty?
128
+ if selected =~ /^\s*(\d+)\.\s+(\S+)/
129
+ puts $2
130
+ end
131
+ end
132
+ end
133
+
134
+ o.add_subcmd(:edit) do |*args|
135
+ if args.empty?
136
+ puts "Usage: h link edit <number|shorthand>"
137
+ exit 1
138
+ end
139
+
140
+ links = load_links
141
+ idx, link = find_link_by_ref(args.first, links)
142
+
143
+ if link.nil?
144
+ puts "Link not found: #{args.first}"
145
+ exit 1
146
+ end
147
+
148
+ require 'tempfile'
149
+ tmpfile = Tempfile.new(['link-edit-', '.yml'])
150
+ tmpfile.write(link.to_yaml)
151
+ tmpfile.close
152
+
153
+ system(ENV['EDITOR'] || 'vim', tmpfile.path)
154
+
155
+ updated = YAML.load_file(tmpfile.path)
156
+ tmpfile.unlink
157
+
158
+ links[idx] = updated
159
+ save_links(links)
160
+
161
+ puts "Updated link ##{idx + 1}"
162
+ end
163
+
164
+ o.add_subcmd(:open) do |*args|
165
+ if args.empty?
166
+ puts "Usage: h link open <number|shorthand>"
167
+ exit 1
168
+ end
169
+
170
+ links = load_links
171
+ idx, link = find_link_by_ref(args.first, links)
172
+
173
+ if link.nil?
174
+ puts "Link not found: #{args.first}"
175
+ exit 1
176
+ end
177
+
178
+ system('open', link['url'])
179
+ end
180
+
181
+ o.add_subcmd(:path) do |*args|
182
+ print LINKS_FILE
183
+ end
184
+
185
+ begin
186
+ o.run
187
+ rescue => e
188
+ require 'pry'
189
+ binding.pry
190
+ end
data/bin/h-pr ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "hiiro"
4
+ require "fileutils"
5
+ require "yaml"
6
+ require "json"
7
+
8
+ Hiiro.load_env
9
+ hiiro = Hiiro.init(*ARGV, plugins: [Task])
10
+
11
+ class PRManager
12
+ attr_reader :hiiro
13
+
14
+ def initialize(hiiro)
15
+ @hiiro = hiiro
16
+ end
17
+
18
+ def help
19
+ puts "Usage: h pr <subcommand> [args]"
20
+ puts
21
+ puts "Subcommands:"
22
+ puts " save [PR_NUMBER] Record PR for this task (auto-detects if omitted)"
23
+ puts " history List PR history (oldest to newest)"
24
+ puts " history --worktree=X Filter by worktree"
25
+ puts " history --task=X Filter by task"
26
+ puts " history --session=X Filter by tmux session"
27
+ puts " current Show current branch's PR info"
28
+ puts " open [PR_NUMBER] Open PR in browser"
29
+ puts " view [PR_NUMBER] View PR details in terminal"
30
+ end
31
+
32
+ def save(pr_number = nil)
33
+ pr_info = fetch_pr_info(pr_number)
34
+ unless pr_info
35
+ puts "ERROR: Could not find PR"
36
+ puts "Make sure you have the gh CLI installed and authenticated."
37
+ return false
38
+ end
39
+
40
+ entry = build_entry(pr_info)
41
+ unless entry[:task]
42
+ puts "WARNING: Not in a task session, saving without task info"
43
+ end
44
+
45
+ data = load_data
46
+ data['prs'] ||= []
47
+
48
+ # Check if this PR is already recorded for this task
49
+ existing = data['prs'].find do |p|
50
+ p['number'] == pr_info['number'] && p['task'] == entry[:task]
51
+ end
52
+
53
+ if existing
54
+ # Update existing entry
55
+ existing.merge!(entry.transform_keys(&:to_s))
56
+ existing['updated_at'] = Time.now.iso8601
57
+ puts "Updated PR ##{pr_info['number']} for task '#{entry[:task]}'"
58
+ else
59
+ # Add new entry
60
+ data['prs'] << entry.transform_keys(&:to_s).merge('created_at' => Time.now.iso8601)
61
+ puts "Saved PR ##{pr_info['number']} for task '#{entry[:task]}'"
62
+ end
63
+
64
+ save_data(data)
65
+ show_entry(entry)
66
+ true
67
+ end
68
+
69
+ def history(args = [])
70
+ filters = parse_filters(args)
71
+ data = load_data
72
+ prs = data['prs'] || []
73
+
74
+ if prs.empty?
75
+ puts "No PRs recorded."
76
+ puts "Use 'h pr save' to record the current PR."
77
+ return
78
+ end
79
+
80
+ # Apply filters
81
+ prs = filter_entries(prs, filters)
82
+
83
+ if prs.empty?
84
+ puts "No PRs match the given filters."
85
+ return
86
+ end
87
+
88
+ # Sort by created_at (oldest first)
89
+ prs = prs.sort_by { |p| p['created_at'] || '' }
90
+
91
+ puts "PR history (oldest to newest):"
92
+ puts
93
+ prs.each_with_index do |pr, idx|
94
+ puts format_entry(pr, idx + 1)
95
+ end
96
+ end
97
+
98
+ def current
99
+ pr_info = fetch_pr_info
100
+ unless pr_info
101
+ puts "No PR found for current branch."
102
+ puts "Create one with 'gh pr create' or specify a PR number."
103
+ return false
104
+ end
105
+
106
+ entry = build_entry(pr_info)
107
+ puts "Current PR info:"
108
+ puts
109
+ show_entry(entry)
110
+ end
111
+
112
+ def open(pr_number = nil)
113
+ if pr_number
114
+ system('gh', 'pr', 'view', pr_number.to_s, '--web')
115
+ else
116
+ system('gh', 'pr', 'view', '--web')
117
+ end
118
+ end
119
+
120
+ def view(pr_number = nil)
121
+ if pr_number
122
+ system('gh', 'pr', 'view', pr_number.to_s)
123
+ else
124
+ system('gh', 'pr', 'view')
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def fetch_pr_info(pr_number = nil)
131
+ cmd = if pr_number
132
+ ['gh', 'pr', 'view', pr_number.to_s, '--json', 'number,title,url,headRefName,state']
133
+ else
134
+ ['gh', 'pr', 'view', '--json', 'number,title,url,headRefName,state']
135
+ end
136
+
137
+ output = `#{cmd.join(' ')} 2>/dev/null`
138
+ return nil if output.empty?
139
+
140
+ JSON.parse(output)
141
+ rescue JSON::ParserError
142
+ nil
143
+ end
144
+
145
+ def build_entry(pr_info)
146
+ task_info = hiiro.task_manager.send(:current_task)
147
+ tmux_info = capture_tmux_info
148
+
149
+ {
150
+ number: pr_info['number'],
151
+ title: pr_info['title'],
152
+ url: pr_info['url'],
153
+ branch: pr_info['headRefName'],
154
+ state: pr_info['state'],
155
+ worktree: task_info&.[](:tree),
156
+ task: task_info&.[](:task),
157
+ tmux: tmux_info
158
+ }
159
+ end
160
+
161
+ def capture_tmux_info
162
+ return nil unless ENV['TMUX']
163
+
164
+ {
165
+ 'session' => `tmux display-message -p '#S'`.strip,
166
+ 'window' => `tmux display-message -p '#W'`.strip,
167
+ 'pane' => ENV['TMUX_PANE']
168
+ }
169
+ end
170
+
171
+ def parse_filters(args)
172
+ filters = {}
173
+ args.each do |arg|
174
+ case arg
175
+ when /^--worktree=(.+)$/
176
+ filters[:worktree] = $1
177
+ when /^--task=(.+)$/
178
+ filters[:task] = $1
179
+ when /^--session=(.+)$/
180
+ filters[:session] = $1
181
+ when /^-w(.+)$/
182
+ filters[:worktree] = $1
183
+ when /^-t(.+)$/
184
+ filters[:task] = $1
185
+ when /^-s(.+)$/
186
+ filters[:session] = $1
187
+ end
188
+ end
189
+ filters
190
+ end
191
+
192
+ def filter_entries(entries, filters)
193
+ entries.select do |entry|
194
+ next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
195
+ next false if filters[:task] && !entry['task']&.include?(filters[:task])
196
+ next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
197
+ true
198
+ end
199
+ end
200
+
201
+ def format_entry(entry, num)
202
+ lines = []
203
+ lines << "#{num}. PR ##{entry['number']}: #{entry['title']}"
204
+ lines << " Branch: #{entry['branch']}"
205
+ lines << " State: #{entry['state']}"
206
+ lines << " Task: #{entry['task'] || '(none)'}"
207
+ lines << " Worktree: #{entry['worktree'] || '(none)'}"
208
+ if entry['tmux']
209
+ lines << " Tmux: #{entry['tmux']['session']}/#{entry['tmux']['window']}"
210
+ end
211
+ lines << " Created: #{entry['created_at']}"
212
+ lines << " URL: #{entry['url']}"
213
+ lines.join("\n")
214
+ end
215
+
216
+ def show_entry(entry)
217
+ puts " PR: ##{entry[:number]}"
218
+ puts " Title: #{entry[:title]}"
219
+ puts " Branch: #{entry[:branch]}"
220
+ puts " State: #{entry[:state]}"
221
+ puts " URL: #{entry[:url]}"
222
+ puts " Task: #{entry[:task] || '(none)'}"
223
+ puts " Worktree: #{entry[:worktree] || '(none)'}"
224
+ if entry[:tmux]
225
+ puts " Tmux session: #{entry[:tmux]['session']}"
226
+ puts " Tmux window: #{entry[:tmux]['window']}"
227
+ puts " Tmux pane: #{entry[:tmux]['pane']}"
228
+ end
229
+ end
230
+
231
+ def data_file
232
+ File.join(Dir.home, '.config', 'hiiro', 'prs.yml')
233
+ end
234
+
235
+ def load_data
236
+ return {} unless File.exist?(data_file)
237
+ YAML.safe_load_file(data_file) || {}
238
+ end
239
+
240
+ def save_data(data)
241
+ FileUtils.mkdir_p(File.dirname(data_file))
242
+ File.write(data_file, YAML.dump(data))
243
+ end
244
+ end
245
+
246
+ manager = PRManager.new(hiiro)
247
+
248
+ hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
249
+ hiiro.add_subcmd(:save) { |pr_number=nil| manager.save(pr_number) }
250
+ hiiro.add_subcmd(:history) { |*args| manager.history(args) }
251
+ hiiro.add_subcmd(:current) { manager.current }
252
+ hiiro.add_subcmd(:open) { |pr_number=nil| manager.open(pr_number) }
253
+ hiiro.add_subcmd(:view) { |pr_number=nil| manager.view(pr_number) }
254
+
255
+ hiiro.add_default { manager.help }
256
+
257
+ hiiro.run
data/bin/h-subtask ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Shorthand for `h task subtask`
4
+
5
+ require "hiiro"
6
+ require "fileutils"
7
+ require "yaml"
8
+
9
+ Hiiro.load_env
10
+ hiiro = Hiiro.init(*ARGV, plugins: [Tmux, Task])
11
+
12
+ tasks = hiiro.task_manager
13
+
14
+ hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
15
+ hiiro.add_subcmd(:ls) { tasks.list_subtasks }
16
+ hiiro.add_subcmd(:new) { |subtask_name| tasks.new_subtask(subtask_name) }
17
+ hiiro.add_subcmd(:switch) { |subtask_name| tasks.switch_subtask(subtask_name) }
18
+
19
+ hiiro.add_default { tasks.subtask_help }
20
+
21
+ hiiro.run
data/bin/h-wtree ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'hiiro'
4
+
5
+ Hiiro.run(*ARGV) do |h|
6
+ h.add_subcmd(:ls) { |*args| system('git', 'worktree', 'list', *args) }
7
+ h.add_subcmd(:list) { |*args| h.run_subcmd(:ls, *args) }
8
+ h.add_subcmd(:add) { |*args| system('git', 'worktree', 'add', *args) }
9
+ h.add_subcmd(:lock) { |*args| system('git', 'worktree', 'lock', *args) }
10
+ h.add_subcmd(:move) { |*args| system('git', 'worktree', 'move', *args) }
11
+ h.add_subcmd(:prune) { |*args| system('git', 'worktree', 'prune', *args) }
12
+ h.add_subcmd(:remove) { |*args| system('git', 'worktree', 'remove', *args) }
13
+ h.add_subcmd(:repair) { |*args| system('git', 'worktree', 'repair', *args) }
14
+ h.add_subcmd(:unlock) { |*args| system('git', 'worktree', 'unlock', *args) }
15
+ end
16
+
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.10"
2
+ VERSION = "0.1.12"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -19,6 +19,12 @@ class Hiiro
19
19
  end
20
20
  end
21
21
 
22
+ def self.run(*args, plugins: [], logging: false, **values, &block)
23
+ hiiro = init(*args, plugins:,logging:,**values, &block)
24
+
25
+ hiiro.run
26
+ end
27
+
22
28
  def self.load_env
23
29
  Config.plugin_files.each do |plugin_file|
24
30
  require plugin_file
@@ -82,6 +88,11 @@ class Hiiro
82
88
  end
83
89
  alias add_subcmd add_subcommand
84
90
 
91
+ def run_subcommand(name, *args)
92
+ runners.run_subcommand(name, *args)
93
+ end
94
+ alias run_subcmd run_subcommand
95
+
85
96
  def full_name
86
97
  runner&.full_name || [bin_name, subcmd].join(?-)
87
98
  end
@@ -226,6 +237,12 @@ class Hiiro
226
237
  @subcommands[name] = Subcommand.new(bin_name, name, handler, values)
227
238
  end
228
239
 
240
+ def run_subcommand(name, *args)
241
+ cmd = subcommands[name]
242
+
243
+ cmd&.run(*args)
244
+ end
245
+
229
246
  def exact_runner
230
247
  all_runners.find { |r| r.exact_match?(subcmd) }
231
248
  end
data/plugins/task.rb CHANGED
@@ -26,6 +26,7 @@ module Task
26
26
  status: ->(*sargs) { tasks.status },
27
27
  save: ->(*sargs) { tasks.save_current },
28
28
  stop: ->(*sargs) { tasks.stop_current },
29
+ subtask: ->(*sargs) { tasks.handle_subtask(*sargs) },
29
30
  }
30
31
 
31
32
  case args
@@ -51,6 +52,14 @@ module Task
51
52
  tasks.stop_current
52
53
  in ['stop', task_name]
53
54
  tasks.stop_task(task_name)
55
+ in ['subtask']
56
+ tasks.subtask_help
57
+ in ['subtask', 'ls']
58
+ tasks.list_subtasks
59
+ in ['subtask', 'new', subtask_name]
60
+ tasks.new_subtask(subtask_name)
61
+ in ['subtask', 'switch', subtask_name]
62
+ tasks.switch_subtask(subtask_name)
54
63
  in [subcmd, *sargs]
55
64
  match = runner_map.keys.find { |full_subcmd| full_subcmd.to_s.start_with?(subcmd) }
56
65
 
@@ -94,6 +103,7 @@ module Task
94
103
  puts " save Save current tmux session info for this task"
95
104
  puts " status, st Show current task status"
96
105
  puts " stop Stop working on current task (worktree becomes available)"
106
+ puts " subtask <subcmd> Manage subtasks (ls, new, switch)"
97
107
  end
98
108
 
99
109
  # List all worktrees and their active tasks
@@ -363,6 +373,183 @@ module Task
363
373
  true
364
374
  end
365
375
 
376
+ # Subtask management
377
+ def subtask_help
378
+ puts "Usage: h task subtask <subcommand> [args]"
379
+ puts " h subtask <subcommand> [args]"
380
+ puts
381
+ puts "Subcommands:"
382
+ puts " ls List subtasks for current task"
383
+ puts " new SUBTASK_NAME Start a new subtask (creates worktree and session)"
384
+ puts " switch SUBTASK_NAME Switch to subtask's tmux session"
385
+ end
386
+
387
+ def handle_subtask(*args)
388
+ case args
389
+ in []
390
+ subtask_help
391
+ in ['ls']
392
+ list_subtasks
393
+ in ['new', subtask_name]
394
+ new_subtask(subtask_name)
395
+ in ['switch', subtask_name]
396
+ switch_subtask(subtask_name)
397
+ else
398
+ puts "Unknown subtask command: #{args.inspect}"
399
+ subtask_help
400
+ end
401
+ end
402
+
403
+ def list_subtasks
404
+ current = current_task
405
+ unless current
406
+ puts "ERROR: Not currently in a task session"
407
+ return false
408
+ end
409
+
410
+ parent_task = current[:task]
411
+ subtasks = subtasks_for_task(parent_task)
412
+
413
+ if subtasks.empty?
414
+ puts "No subtasks for task '#{parent_task}'."
415
+ puts
416
+ puts "Create one with 'h subtask new SUBTASK_NAME'"
417
+ return
418
+ end
419
+
420
+ puts "Subtasks for '#{parent_task}':"
421
+ puts
422
+ subtasks.each do |subtask|
423
+ marker = subtask['active'] ? "*" : " "
424
+ puts format("%s %-25s tree: %-15s created: %s",
425
+ marker,
426
+ subtask['name'],
427
+ subtask['worktree'] || '(none)',
428
+ subtask['created_at']&.split('T')&.first || '?'
429
+ )
430
+ end
431
+ end
432
+
433
+ def new_subtask(subtask_name)
434
+ current = current_task
435
+ unless current
436
+ puts "ERROR: Not currently in a task session"
437
+ puts "Use 'h task start TASK_NAME' first"
438
+ return false
439
+ end
440
+
441
+ parent_task = current[:task]
442
+ full_subtask_name = "#{parent_task}/#{subtask_name}"
443
+
444
+ # Check if subtask already exists
445
+ existing = subtasks_for_task(parent_task).find { |s| s['name'] == subtask_name }
446
+ if existing
447
+ puts "Subtask '#{subtask_name}' already exists for task '#{parent_task}'"
448
+ puts "Switching to existing session..."
449
+ switch_subtask(subtask_name)
450
+ return true
451
+ end
452
+
453
+ # Create new worktree for subtask
454
+ puts "Creating new worktree for subtask '#{subtask_name}'..."
455
+ new_path = File.join(Dir.home, 'work', full_subtask_name)
456
+
457
+ result = system('git', '-C', main_repo_path, 'worktree', 'add', '--detach', new_path)
458
+ unless result
459
+ puts "ERROR: Failed to create worktree"
460
+ return false
461
+ end
462
+ clear_worktree_cache
463
+
464
+ # Associate subtask with tree
465
+ assign_task_to_tree(full_subtask_name, full_subtask_name)
466
+
467
+ # Record subtask in parent's metadata
468
+ add_subtask_to_task(parent_task, {
469
+ 'name' => subtask_name,
470
+ 'worktree' => full_subtask_name,
471
+ 'session' => full_subtask_name,
472
+ 'created_at' => Time.now.iso8601,
473
+ 'active' => true
474
+ })
475
+
476
+ # Create/switch to tmux session
477
+ Dir.chdir(new_path)
478
+ hiiro.start_tmux_session(full_subtask_name)
479
+
480
+ puts "Started subtask '#{subtask_name}' for task '#{parent_task}'"
481
+ true
482
+ end
483
+
484
+ def switch_subtask(subtask_name)
485
+ current = current_task
486
+ unless current
487
+ puts "ERROR: Not currently in a task session"
488
+ puts "Use 'h task start TASK_NAME' first"
489
+ return false
490
+ end
491
+
492
+ parent_task = current[:task]
493
+ # Handle if we're in a subtask - get the parent
494
+ if parent_task.include?('/')
495
+ parent_task = parent_task.split('/').first
496
+ end
497
+
498
+ subtask = find_subtask(parent_task, subtask_name)
499
+ unless subtask
500
+ puts "Subtask '#{subtask_name}' not found for task '#{parent_task}'"
501
+ puts
502
+ list_subtasks
503
+ return false
504
+ end
505
+
506
+ session_name = subtask['session'] || "#{parent_task}/#{subtask_name}"
507
+ tree_name = subtask['worktree'] || session_name
508
+
509
+ # Check if session exists
510
+ session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
511
+
512
+ if session_exists
513
+ hiiro.start_tmux_session(session_name)
514
+ else
515
+ # Create new session in the worktree path
516
+ path = tree_path(tree_name)
517
+ if Dir.exist?(path)
518
+ Dir.chdir(path)
519
+ hiiro.start_tmux_session(session_name)
520
+ else
521
+ puts "ERROR: Worktree path '#{path}' does not exist"
522
+ return false
523
+ end
524
+ end
525
+
526
+ puts "Switched to subtask '#{subtask_name}'"
527
+ true
528
+ end
529
+
530
+ private
531
+
532
+ def subtasks_for_task(task_name)
533
+ meta = task_metadata(task_name)
534
+ return [] unless meta
535
+ meta['subtasks'] || []
536
+ end
537
+
538
+ def find_subtask(parent_task, subtask_name)
539
+ subtasks = subtasks_for_task(parent_task)
540
+ subtasks.find { |s| s['name'].start_with?(subtask_name) }
541
+ end
542
+
543
+ def add_subtask_to_task(parent_task, subtask_data)
544
+ meta = task_metadata(parent_task) || {}
545
+ meta['subtasks'] ||= []
546
+ meta['subtasks'] << subtask_data
547
+ FileUtils.mkdir_p(task_dir)
548
+ File.write(task_metadata_file(parent_task), YAML.dump(meta))
549
+ end
550
+
551
+ public
552
+
366
553
  def list_configured_apps
367
554
  if apps_config.any?
368
555
  puts "Configured apps (#{apps_config_file}):"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-01-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: pry
@@ -38,14 +39,18 @@ files:
38
39
  - README.md
39
40
  - Rakefile
40
41
  - bin/h
42
+ - bin/h-branch
41
43
  - bin/h-buffer
44
+ - bin/h-link
42
45
  - bin/h-pane
43
46
  - bin/h-plugin
44
- - bin/h-project
47
+ - bin/h-pr
45
48
  - bin/h-session
49
+ - bin/h-subtask
46
50
  - bin/h-task
47
51
  - bin/h-video
48
52
  - bin/h-window
53
+ - bin/h-wtree
49
54
  - docs/README.md
50
55
  - docs/h-buffer.md
51
56
  - docs/h-pane.md
@@ -72,6 +77,7 @@ metadata:
72
77
  homepage_uri: https://github.com/unixsuperhero/hiiro
73
78
  source_code_uri: https://github.com/unixsuperhero/hiiro
74
79
  changelog_uri: https://github.com/unixsuperhero/hiiro/blob/main/CHANGELOG.md
80
+ post_install_message:
75
81
  rdoc_options: []
76
82
  require_paths:
77
83
  - lib
@@ -86,7 +92,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
92
  - !ruby/object:Gem::Version
87
93
  version: '0'
88
94
  requirements: []
89
- rubygems_version: 3.6.9
95
+ rubygems_version: 3.3.7
96
+ signing_key:
90
97
  specification_version: 4
91
98
  summary: A lightweight CLI framework for Ruby
92
99
  test_files: []
data/bin/h-project DELETED
@@ -1,176 +0,0 @@
1
- #!/usr/bin/env ruby
2
- load '/Users/unixsuperhero/bin/h'
3
-
4
- o = Hiiro.init(*ARGV)
5
-
6
- # Helper to start or attach to a tmux session
7
- def start_tmux_session(session_name)
8
- session_name = session_name.to_s
9
-
10
- unless system('tmux', 'has-session', '-t', session_name)
11
- system('tmux', 'new', '-d', '-A', '-s', session_name)
12
- end
13
-
14
- if ENV['TMUX']
15
- system('tmux', 'switchc', '-t', session_name)
16
- elsif ENV['NVIM']
17
- puts "Can't attach to tmux inside a vim terminal"
18
- else
19
- system('tmux', 'new', '-A', '-s', session_name)
20
- end
21
- end
22
-
23
- # Get project directories from ~/proj/
24
- def project_dirs
25
- Dir.glob(File.join(Dir.home, 'proj', '*/')).map { |path|
26
- [File.basename(path), path]
27
- }.to_h
28
- end
29
-
30
- # Get projects from config file
31
- def projects_from_config
32
- projects_file = File.join(Dir.home, '.config/hiiro', 'projects.yml')
33
-
34
- return {} unless File.exist?(projects_file)
35
-
36
- require 'yaml'
37
- YAML.safe_load_file(projects_file)
38
- end
39
-
40
- # === OPEN PROJECT (default) ===
41
-
42
- o.add_subcmd(:open) { |project_name|
43
- re = /#{project_name}/i
44
-
45
- conf_matches = projects_from_config.select { |k, v| k.match?(re) }
46
- dir_matches = project_dirs.select { |proj, path| proj.match?(re) }
47
-
48
- matches = dir_matches.merge(conf_matches)
49
- if matches.count > 1
50
- matches = matches.select { |name, path| name == project_name }
51
- end
52
-
53
- case matches.count
54
- when 0
55
- name = 'proj'
56
- path = File.join(Dir.home, 'proj')
57
-
58
- unless Dir.exist?(path)
59
- puts "Error: #{path.inspect} does not exist"
60
- exit 1
61
- end
62
-
63
- puts "changing dir: #{path}"
64
- Dir.chdir(path)
65
-
66
- start_tmux_session(name)
67
- when 1
68
- name, path = matches.first
69
-
70
- puts "changing dir: #{path}"
71
- Dir.chdir(path)
72
-
73
- start_tmux_session(name)
74
- when (2..)
75
- puts "ERROR: Multiple matches found"
76
- puts
77
- puts "Matches:"
78
- matches.each { |name, path|
79
- puts format(" %s: %s", name, path)
80
- }
81
- end
82
- }
83
-
84
- # === LIST PROJECTS ===
85
-
86
- o.add_subcmd(:list) { |*args|
87
- dirs = project_dirs
88
- conf = projects_from_config
89
-
90
- all_projects = dirs.merge(conf)
91
-
92
- puts "Projects:"
93
- puts
94
- all_projects.keys.sort.each do |name|
95
- path = all_projects[name]
96
- source = conf.key?(name) ? '[config]' : '[dir]'
97
- puts format(" %-12s %-8s %s", name, source, path)
98
- end
99
- }
100
-
101
- o.add_subcmd(:ls) { |*args|
102
- o.run_subcmd(:list, *args)
103
- }
104
-
105
- # === SHOW CONFIG FILE ===
106
-
107
- o.add_subcmd(:config) { |*args|
108
- projects_file = File.join(Dir.home, '.config/hiiro', 'projects.yml')
109
-
110
- if File.exist?(projects_file)
111
- puts File.read(projects_file)
112
- else
113
- puts "No config file found at: #{projects_file}"
114
- puts
115
- puts "Create it with YAML format:"
116
- puts " project_name: /path/to/project"
117
- end
118
- }
119
-
120
- # === EDIT CONFIG FILE ===
121
-
122
- o.add_subcmd(:edit) { |*args|
123
- projects_file = File.join(Dir.home, '.config/hiiro', 'projects.yml')
124
- editor = ENV['EDITOR'] || 'vim'
125
-
126
- # Create config dir if needed
127
- config_dir = File.dirname(projects_file)
128
- Dir.mkdir(config_dir) unless Dir.exist?(config_dir)
129
-
130
- # Create empty file if it doesn't exist
131
- unless File.exist?(projects_file)
132
- File.write(projects_file, "# Project aliases\n# project_name: /path/to/project\n")
133
- end
134
-
135
- exec(editor, projects_file)
136
- }
137
-
138
- # === HELP ===
139
-
140
- o.add_subcmd(:help) { |*args|
141
- puts <<~HELP
142
- h-project - Project directory and tmux session manager
143
-
144
- USAGE:
145
- h-project <project_name> Open project (fuzzy match) and start tmux session
146
- h-project open <project_name> Same as above
147
- h-project list List all known projects
148
- h-project ls Alias for list
149
- h-project config Show config file contents
150
- h-project edit Edit config file
151
-
152
- SOURCES:
153
- Projects are discovered from two sources:
154
- 1. Directories in ~/proj/
155
- 2. Entries in ~/.config/hiiro/projects.yml
156
-
157
- CONFIG FORMAT (projects.yml):
158
- project_name: /path/to/project
159
- another: /some/other/path
160
-
161
- MATCHING:
162
- Project names are matched using regex (case insensitive).
163
- If multiple matches are found, an exact match is preferred.
164
- If still ambiguous, all matches are displayed.
165
- HELP
166
- }
167
-
168
- # === DEFAULT SUBCOMMAND ===
169
-
170
- o.default_subcommand(:open)
171
-
172
- if o.runnable?
173
- o.run
174
- else
175
- o.run_subcmd(:help)
176
- end