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