hiiro 0.1.31 → 0.1.32

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: f9acf06495590129ec1870e030280f89addc30177e768c360aa8e6891d69ece7
4
- data.tar.gz: afdc44652ae0ea1efbc107f8bd4b972c88477f07cf247665a66104859f49a0c2
3
+ metadata.gz: 33682128f00683ee714b4bc368dbcc2243544cf3c495063231ec95497ded5bc8
4
+ data.tar.gz: 8bfb329a9ec2f09a96e886c8a3ea3146ccb7a5a728ca5efc7a83509df3d578a0
5
5
  SHA512:
6
- metadata.gz: 74d7b72bcb20c9cfdde0ef7c63d02d270f6f95fd05510fb5ff25f0543c4830f15418718f693fce532c8388f9c9a80851a9d13969646d75b187119f08d3d9cd64
7
- data.tar.gz: 5f00c18b9bf9c47f8063a37dd6984bcbf97d7bdc4d06f06d918e1aa93aa48c316a14fd76301c56e029eafab2acacbc163f3e50c040b6d85f64217611554a56ae
6
+ metadata.gz: 6d4ad31db7afe3ca4ee5f7160e5004a086b8a5962908bc346bd30ca9a0c8a4d6948f1c699339d5103a14ea37b101b1beb8a515f17a154ec77e3b7a422cf8211e
7
+ data.tar.gz: b6f2348435f48f1ba9a2ce9b38cd7a6db4d5aefca7c32d8291e96ff6faf589d061f4b2d9838fc9643ff05988dfd9489680dd684e36a478f11203ceb8b40a311d
data/README.md CHANGED
@@ -19,7 +19,7 @@ See [docs/](docs/) for detailed documentation on all subcommands.
19
19
  gem install hiiro
20
20
 
21
21
  # Install plugins and subcommands
22
- hiiro setup
22
+ h setup
23
23
  ```
24
24
 
25
25
  This installs:
@@ -28,26 +28,15 @@ This installs:
28
28
 
29
29
  Ensure `~/bin` is in your `$PATH`.
30
30
 
31
- ### Manual Installation
32
-
33
- ```sh
34
- # Copy the main script
35
- cp bin/h ~/bin/h
36
- chmod +x ~/bin/h
37
-
38
- # Copy subcommands (optional)
39
- cp bin/h-* ~/bin/
40
-
41
- # Copy plugins (optional)
42
- mkdir -p ~/.config/hiiro/plugins
43
- cp plugins/*.rb ~/.config/hiiro/plugins/
44
- ```
45
31
 
46
32
  ### Dependencies
47
33
 
48
34
  ```sh
49
35
  # For notify plugin (macOS)
50
36
  brew install terminal-notifier
37
+
38
+ # For fuzzy-finder
39
+ brew install sk
51
40
  ```
52
41
 
53
42
  ## Quick Start
@@ -56,14 +45,6 @@ brew install terminal-notifier
56
45
  # List available subcommands
57
46
  h
58
47
 
59
- # Edit the main h script
60
- h edit
61
-
62
- # Get paths
63
- h path # Print current directory
64
- h ppath # Print project root (git repo root + relative dir)
65
- h rpath # Print relative directory from git root
66
-
67
48
  # Simple test
68
49
  h ping
69
50
  # => pong
data/TODO.md ADDED
@@ -0,0 +1,21 @@
1
+
2
+ # To do...
3
+
4
+ - [ ] move task/subtask back into a plugin
5
+ - [ ] have it add :task and :subtask subcommands to the instance of Hiiro
6
+ - [ ] they should init another hiiro instance with a `scope` value set
7
+ - [ ] if scope is :task, it looks at the whole world
8
+ - [ ] if the scope is :subtask, it scopes behavior to the current task
9
+ - [ ] redesign the yml structure for managing tasks
10
+
11
+ ```yml
12
+ tasks:
13
+ - name: some_name
14
+ parent_task: parent_task_name (if included it's a subtask)
15
+ tree: tree_name
16
+ root: repo_root/tree_name
17
+ ```
18
+ - [ ] refactor task plugin to not be procedural AI slop
19
+ - [ ] convert to js and using node
20
+ - [ ] h-config {vim,zsh,tmux}
21
+ - [ ]
data/bin/g-pr CHANGED
@@ -7,7 +7,7 @@ require "yaml"
7
7
  require "json"
8
8
 
9
9
  Hiiro.load_env
10
- hiiro = Hiiro.init(*ARGV, plugins: [Task, Tmux, Pins])
10
+ hiiro = Hiiro.init(*ARGV, plugins: [OldTask, Tmux, Pins])
11
11
 
12
12
  class PRManager
13
13
  attr_reader :hiiro
data/bin/h-branch CHANGED
@@ -5,7 +5,7 @@ require "fileutils"
5
5
  require "yaml"
6
6
 
7
7
  Hiiro.load_env
8
- hiiro = Hiiro.init(*ARGV, plugins: [Task])
8
+ hiiro = Hiiro.init(*ARGV, plugins: [OldTask])
9
9
 
10
10
  class BranchManager
11
11
  attr_reader :hiiro
@@ -8,7 +8,7 @@ require "yaml"
8
8
  require "time"
9
9
 
10
10
  Hiiro.load_env
11
- hiiro = Hiiro.init(*ARGV, plugins: [Tmux, Task])
11
+ hiiro = Hiiro.init(*ARGV, plugins: [Tmux, OldTask])
12
12
 
13
13
  tasks = hiiro.task_manager
14
14
 
data/bin/h-pr CHANGED
@@ -7,7 +7,7 @@ require "yaml"
7
7
  require "json"
8
8
 
9
9
  Hiiro.load_env
10
- hiiro = Hiiro.init(*ARGV, plugins: [Task, Tmux, Pins])
10
+ hiiro = Hiiro.init(*ARGV, plugins: [OldTask, Tmux, Pins])
11
11
 
12
12
  class PRManager
13
13
  attr_reader :hiiro
data/exe/h CHANGED
@@ -3,7 +3,7 @@
3
3
  require "hiiro"
4
4
  require "fileutils"
5
5
 
6
- hiiro = Hiiro.init(*ARGV, cwd: Dir.pwd)
6
+ hiiro = Hiiro.init(*ARGV, cwd: Dir.pwd, plugins: [Tasks])
7
7
 
8
8
  hiiro.add_subcommand(:version) { |*args|
9
9
  puts Hiiro::VERSION
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.31"
2
+ VERSION = "0.1.32"
3
3
  end
data/notes ADDED
@@ -0,0 +1,224 @@
1
+
2
+ # Principles to use for refactoring
3
+
4
+ - Separate data from behavior - create domain objects and any relevant value objects
5
+
6
+
7
+ # Goals
8
+
9
+ - same code to manage tasks/subtasks
10
+ - just scoped differently
11
+ - if scope is `:task` then consider all tasks, if scope is :subtask then only consider the tasks associated with the parent task
12
+ - extract data objects
13
+ - so our procedures are human and easy af
14
+ - more composable
15
+ - separate presentation layer from the data
16
+
17
+
18
+ # features we want
19
+
20
+ - list tasks/subtasks
21
+ - start a new one (wtree/tmux session)
22
+ - switch between them
23
+ - cd to an app's directory
24
+ - stop a task
25
+ - use yml files to track things
26
+
27
+ turn @plugins/tasks.rb into a plugin similar to @plugins/task.rb
28
+
29
+ but in add_subcommands(hiiro) i want to define 2 subtasks:
30
+ 1. `:task`
31
+ 2. `:subtask`
32
+
33
+ both subcommands should initialize `Tasks.new(hiiro_instance, :task)` or
34
+ `Tasks.new(hiiro_instance, :subtask)` if the subtask subcmd was called
35
+
36
+ then uses the instance of Tasks to either do all the things that it needs to
37
+ do... or just use it to get the data it needs, so that it can do the high level
38
+ things like running tmux or git worktree commands.
39
+ if it doesn't put those things inside Tasks, that would be better, so I can use
40
+ these objects in other places like inside @bin/h-link
41
+
42
+ use the following interface as a guide for how to organize things inside the
43
+ new Tasks plugin
44
+
45
+ # Interface
46
+
47
+ class Environment
48
+ def self.current # => Environment - makes new instance
49
+ def all_tasks # => Task[] - call Task.all and memo cache the result
50
+ def all_sessions # => Session[] - call TmuxSession.all and memo cache the result
51
+ def all_trees # => Tree[] - call Tree.all and memo cache the result
52
+ def task # => Task - move Task.current's behavior into this method, and memo cache the result
53
+ def session # => Session - move TmuxSession.current here and memo cache the result
54
+ def tree # => Tree - move Tree.current here and memo cache the result
55
+
56
+ class Tree
57
+ attr_reader :path, :task, :session
58
+
59
+ def self.current # => Tree - move to Environment#tree
60
+ def self.all # => Tree[]
61
+
62
+ class Session
63
+ attr_reader :name, :task
64
+
65
+ def self.current # => Session - move to Environment#session
66
+ def self.all # => Session[]
67
+
68
+ class Task
69
+ attr_reader :name, :tree, :session, :parent, :subtasks
70
+
71
+ def self.current # => Task - move to Environment#task
72
+ def self.all # => Task[]
73
+
74
+ class App
75
+ attr_reader :name, :path
76
+ def self.current # => App[]
77
+ def self.all # => App[]
78
+
79
+ class Tasks
80
+ attr_reader :hiiro, :scope, :environment
81
+
82
+ def tasks # => Task[]
83
+ def subtasks(task) # => Task[]
84
+ def task_by_name(name) # => Task
85
+ def task_by_tree(tree_name) # => Task
86
+ def task_by_session(session_name) # => Task
87
+ def current_task # => Task
88
+ def current_session # => Session
89
+ def current_tree # => Tree
90
+
91
+ def start_task(name, path) # => Task
92
+ def switch_to_task(task) # => nil
93
+ def stop_task(task) # => nil
94
+ def list(tasks) # => nil # print to screen
95
+ def cd_to_task(task) # => nil
96
+ def cd_to_app(app_name) # => nil
97
+
98
+ class Config
99
+ attr_reader :path
100
+
101
+
102
+
103
+
104
+ # Follow-up prompts
105
+
106
+ ## i forget
107
+
108
+ > when initializing an instance of `Task`...it shouldn't have a `parent_task` or `parent_name` kwarg. it derive that context from the name given. if the name has a `/` in it...then it
109
+ is a subtask and the left side is the parent name and the right side is the subtask name.
110
+
111
+
112
+ ## lots of updates
113
+
114
+ here are some updates i'd like to make:
115
+
116
+ - all arguments received from the user should looking that object using
117
+ - something like `start_with?` so they can abbreviate the names of things to
118
+ match
119
+ - the only special one is task_name or subtask_name...if there is a `/` the
120
+ left side will do the abbreviation lookup on a parent name and the right
121
+ side will do a lookup on the subtask name
122
+ - you might want to add `#find_tree(abbreviated_name)` method to
123
+ Environment
124
+ - you might want to add `#find_session(abbreviated_name)` method to
125
+ Environment
126
+ - you might want to add `#find_app(abbreviated_name)` method to
127
+ Environment
128
+ - you might want to add `#find_task(abbreviated_name)` method to
129
+ Environment, where it does the left/right abbreviation lookups
130
+ - these new methods should help clean up and simplify all the lookups in
131
+ the other methods that typically use `.find{ ... }`
132
+
133
+ - get rid of methods like `apps_hash` and in the places that used it...
134
+ - just use the array returned from `#apps` and the attr_reader methods
135
+ for things like name and path or other values that there are attr_readers
136
+ for.
137
+ - always favor an instance method or attr_reader over exporting something
138
+ like hash
139
+
140
+ - `#start_task` shouldn't take an optional `tree_path` keyword arg
141
+ - instead it should take an optional `app_name` kwarg and set the tmux
142
+ session's base directory to the matching app's configured directory
143
+
144
+ - `#switch_task` should also take an optional `app_name` kwarg. if a tmux
145
+ session already exists, just update the configs, associating the task
146
+ with that app. if the tmux session doesn't exist, use the app's path as the
147
+ base_directory.
148
+
149
+
150
+ here's one fix i really want to get right.
151
+
152
+ right now TasksPlugin has a dispatch method that uses a case statement to
153
+ handle the subcommands. what i'd like instead is for tasks plugin to initialize
154
+ a new instance of `Hiiro` but instead of it receiving it's usual `*ARGV` as
155
+ positional args, you should pass-in t `*hiiro.args` using the existing `hiiro`
156
+ instance. then for the new `Hiiro` instance...before you call `#run` on
157
+ it...add each of the subcommands defined in the case statement using the
158
+ `add_subcmd` method and have the block passed in be the handler for those
159
+ subcommands
160
+
161
+ so something like this:
162
+
163
+ ```ruby
164
+ module TasksPlugin
165
+ # ...
166
+
167
+ def self.add_subcommands(hiiro)
168
+ hiiro.add_subcmd(:task) do |*args|
169
+ mgr = Tasks.new(hiiro, scope: :task)
170
+ new_hiiro = task_hiiro(hiiro, mgr)
171
+ new_hiiro.run
172
+ end
173
+
174
+ hiiro.add_subcmd(:subtask) do |*args|
175
+ mgr = Tasks.new(hiiro, scope: :subtask)
176
+ new_hiiro = task_hiiro(hiiro, mgr)
177
+ new_hiiro.run
178
+ end
179
+ end
180
+
181
+ def self.task_hiiro(parent_hiiro, mgr)
182
+ Hiiro.init(*parent_hiiro.args, mgr: mgr) do |task_hiiro|
183
+ task_hiiro.add_subcmd(:ls) do |*args|
184
+ # TODO: the logic to print out the task list based on mgr's values
185
+ end
186
+
187
+ task_hiiro.add_subcmd(:start) do |task_name, app_name=nil|
188
+ # TODO: the logic to print out the task list based on mgr's values
189
+ # like:
190
+ mgr.start_task(task_name, app_name: app_name)
191
+ end
192
+
193
+ task_hiiro.add_subcmd(:switch) do |task_name, app_name=nil|
194
+ # TODO: the logic to print out the task list based on mgr's values
195
+ mgr.switch_to_task(task_name, app_name: app_name)
196
+
197
+ # but if you need to add logic, i would do it here...like:
198
+ task = mgr.find_task(task_name)
199
+
200
+ if task
201
+ # run cmds to switch to task here...
202
+ else
203
+ # print some msg like no task found
204
+ end
205
+ end
206
+
207
+ # ...
208
+ end
209
+ end
210
+
211
+ # ...
212
+ end
213
+ ```
214
+
215
+ ## third prompt
216
+
217
+ for things like listing and switching subtasks
218
+ always consider the main/parent task as a potential subtask to switch to.
219
+ for lookups, treat the task name as parent name and `main` as the subtask name
220
+ so something like `nameofparent/main`
221
+
222
+
223
+
224
+
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- module Task
3
+ module OldTask
4
4
  def self.load(hiiro)
5
5
  hiiro.load_plugin(Tmux)
6
6
  attach_methods(hiiro)
@@ -79,7 +79,7 @@ module Task
79
79
  def self.attach_methods(hiiro)
80
80
  hiiro.instance_eval do
81
81
  def task_manager
82
- @task_manager ||= Task::TaskManager.new(self)
82
+ @task_manager ||= OldTask::TaskManager.new(self)
83
83
  end
84
84
  end
85
85
  end
data/plugins/tasks.rb ADDED
@@ -0,0 +1,813 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+ require 'open3'
4
+
5
+ WORK_DIR = File.join(Dir.home, 'work')
6
+ REPO_PATH = File.join(WORK_DIR, '.bare')
7
+
8
+ class TmuxSession
9
+ attr_reader :name
10
+
11
+ def self.current
12
+ return nil unless ENV['TMUX']
13
+
14
+ name = `tmux display-message -p '#S'`.chomp
15
+ new(name)
16
+ end
17
+
18
+ def self.all
19
+ output = `tmux list-sessions -F '#S' 2>/dev/null`
20
+ output.lines(chomp: true).map { |name| new(name) }
21
+ end
22
+
23
+ def initialize(name)
24
+ @name = name
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(TmuxSession) && name == other.name
29
+ end
30
+
31
+ def to_s
32
+ name
33
+ end
34
+ end
35
+
36
+ class Tree
37
+ attr_reader :path, :head, :branch
38
+
39
+ def self.all(repo_path: REPO_PATH)
40
+ output = `git -C #{repo_path} worktree list --porcelain 2>/dev/null`
41
+
42
+ trees = []
43
+ current = nil
44
+
45
+ output.lines(chomp: true).each do |line|
46
+ case line
47
+ when /^worktree (.*)/
48
+ trees << new(**current) if current
49
+ current = { path: $1 }
50
+ when /^HEAD (.*)/
51
+ current[:head] = $1 if current
52
+ when /^branch refs\/heads\/(.*)/
53
+ current[:branch] = $1 if current
54
+ when 'bare'
55
+ current = nil
56
+ end
57
+ end
58
+
59
+ trees << new(**current) if current
60
+ trees
61
+ end
62
+
63
+ def initialize(path:, head: nil, branch: nil)
64
+ @path = path
65
+ @head = head
66
+ @branch = branch
67
+ end
68
+
69
+ def name
70
+ @name ||= if path.start_with?(WORK_DIR + '/')
71
+ path.sub(WORK_DIR + '/', '')
72
+ else
73
+ File.basename(path)
74
+ end
75
+ end
76
+
77
+ def match?(pwd = Dir.pwd)
78
+ pwd == path || pwd.start_with?(path + '/')
79
+ end
80
+
81
+ def detached?
82
+ branch.nil?
83
+ end
84
+
85
+ def ==(other)
86
+ other.is_a?(Tree) && path == other.path
87
+ end
88
+
89
+ def to_s
90
+ name
91
+ end
92
+ end
93
+
94
+ class Task
95
+ attr_reader :name, :tree_name, :session_name
96
+
97
+ def initialize(name:, tree: nil, session: nil, **_)
98
+ @name = name
99
+ @tree_name = tree
100
+ @session_name = session || name
101
+ end
102
+
103
+ def parent_name
104
+ return nil unless subtask?
105
+ name.split('/').first
106
+ end
107
+
108
+ def short_name
109
+ subtask? ? name.split('/', 2).last : name
110
+ end
111
+
112
+ def subtask?
113
+ name.include?('/')
114
+ end
115
+
116
+ def top_level?
117
+ !subtask?
118
+ end
119
+
120
+ def ==(other)
121
+ other.is_a?(Task) && name == other.name
122
+ end
123
+
124
+ def to_s
125
+ name
126
+ end
127
+
128
+ def to_h
129
+ h = { 'name' => name }
130
+ h['tree'] = tree_name if tree_name
131
+ h['session'] = session_name if session_name != name
132
+ h
133
+ end
134
+ end
135
+
136
+ class App
137
+ attr_reader :name, :relative_path
138
+
139
+ def initialize(name:, path:)
140
+ @name = name
141
+ @relative_path = path
142
+ end
143
+
144
+ def resolve(tree_root)
145
+ File.join(tree_root, relative_path)
146
+ end
147
+
148
+ def ==(other)
149
+ other.is_a?(App) && name == other.name
150
+ end
151
+
152
+ def to_s
153
+ name
154
+ end
155
+ end
156
+
157
+ class Environment
158
+ attr_reader :path
159
+
160
+ def self.current
161
+ new(path: Dir.pwd)
162
+ end
163
+
164
+ def initialize(path: Dir.pwd, config: nil)
165
+ @path = path
166
+ @config = config
167
+ end
168
+
169
+ def config
170
+ @config ||= TaskManager::Config.new
171
+ end
172
+
173
+ def all_tasks
174
+ @all_tasks ||= config.tasks
175
+ end
176
+
177
+ def all_sessions
178
+ @all_sessions ||= TmuxSession.all
179
+ end
180
+
181
+ def all_trees
182
+ @all_trees ||= Tree.all
183
+ end
184
+
185
+ def all_apps
186
+ @all_apps ||= config.apps
187
+ end
188
+
189
+ def task
190
+ @task ||= begin
191
+ s = session
192
+ t = tree
193
+ all_tasks.find { |task|
194
+ (s && task.session_name == s.name) ||
195
+ (t && task.tree_name == t.name)
196
+ }
197
+ end
198
+ end
199
+
200
+ def session
201
+ @session ||= TmuxSession.current
202
+ end
203
+
204
+ def tree
205
+ @tree ||= all_trees.find { |t| t.match?(path) }
206
+ end
207
+
208
+ def find_task(abbreviated)
209
+ return nil if abbreviated.nil?
210
+
211
+ if abbreviated.include?('/')
212
+ parent_prefix, child_prefix = abbreviated.split('/', 2)
213
+ parent = all_tasks.select(&:top_level?).find { |t| t.name.start_with?(parent_prefix) }
214
+ return nil unless parent
215
+
216
+ subtask = all_tasks.select { |t| t.parent_name == parent.name }.find { |t| t.short_name.start_with?(child_prefix) }
217
+ return subtask if subtask
218
+
219
+ # "main" refers to the parent task itself
220
+ return parent if 'main'.start_with?(child_prefix)
221
+
222
+ nil
223
+ else
224
+ all_tasks.find { |t| t.name.start_with?(abbreviated) }
225
+ end
226
+ end
227
+
228
+ def find_tree(abbreviated)
229
+ return nil if abbreviated.nil?
230
+ all_trees.find { |t| t.name.start_with?(abbreviated) }
231
+ end
232
+
233
+ def find_session(abbreviated)
234
+ return nil if abbreviated.nil?
235
+ all_sessions.find { |s| s.name.start_with?(abbreviated) }
236
+ end
237
+
238
+ def find_app(abbreviated)
239
+ return nil if abbreviated.nil?
240
+ all_apps.find { |a| a.name.start_with?(abbreviated) }
241
+ end
242
+ end
243
+
244
+ class TaskManager
245
+ TASKS_DIR = File.join(Dir.home, '.config', 'hiiro', 'tasks')
246
+ APPS_FILE = File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
247
+
248
+ class Config
249
+ attr_reader :tasks_file, :apps_file
250
+
251
+ def initialize(tasks_file: nil, apps_file: nil)
252
+ @tasks_file = tasks_file || File.join(TASKS_DIR, 'tasks.yml')
253
+ @apps_file = apps_file || APPS_FILE
254
+ end
255
+
256
+ def tasks
257
+ data = load_tasks
258
+ (data['tasks'] || []).map { |h| Task.new(**h.transform_keys(&:to_sym)) }
259
+ end
260
+
261
+ def apps
262
+ return [] unless File.exist?(apps_file)
263
+ data = YAML.safe_load_file(apps_file) || {}
264
+ data.map { |name, path| App.new(name: name, path: path) }
265
+ end
266
+
267
+ def save_task(task)
268
+ data = load_tasks
269
+ data['tasks'] ||= []
270
+ data['tasks'].reject! { |t| t['name'] == task.name }
271
+ data['tasks'] << task.to_h
272
+ save_tasks(data)
273
+ end
274
+
275
+ def remove_task(name)
276
+ data = load_tasks
277
+ data['tasks'] ||= []
278
+ data['tasks'].reject! { |t| t['name'] == name }
279
+ save_tasks(data)
280
+ end
281
+
282
+ private
283
+
284
+ def load_tasks
285
+ if File.exist?(tasks_file)
286
+ return YAML.safe_load_file(tasks_file) || { 'tasks' => [] }
287
+ end
288
+
289
+ assignments_file = File.join(File.dirname(tasks_file), 'assignments.yml')
290
+ if File.exist?(assignments_file)
291
+ raw = YAML.safe_load_file(assignments_file) || {}
292
+ data = { 'tasks' => raw.map { |path, name| { 'name' => name, 'tree' => path } } }
293
+ save_tasks(data)
294
+ return data
295
+ end
296
+
297
+ { 'tasks' => [] }
298
+ end
299
+
300
+ def save_tasks(data)
301
+ FileUtils.mkdir_p(File.dirname(tasks_file))
302
+ File.write(tasks_file, YAML.dump(data))
303
+ end
304
+ end
305
+
306
+ attr_reader :hiiro, :scope, :environment
307
+
308
+ def initialize(hiiro, scope: :task, environment: nil)
309
+ @hiiro = hiiro
310
+ @scope = scope
311
+ @environment = environment || Environment.current
312
+ end
313
+
314
+ def config
315
+ environment.config
316
+ end
317
+
318
+ # --- Scope-aware queries ---
319
+
320
+ def tasks
321
+ if scope == :subtask
322
+ parent = current_parent_task
323
+ return [] unless parent
324
+ main_task = Task.new(name: "#{parent.name}/main", tree: parent.tree_name, session: parent.session_name)
325
+ subtask_list = environment.all_tasks.select { |t| t.parent_name == parent.name }
326
+ [main_task, *subtask_list]
327
+ else
328
+ environment.all_tasks.select(&:top_level?)
329
+ end
330
+ end
331
+
332
+ def subtasks(task)
333
+ environment.all_tasks.select { |t| t.parent_name == task.name }
334
+ end
335
+
336
+ def task_by_name(name)
337
+ return slash_lookup(name) if name.include?('/')
338
+
339
+ tasks.find { |t|
340
+ match_name = (scope == :subtask) ? t.short_name : t.name
341
+ match_name.start_with?(name)
342
+ }
343
+ end
344
+
345
+ def task_by_tree(tree_name)
346
+ tasks.find { |t| t.tree_name == tree_name }
347
+ end
348
+
349
+ def task_by_session(session_name)
350
+ tasks.find { |t| t.session_name == session_name }
351
+ end
352
+
353
+ def current_task
354
+ environment.task
355
+ end
356
+
357
+ def current_session
358
+ environment.session
359
+ end
360
+
361
+ def current_tree
362
+ environment.tree
363
+ end
364
+
365
+ # --- Actions ---
366
+
367
+ def start_task(name, app_name: nil)
368
+ existing = task_by_name(name)
369
+ if existing
370
+ puts "Task '#{existing.name}' already exists. Switching..."
371
+ switch_to_task(existing, app_name: app_name)
372
+ return
373
+ end
374
+
375
+ task_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : name
376
+ subtree_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : "#{name}/main"
377
+
378
+ target_path = File.join(WORK_DIR, subtree_name)
379
+
380
+ available = find_available_tree
381
+ if available
382
+ puts "Renaming worktree '#{available.name}' to '#{subtree_name}'..."
383
+ FileUtils.mkdir_p(File.dirname(target_path))
384
+ unless system('git', '-C', REPO_PATH, 'worktree', 'move', available.path, target_path)
385
+ puts "ERROR: Failed to rename worktree"
386
+ return
387
+ end
388
+ else
389
+ puts "Creating new worktree '#{subtree_name}'..."
390
+ FileUtils.mkdir_p(File.dirname(target_path))
391
+ unless system('git', '-C', REPO_PATH, 'worktree', 'add', '--detach', target_path)
392
+ puts "ERROR: Failed to create worktree"
393
+ return
394
+ end
395
+ end
396
+
397
+ session_name = task_name
398
+ task = Task.new(name: task_name, tree: subtree_name, session: session_name)
399
+ config.save_task(task)
400
+
401
+ base_dir = target_path
402
+ if app_name
403
+ app = environment.find_app(app_name)
404
+ base_dir = app.resolve(target_path) if app
405
+ end
406
+
407
+ Dir.chdir(base_dir)
408
+ hiiro.start_tmux_session(session_name)
409
+
410
+ puts "Started task '#{task_name}' in worktree '#{subtree_name}'"
411
+ end
412
+
413
+ def switch_to_task(task, app_name: nil)
414
+ unless task
415
+ puts "Task not found"
416
+ return
417
+ end
418
+
419
+ tree = environment.find_tree(task.tree_name)
420
+ tree_path = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
421
+
422
+ session_name = task.session_name
423
+ session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL)
424
+
425
+ if session_exists
426
+ hiiro.start_tmux_session(session_name)
427
+ else
428
+ base_dir = tree_path
429
+ if app_name
430
+ app = environment.find_app(app_name)
431
+ base_dir = app.resolve(tree_path) if app
432
+ end
433
+
434
+ if Dir.exist?(base_dir)
435
+ Dir.chdir(base_dir)
436
+ hiiro.start_tmux_session(session_name)
437
+ else
438
+ puts "ERROR: Path '#{base_dir}' does not exist"
439
+ return
440
+ end
441
+ end
442
+
443
+ puts "Switched to '#{task.name}'"
444
+ end
445
+
446
+ def stop_task(task)
447
+ unless task
448
+ puts "Task not found"
449
+ return
450
+ end
451
+
452
+ config.remove_task(task.name)
453
+ # Also remove any subtasks
454
+ subtasks(task).each { |st| config.remove_task(st.name) }
455
+
456
+ puts "Stopped task '#{task.name}' (worktree available for reuse)"
457
+ end
458
+
459
+ def list
460
+ items = tasks
461
+ if items.empty?
462
+ puts scope == :subtask ? "No subtasks found" : "No tasks found"
463
+ puts "Use 'h #{scope} start NAME' to create one."
464
+ return
465
+ end
466
+
467
+ current = current_task
468
+ label = scope == :subtask ? "Subtasks" : "Tasks"
469
+ if scope == :subtask && current
470
+ parent = current_parent_task
471
+ label = "Subtasks of '#{parent&.name}'" if parent
472
+ end
473
+
474
+ puts "#{label}:"
475
+ puts
476
+
477
+ items.each do |task|
478
+ marker = (current && current.name == task.name) ? "*" : " "
479
+ tree = environment.find_tree(task.tree_name)
480
+ branch = tree&.branch || (tree&.detached? ? '(detached)' : nil)
481
+ branch_str = branch ? " [#{branch}]" : ""
482
+
483
+ display_name = scope == :subtask ? task.short_name : task.name
484
+ puts format("%s %-25s tree: %-20s%s", marker, display_name, task.tree_name || '(none)', branch_str)
485
+
486
+ # Show subtask count for top-level tasks
487
+ if scope == :task
488
+ subs = subtasks(task)
489
+ subs.each do |st|
490
+ sub_marker = (current && current.name == st.name) ? "*" : " "
491
+ sub_tree = environment.find_tree(st.tree_name)
492
+ sub_branch = sub_tree&.branch || (sub_tree&.detached? ? '(detached)' : nil)
493
+ sub_branch_str = sub_branch ? " [#{sub_branch}]" : ""
494
+ padding = " " * task.name.length
495
+ puts format("%s %s/%-*s tree: %-20s%s", sub_marker, padding, 25 - task.name.length - 1, st.short_name, st.tree_name || '(none)', sub_branch_str)
496
+ end
497
+ end
498
+ end
499
+
500
+ available = environment.all_trees.reject { |t|
501
+ environment.all_tasks.any? { |task| task.tree_name == t.name }
502
+ }
503
+
504
+ if available.any?
505
+ puts
506
+ available.each do |tree|
507
+ branch_str = tree.branch ? " [#{tree.branch}]" : tree.detached? ? " [(detached)]" : ""
508
+ puts format(" %-25s (available)%s", tree.name, branch_str)
509
+ end
510
+ end
511
+ end
512
+
513
+ def status
514
+ task = current_task
515
+ unless task
516
+ puts "Not currently in a task session"
517
+ return
518
+ end
519
+
520
+ puts "Task: #{task.name}"
521
+ puts "Worktree: #{task.tree_name}"
522
+ tree = environment.find_tree(task.tree_name)
523
+ puts "Path: #{tree&.path || '(unknown)'}"
524
+ puts "Session: #{task.session_name}"
525
+ puts "Parent: #{task.parent_name}" if task.subtask?
526
+ end
527
+
528
+ def save
529
+ task = current_task
530
+ unless task
531
+ puts "ERROR: Not currently in a task session"
532
+ return
533
+ end
534
+
535
+ windows = capture_tmux_windows(task.session_name)
536
+ puts "Saved task '#{task.name}' state (#{windows.count} windows)"
537
+ end
538
+
539
+ def open_app(app_name)
540
+ task = current_task
541
+ unless task
542
+ puts "ERROR: Not currently in a task session"
543
+ return
544
+ end
545
+
546
+ result = resolve_app(app_name, task)
547
+ return unless result
548
+
549
+ resolved_name, app_path = result
550
+ system('tmux', 'new-window', '-n', resolved_name, '-c', app_path)
551
+ puts "Opened '#{resolved_name}' in new window (#{app_path})"
552
+ end
553
+
554
+ def list_apps
555
+ apps = environment.all_apps
556
+ if apps.any?
557
+ puts "Configured apps:"
558
+ puts
559
+ apps.each do |app|
560
+ puts format(" %-20s => %s", app.name, app.relative_path)
561
+ end
562
+ else
563
+ puts "No apps configured."
564
+ puts "Create #{APPS_FILE} with format:"
565
+ puts " app_name: relative/path/from/repo"
566
+ end
567
+ end
568
+
569
+ def cd_to_task(task)
570
+ unless task
571
+ puts "Task not found"
572
+ return
573
+ end
574
+
575
+ tree = environment.find_tree(task.tree_name)
576
+ path = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
577
+ send_cd(path)
578
+ end
579
+
580
+ def cd_to_app(app_name = nil)
581
+ task = current_task
582
+ unless task
583
+ puts "ERROR: Not currently in a task session"
584
+ return
585
+ end
586
+
587
+ if app_name.nil? || app_name.empty?
588
+ tree = environment.find_tree(task.tree_name)
589
+ send_cd(tree&.path || File.join(WORK_DIR, task.tree_name))
590
+ return
591
+ end
592
+
593
+ result = resolve_app(app_name, task)
594
+ return unless result
595
+
596
+ _resolved_name, app_path = result
597
+ send_cd(app_path)
598
+ end
599
+
600
+ def app_path(app_name = nil)
601
+ task = current_task
602
+ tree_root = if task
603
+ tree = environment.find_tree(task.tree_name)
604
+ tree&.path || File.join(WORK_DIR, task.tree_name)
605
+ else
606
+ `git rev-parse --show-toplevel`.strip
607
+ end
608
+
609
+ if app_name.nil?
610
+ print tree_root
611
+ return
612
+ end
613
+
614
+ matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
615
+
616
+ case matches.count
617
+ when 0
618
+ puts "ERROR: No matches found"
619
+ puts
620
+ puts "Possible Apps:"
621
+ environment.all_apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
622
+ when 1
623
+ print matches.first.resolve(tree_root)
624
+ else
625
+ puts "Multiple matches found:"
626
+ matches.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
627
+ end
628
+ end
629
+
630
+ def help
631
+ scope_name = scope.to_s
632
+ puts "Usage: h #{scope_name} <subcommand> [args]"
633
+ puts
634
+ puts "Subcommands:"
635
+ puts " list, ls List #{scope_name}s"
636
+ puts " start NAME [APP] Start a new #{scope_name}"
637
+ puts " switch [NAME] Switch to a #{scope_name} (interactive if no name)"
638
+ puts " app [APP_NAME] Open app in new tmux window (interactive if no name)"
639
+ puts " apps List configured apps"
640
+ puts " cd [APP_NAME] Change directory to app"
641
+ puts " path [APP_NAME] Print app path"
642
+ puts " status, st Show current #{scope_name} status"
643
+ puts " save Save current session state"
644
+ puts " stop [NAME] Stop a #{scope_name} (interactive if no name)"
645
+ end
646
+
647
+ # --- Interactive selection with sk ---
648
+
649
+ def select_task_interactive(prompt = nil)
650
+ names = tasks.map { |t| scope == :subtask ? t.short_name : t.name }
651
+ return nil if names.empty?
652
+
653
+ sk_select(names)
654
+ end
655
+
656
+ # --- Private helpers ---
657
+
658
+ private
659
+
660
+ def slash_lookup(input)
661
+ environment.find_task(input)
662
+ end
663
+
664
+ def current_parent_task
665
+ task = current_task
666
+ return nil unless task
667
+
668
+ if task.subtask?
669
+ environment.find_task(task.parent_name)
670
+ else
671
+ task
672
+ end
673
+ end
674
+
675
+ def find_available_tree
676
+ assigned_tree_names = environment.all_tasks.map(&:tree_name)
677
+ environment.all_trees.find { |tree| !assigned_tree_names.include?(tree.name) }
678
+ end
679
+
680
+ def resolve_app(app_name, task)
681
+ tree = environment.find_tree(task.tree_name)
682
+ tree_root = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
683
+
684
+ matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
685
+
686
+ case matches.count
687
+ when 0
688
+ # Fallback: directory discovery
689
+ exact = File.join(tree_root, app_name)
690
+ return [app_name, exact] if Dir.exist?(exact)
691
+
692
+ nested = File.join(tree_root, app_name, app_name)
693
+ return [app_name, nested] if Dir.exist?(nested)
694
+
695
+ puts "ERROR: App '#{app_name}' not found"
696
+ list_apps
697
+ nil
698
+ when 1
699
+ app = matches.first
700
+ [app.name, app.resolve(tree_root)]
701
+ else
702
+ exact = matches.find { |a| a.name == app_name }
703
+ if exact
704
+ [exact.name, exact.resolve(tree_root)]
705
+ else
706
+ puts "ERROR: '#{app_name}' matches multiple apps:"
707
+ matches.each { |a| puts " #{a.name}" }
708
+ nil
709
+ end
710
+ end
711
+ end
712
+
713
+ def send_cd(path)
714
+ pane = ENV['TMUX_PANE']
715
+ if pane
716
+ system('tmux', 'send-keys', '-t', pane, "cd #{path}\n")
717
+ else
718
+ system('tmux', 'send-keys', "cd #{path}\n")
719
+ end
720
+ end
721
+
722
+ def capture_tmux_windows(session)
723
+ output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}' 2>/dev/null`
724
+ output.lines.map(&:strip).map { |line|
725
+ idx, name, path = line.split(':')
726
+ { 'index' => idx, 'name' => name, 'path' => path }
727
+ }
728
+ end
729
+
730
+ def sk_select(items)
731
+ selected, status = Open3.capture2('sk', stdin_data: items.join("\n"))
732
+ return selected.strip if status.success? && !selected.strip.empty?
733
+ nil
734
+ end
735
+ end
736
+
737
+ module Tasks
738
+ def self.load(hiiro)
739
+ hiiro.load_plugin(Tmux)
740
+ add_subcommands(hiiro)
741
+ end
742
+
743
+ def self.add_subcommands(hiiro)
744
+ hiiro.add_subcmd(:task) do |*args|
745
+ mgr = TaskManager.new(hiiro, scope: :task)
746
+ task_hiiro = Tasks.build_hiiro(hiiro, mgr)
747
+ task_hiiro.run
748
+ end
749
+
750
+ hiiro.add_subcmd(:subtask) do |*args|
751
+ mgr = TaskManager.new(hiiro, scope: :subtask)
752
+ task_hiiro = Tasks.build_hiiro(hiiro, mgr)
753
+ task_hiiro.run
754
+ end
755
+ end
756
+
757
+ def self.build_hiiro(parent_hiiro, mgr)
758
+ Hiiro.init(*parent_hiiro.args, mgr: mgr) do |h|
759
+ h.add_subcmd(:list) { mgr.list }
760
+ h.add_subcmd(:ls) { mgr.list }
761
+
762
+ h.add_subcmd(:start) do |task_name, app_name=nil|
763
+ mgr.start_task(task_name, app_name: app_name)
764
+ end
765
+
766
+ h.add_subcmd(:switch) do |task_name=nil, app_name=nil|
767
+ if task_name.nil?
768
+ task_name = mgr.select_task_interactive
769
+ return unless task_name
770
+ end
771
+ task = mgr.task_by_name(task_name)
772
+ mgr.switch_to_task(task, app_name: app_name)
773
+ end
774
+
775
+ h.add_subcmd(:app) do |app_name=nil|
776
+ if app_name.nil?
777
+ names = mgr.environment.all_apps.map(&:name)
778
+ app_name = mgr.send(:sk_select, names)
779
+ return unless app_name
780
+ end
781
+ mgr.open_app(app_name)
782
+ end
783
+
784
+ h.add_subcmd(:apps) { mgr.list_apps }
785
+
786
+ h.add_subcmd(:cd) do |app_name=nil|
787
+ mgr.cd_to_app(app_name)
788
+ end
789
+
790
+ h.add_subcmd(:path) do |app_name=nil|
791
+ mgr.app_path(app_name)
792
+ end
793
+
794
+ h.add_subcmd(:status) { mgr.status }
795
+ h.add_subcmd(:st) { mgr.status }
796
+
797
+ h.add_subcmd(:save) { mgr.save }
798
+
799
+ h.add_subcmd(:stop) do |task_name=nil|
800
+ if task_name.nil?
801
+ task_name = mgr.select_task_interactive
802
+ return unless task_name
803
+ end
804
+ task = mgr.task_by_name(task_name)
805
+ mgr.stop_task(task)
806
+ end
807
+
808
+ h.add_subcmd(:edit) do
809
+ system(ENV['EDITOR'] || 'nvim', __FILE__)
810
+ end
811
+ end
812
+ end
813
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.31
4
+ version: 0.1.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-31 00:00:00.000000000 Z
11
+ date: 2026-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pry
@@ -38,6 +38,7 @@ files:
38
38
  - LICENSE
39
39
  - README.md
40
40
  - Rakefile
41
+ - TODO.md
41
42
  - bin/g-pr
42
43
  - bin/h
43
44
  - bin/h-branch
@@ -49,6 +50,8 @@ files:
49
50
  - bin/h-link
50
51
  - bin/h-mic
51
52
  - bin/h-note
53
+ - bin/h-osubtask
54
+ - bin/h-otask
52
55
  - bin/h-pane
53
56
  - bin/h-plugin
54
57
  - bin/h-pr
@@ -59,8 +62,6 @@ files:
59
62
  - bin/h-serve
60
63
  - bin/h-session
61
64
  - bin/h-sha
62
- - bin/h-subtask
63
- - bin/h-task
64
65
  - bin/h-video
65
66
  - bin/h-vim
66
67
  - bin/h-window
@@ -78,10 +79,12 @@ files:
78
79
  - lib/hiiro/history.rb
79
80
  - lib/hiiro/version.rb
80
81
  - links.backup.yml
82
+ - notes
81
83
  - plugins/notify.rb
84
+ - plugins/old_task.rb
82
85
  - plugins/pins.rb
83
86
  - plugins/project.rb
84
- - plugins/task.rb
87
+ - plugins/tasks.rb
85
88
  - plugins/tmux.rb
86
89
  - script/compare
87
90
  - script/install
File without changes