hiiro 0.1.55 → 0.1.57

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: 8ef7a585109b14b6a8de8f7036b8d0099c1abbee33569c55436dcb51ac4c1905
4
- data.tar.gz: 0f20d4397f16fd0b4f67a3ee7485d9ee8583a5e96d7b2352c6bbcd9fb0dc1329
3
+ metadata.gz: 7f55785ca9b54838e9b0e0eefe8e1e829f85ad1b1d9a1ed6b961305b847f1828
4
+ data.tar.gz: cda5ec5bdc86b4210e4870d88bca437c9cc7dbac3857ae3e264a66ba0b054675
5
5
  SHA512:
6
- metadata.gz: 8b6579c8c352aeafa41d6e066956ff56a29c17acd41fca0825f6ac35555cca1db9a726e83e150f28f2c938987e7fd1d35dc424e817c1cc0b1f1f6d078858ab93
7
- data.tar.gz: 958d0c9c18f69e90578941f90457865eec33492b5bf83594c3fd8dd5d0d4e2cb30aced8e6535246729043fe3248e2f242731b63d08dacf8168133afe58975dc3
6
+ metadata.gz: 8d8e719178da0355245154216264edb4c292fb5ccc62dc561e8529bf7b67f4a94d85a5db3516e1406f5cf7464f61454dc3c07f7dc26530a44a895577b7082c12
7
+ data.tar.gz: 28a570aba442cb90419feaf3edd4ef4135773fc2f971401df942bb09617f17c7466c3ba32dbfc2edd1849895d973cdada07f80b8edd2932685e8f21bc00f29d4
data/Rakefile CHANGED
@@ -1,3 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rake/testtask"
2
3
 
3
- task default: :build
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :test
data/bin/h-app CHANGED
@@ -62,23 +62,21 @@ Hiiro.run(*ARGV, plugins: [:Tasks]) {
62
62
  next
63
63
  end
64
64
 
65
- apps = load_apps
66
- match = apps.find { |name, _| name.start_with?(app_name) }
65
+ app = environment.app_matcher.find(app_name)
67
66
 
68
- if match
69
- name, app_relative_path = match
70
- target = File.join(root, app_relative_path)
67
+ if app
68
+ target = File.join(root, app.relative_path)
71
69
  send_cd(relative_cd_path(target))
72
70
  else
73
71
  puts "App '#{app_name}' not found"
74
72
  puts
75
73
  puts "Available apps:"
76
- apps.each { |name, path| puts format(" %-20s => %s", name, path) }
74
+ environment.all_apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
77
75
  end
78
76
  }
79
77
 
80
78
  add_subcmd(:ls) {
81
- apps = load_apps
79
+ apps = environment.all_apps
82
80
 
83
81
  if apps.empty?
84
82
  puts "No apps configured."
@@ -87,7 +85,7 @@ Hiiro.run(*ARGV, plugins: [:Tasks]) {
87
85
  else
88
86
  puts "Configured apps:"
89
87
  puts
90
- apps.each { |name, path| puts format(" %-20s => %s", name, path) }
88
+ apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
91
89
  end
92
90
  }
93
91
 
@@ -104,12 +102,10 @@ Hiiro.run(*ARGV, plugins: [:Tasks]) {
104
102
  next
105
103
  end
106
104
 
107
- apps = load_apps
108
- match = apps.find { |name, _| name.start_with?(app_name) }
105
+ app = environment.app_matcher.find(app_name)
109
106
 
110
- if match
111
- name, app_relative_path = match
112
- target = File.join(root, app_relative_path)
107
+ if app
108
+ target = File.join(root, app.relative_path)
113
109
  puts relative_cd_path(target)
114
110
  else
115
111
  puts "App '#{app_name}' not found"
@@ -129,12 +125,10 @@ Hiiro.run(*ARGV, plugins: [:Tasks]) {
129
125
  next
130
126
  end
131
127
 
132
- apps = load_apps
133
- match = apps.find { |name, _| name.start_with?(app_name) }
128
+ app = environment.app_matcher.find(app_name)
134
129
 
135
- if match
136
- name, app_relative_path = match
137
- puts File.join(root, app_relative_path)
130
+ if app
131
+ puts File.join(root, app.relative_path)
138
132
  else
139
133
  puts "App '#{app_name}' not found"
140
134
  end
data/bin/h-plugin CHANGED
@@ -23,7 +23,8 @@ o.add_subcmd(:edit) { |*args|
23
23
  if args.none?
24
24
  system(ENV['EDITOR'] || 'safe_nvim', __FILE__)
25
25
  else
26
- plugins = plugin_files.select{|f| args.any?{|arg| File.basename(f).start_with?(arg) } }
26
+ pm = Hiiro::PrefixMatcher.new(plugin_files) { |f| File.basename(f) }
27
+ plugins = args.flat_map { |arg| pm.find_all(arg).matches.map(&:item) }.uniq
27
28
 
28
29
  if plugins.none?
29
30
  puts "No matching plugins found for: #{args.map(&:inspect).join(' ')}"
data/exe/h CHANGED
@@ -2,52 +2,55 @@
2
2
 
3
3
  require "hiiro"
4
4
  require "fileutils"
5
-
6
- hiiro = Hiiro.init(*ARGV, cwd: Dir.pwd, plugins: [Tasks])
7
-
8
- hiiro.add_subcommand(:version) { |*args|
9
- puts Hiiro::VERSION
10
- }
11
-
12
- hiiro.add_subcommand(:ping) { |*args|
13
- puts "pong"
14
- }
15
-
16
- hiiro.add_subcommand(:setup) do |*args|
17
- gem_root = File.expand_path("../..", __FILE__)
18
- source_plugins = File.join(gem_root, "plugins")
19
- source_bins = File.join(gem_root, "bin")
20
- dest_plugins = File.expand_path("~/.config/hiiro/plugins")
21
- dest_bin = File.expand_path("~/bin")
22
-
23
- FileUtils.mkdir_p(dest_plugins)
24
- FileUtils.mkdir_p(dest_bin)
25
-
26
- # Copy plugins
27
- plugin_files = Dir["#{source_plugins}/*.rb"]
28
- if plugin_files.any?
29
- FileUtils.cp(plugin_files, dest_plugins)
30
- puts "Installed #{plugin_files.size} plugins to #{dest_plugins}"
31
- plugin_files.each { |f| puts " - #{File.basename(f)}" }
32
- else
33
- puts "No plugins found in #{source_plugins}"
34
- end
35
-
36
- puts
37
-
38
- # Copy bin scripts
39
- bin_files = Dir["#{source_bins}/h-*"]
40
- if bin_files.any?
41
- FileUtils.cp(bin_files, dest_bin)
42
- bin_files.each { |f| FileUtils.chmod(0755, File.join(dest_bin, File.basename(f))) }
43
- puts "Installed #{bin_files.size} subcommands to #{dest_bin}"
44
- bin_files.each { |f| puts " - #{File.basename(f)}" }
45
- else
46
- puts "No subcommands found in #{source_bins}"
5
+ require "pry"
6
+
7
+ Hiiro.run(*ARGV, cwd: Dir.pwd, plugins: [Tasks]) do
8
+ add_subcommand(:version) { |*args|
9
+ puts Hiiro::VERSION
10
+ }
11
+
12
+ add_subcommand(:pry) { |*args|
13
+ binding.pry
14
+ }
15
+
16
+ add_subcommand(:ping) { |*args|
17
+ puts "pong"
18
+ }
19
+
20
+ add_subcommand(:setup) do |*args|
21
+ gem_root = File.expand_path("../..", __FILE__)
22
+ source_plugins = File.join(gem_root, "plugins")
23
+ source_bins = File.join(gem_root, "bin")
24
+ dest_plugins = File.expand_path("~/.config/hiiro/plugins")
25
+ dest_bin = File.expand_path("~/bin")
26
+
27
+ FileUtils.mkdir_p(dest_plugins)
28
+ FileUtils.mkdir_p(dest_bin)
29
+
30
+ # Copy plugins
31
+ plugin_files = Dir["#{source_plugins}/*.rb"]
32
+ if plugin_files.any?
33
+ FileUtils.cp(plugin_files, dest_plugins)
34
+ puts "Installed #{plugin_files.size} plugins to #{dest_plugins}"
35
+ plugin_files.each { |f| puts " - #{File.basename(f)}" }
36
+ else
37
+ puts "No plugins found in #{source_plugins}"
38
+ end
39
+
40
+ puts
41
+
42
+ # Copy bin scripts
43
+ bin_files = Dir["#{source_bins}/h-*"]
44
+ if bin_files.any?
45
+ FileUtils.cp(bin_files, dest_bin)
46
+ bin_files.each { |f| FileUtils.chmod(0755, File.join(dest_bin, File.basename(f))) }
47
+ puts "Installed #{bin_files.size} subcommands to #{dest_bin}"
48
+ bin_files.each { |f| puts " - #{File.basename(f)}" }
49
+ else
50
+ puts "No subcommands found in #{source_bins}"
51
+ end
52
+
53
+ puts
54
+ puts "Setup complete! Make sure ~/bin is in your PATH."
47
55
  end
48
-
49
- puts
50
- puts "Setup complete! Make sure ~/bin is in your PATH."
51
56
  end
52
-
53
- hiiro.run
data/hiiro.gemspec CHANGED
@@ -27,4 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_dependency "pry", "~> 0.14"
30
+
31
+ spec.add_development_dependency "minitest", "~> 5.0"
32
+ spec.add_development_dependency "rake", "~> 13.0"
30
33
  end
@@ -0,0 +1,241 @@
1
+ class Hiiro
2
+ class PrefixMatcher
3
+ class Item
4
+ attr_reader :item, :extracted_item, :key, :block
5
+
6
+ def initialize(item:, extracted_item:, key: nil, block: nil)
7
+ @item = item
8
+ @extracted_item = extracted_item
9
+ @key = key
10
+ @block = block
11
+ end
12
+ end
13
+
14
+ class Result
15
+ attr_reader :matcher, :all_items, :key, :block, :prefix
16
+
17
+ def initialize(matcher:, all_items:, prefix:, key: nil, block: nil)
18
+ @matcher = matcher
19
+ @all_items = all_items
20
+ @prefix = prefix
21
+ @key = key
22
+ @block = block
23
+ end
24
+
25
+ def matches
26
+ @matches ||= all_items.select { |item| item.extracted_item.to_s.start_with?(prefix.to_s) }
27
+ end
28
+
29
+ def count
30
+ matches.count
31
+ end
32
+
33
+ def ambiguous?
34
+ count > 1
35
+ end
36
+
37
+ def exact_match
38
+ all_items.find { |item| item.extracted_item == prefix }
39
+ end
40
+
41
+ def match
42
+ one? ? matches.first : nil
43
+ end
44
+
45
+ def match?
46
+ matches.any?
47
+ end
48
+
49
+ def exact?
50
+ !exact_match.nil?
51
+ end
52
+
53
+ def one?
54
+ count == 1
55
+ end
56
+
57
+ # Returns item for resolve semantics: exact match, or single match, otherwise nil
58
+ def resolved
59
+ exact_match || match
60
+ end
61
+
62
+ # Returns the first matching item (for find semantics)
63
+ def first
64
+ matches.first
65
+ end
66
+ end
67
+
68
+ class PathResult
69
+ attr_reader :matcher, :all_items, :key, :block, :prefix
70
+
71
+ def initialize(matcher:, all_items:, prefix:, key: nil, block: nil)
72
+ @matcher = matcher
73
+ @all_items = all_items
74
+ @prefix = prefix
75
+ @key = key
76
+ @block = block
77
+ end
78
+
79
+ def matches
80
+ @matches ||= begin
81
+ prefixes = prefix.to_s.split('/')
82
+
83
+ items_with_paths = all_items.map { |item|
84
+ [item, item.extracted_item.to_s.split('/')]
85
+ }
86
+
87
+ prefixes.each_with_index do |seg, i|
88
+ items_with_paths = items_with_paths.select { |_, path| path[i]&.start_with?(seg) }
89
+ end
90
+
91
+ items_with_paths.map(&:first)
92
+ end
93
+ end
94
+
95
+ def count
96
+ matches.count
97
+ end
98
+
99
+ def ambiguous?
100
+ count > 1
101
+ end
102
+
103
+ def exact_match
104
+ all_items.find { |item| item.extracted_item == prefix }
105
+ end
106
+
107
+ def match
108
+ one? ? matches.first : nil
109
+ end
110
+
111
+ def match?
112
+ matches.any?
113
+ end
114
+
115
+ def exact?
116
+ !exact_match.nil?
117
+ end
118
+
119
+ def one?
120
+ count == 1
121
+ end
122
+
123
+ # Returns item for resolve semantics: exact match, or single match, otherwise nil
124
+ def resolved
125
+ exact_match || match
126
+ end
127
+
128
+ # Returns the first matching item (for find semantics)
129
+ def first
130
+ matches.first
131
+ end
132
+ end
133
+
134
+ class << self
135
+ def find(items, prefix, key: nil, &block)
136
+ new(items, key, &block).find(prefix)
137
+ end
138
+
139
+ def find_all(items, prefix, key: nil, &block)
140
+ new(items, key, &block).find_all(prefix)
141
+ end
142
+
143
+ def resolve(items, prefix, key: nil, &block)
144
+ new(items, key, &block).resolve(prefix)
145
+ end
146
+
147
+ def find_path(items, prefix, key: nil, &block)
148
+ new(items, key, &block).find_path(prefix)
149
+ end
150
+
151
+ def find_all_paths(items, prefix, key: nil, &block)
152
+ new(items, key, &block).find_all_paths(prefix)
153
+ end
154
+
155
+ def resolve_path(items, prefix, key: nil, &block)
156
+ new(items, key, &block).resolve_path(prefix)
157
+ end
158
+ end
159
+
160
+ attr_reader :original_items, :key, :block
161
+
162
+ def initialize(items, key = nil, &block)
163
+ @original_items = items
164
+ @key = key
165
+ @block = block
166
+ end
167
+
168
+ def all_items(key = nil, &block)
169
+ use_key = key.nil? && !block_given? ? @key : key
170
+ use_block = key.nil? && !block_given? ? @block : block
171
+
172
+ @all_items_cache ||= {}
173
+ cache_key = [use_key, use_block].hash
174
+
175
+ @all_items_cache[cache_key] ||= original_items.map { |item|
176
+ Item.new(
177
+ item: item,
178
+ extracted_item: extract(item, use_key, &use_block),
179
+ key: use_key,
180
+ block: use_block
181
+ )
182
+ }
183
+ end
184
+
185
+ def search(prefix, key = nil, &block)
186
+ Result.new(
187
+ matcher: self,
188
+ all_items: all_items(key, &block),
189
+ prefix: prefix,
190
+ key: key || @key,
191
+ block: block || @block
192
+ )
193
+ end
194
+
195
+ def find(prefix, key = nil, &block)
196
+ search(prefix, key, &block)
197
+ end
198
+
199
+ def find_all(prefix, key = nil, &block)
200
+ search(prefix, key, &block)
201
+ end
202
+
203
+ def resolve(prefix, key = nil, &block)
204
+ search(prefix, key, &block)
205
+ end
206
+
207
+ def find_path(prefix, key = nil, &block)
208
+ search_path(prefix, key, &block)
209
+ end
210
+
211
+ def find_all_paths(prefix, key = nil, &block)
212
+ search_path(prefix, key, &block)
213
+ end
214
+
215
+ def resolve_path(prefix, key = nil, &block)
216
+ search_path(prefix, key, &block)
217
+ end
218
+
219
+ def search_path(prefix, key = nil, &block)
220
+ PathResult.new(
221
+ matcher: self,
222
+ all_items: all_items(key, &block),
223
+ prefix: prefix,
224
+ key: key || @key,
225
+ block: block || @block
226
+ )
227
+ end
228
+
229
+ private
230
+
231
+ def matches?(item, prefix)
232
+ item.to_s.start_with?(prefix.to_s)
233
+ end
234
+
235
+ def extract(item, key = nil, &block)
236
+ return block.call(item) if block
237
+ return item.send(key) if key
238
+ item
239
+ end
240
+ end
241
+ end
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.55"
2
+ VERSION = "0.1.57"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -3,6 +3,7 @@ require "yaml"
3
3
  require "shellwords"
4
4
 
5
5
  require_relative "hiiro/version"
6
+ require_relative "hiiro/prefix_matcher"
6
7
  require_relative "hiiro/git"
7
8
  require_relative "hiiro/history"
8
9
  require_relative "hiiro/options"
data/plugins/pins.rb CHANGED
@@ -53,27 +53,27 @@ module Pins
53
53
  value
54
54
  end
55
55
 
56
+ def search(partial)
57
+ Hiiro::PrefixMatcher.find(pins.keys.map(&:to_s), partial)
58
+ end
59
+
56
60
  def find(partial)
57
- pins.keys.map(&:to_s).find do |pin_name|
58
- pin_name.start_with?(partial)
59
- end
61
+ search(partial).match&.item
60
62
  end
61
63
 
62
64
  def find_all(partial)
63
- pins.keys.map(&:to_s).select do |pin_name|
64
- pin_name.start_with?(partial)
65
- end
65
+ search(partial).matches.map(&:item)
66
66
  end
67
67
 
68
68
  def remove(name)
69
- all_matches = find_all(name)
69
+ result = search(name)
70
70
 
71
- if all_matches.count > 1
72
- puts "Unable to remove pin. Multiple matches: #{all_matches.inspect}"
71
+ if result.ambiguous?
72
+ puts "Unable to remove pin. Multiple matches: #{result.matches.map(&:item).inspect}"
73
73
  return
74
74
  end
75
75
 
76
- pin_name = all_matches.first
76
+ pin_name = result.match&.item
77
77
 
78
78
  pins.delete(pin_name.to_s)
79
79
  end
data/plugins/tasks.rb CHANGED
@@ -75,6 +75,8 @@ class Tree
75
75
  end
76
76
 
77
77
  class Task
78
+ HASH_METHODS = %i[name parent_name short_name session_name tree_name top_level? subtask?]
79
+
78
80
  attr_reader :name, :tree_name, :session_name
79
81
 
80
82
  def initialize(name:, tree: nil, session: nil, **_)
@@ -109,10 +111,7 @@ class Task
109
111
  end
110
112
 
111
113
  def to_h
112
- h = { 'name' => name }
113
- h['tree'] = tree_name if tree_name
114
- h['session'] = session_name if session_name != name
115
- h
114
+ HASH_METHODS.zip(HASH_METHODS.map{|m| send(m) }).to_h
116
115
  end
117
116
  end
118
117
 
@@ -169,6 +168,22 @@ class Environment
169
168
  @all_apps ||= config.apps
170
169
  end
171
170
 
171
+ def tree_matcher
172
+ @tree_matcher ||= Hiiro::PrefixMatcher.new(all_trees, :name)
173
+ end
174
+
175
+ def session_matcher
176
+ @session_matcher ||= Hiiro::PrefixMatcher.new(all_sessions, :name)
177
+ end
178
+
179
+ def app_matcher
180
+ @app_matcher ||= Hiiro::PrefixMatcher.new(all_apps, :name)
181
+ end
182
+
183
+ def task_matcher
184
+ @task_matcher ||= Hiiro::PrefixMatcher.new(all_tasks, :name)
185
+ end
186
+
172
187
  def task
173
188
  @task ||= begin
174
189
  s = session
@@ -191,36 +206,36 @@ class Environment
191
206
  def find_task(abbreviated)
192
207
  return nil if abbreviated.nil?
193
208
 
209
+ # Try path-based matching first (handles "parent/child" patterns)
194
210
  if abbreviated.include?('/')
195
- parent_prefix, child_prefix = abbreviated.split('/', 2)
196
- parent = all_tasks.select(&:top_level?).find { |t| t.name.start_with?(parent_prefix) }
197
- return nil unless parent
198
-
199
- subtask = all_tasks.select { |t| t.parent_name == parent.name }.find { |t| t.short_name.start_with?(child_prefix) }
200
- return subtask if subtask
211
+ result = task_matcher.resolve_path(abbreviated)
212
+ return result.resolved&.item if result.match?
201
213
 
202
214
  # "main" refers to the parent task itself
203
- return parent if 'main'.start_with?(child_prefix)
215
+ parent_prefix, child_prefix = abbreviated.split('/', 2)
216
+ if 'main'.start_with?(child_prefix)
217
+ return task_matcher.find(parent_prefix).first&.item
218
+ end
204
219
 
205
220
  nil
206
221
  else
207
- all_tasks.find { |t| t.name.start_with?(abbreviated) }
222
+ task_matcher.find(abbreviated).first&.item
208
223
  end
209
224
  end
210
225
 
211
226
  def find_tree(abbreviated)
212
227
  return nil if abbreviated.nil?
213
- all_trees.find { |t| t.name.start_with?(abbreviated) }
228
+ tree_matcher.find(abbreviated).first&.item
214
229
  end
215
230
 
216
231
  def find_session(abbreviated)
217
232
  return nil if abbreviated.nil?
218
- all_sessions.find { |s| s.name.start_with?(abbreviated) }
233
+ session_matcher.find(abbreviated).first&.item
219
234
  end
220
235
 
221
236
  def find_app(abbreviated)
222
237
  return nil if abbreviated.nil?
223
- all_apps.find { |a| a.name.start_with?(abbreviated) }
238
+ app_matcher.find(abbreviated).first&.item
224
239
  end
225
240
  end
226
241
 
@@ -228,89 +243,6 @@ class TaskManager
228
243
  TASKS_DIR = File.join(Dir.home, '.config', 'hiiro', 'tasks')
229
244
  APPS_FILE = File.join(Dir.home, '.config', 'hiiro', 'apps.yml')
230
245
 
231
- class Config
232
- attr_reader :tasks_file, :apps_file
233
-
234
- def initialize(tasks_file: nil, apps_file: nil)
235
- @tasks_file = tasks_file || File.join(TASKS_DIR, 'tasks.yml')
236
- @apps_file = apps_file || APPS_FILE
237
- end
238
-
239
- def tasks
240
- data = load_tasks
241
- (data['tasks'] || []).map { |h| Task.new(**h.transform_keys(&:to_sym)) }
242
- end
243
-
244
- def apps
245
- return [] unless File.exist?(apps_file)
246
- data = YAML.safe_load_file(apps_file) || {}
247
- data.map { |name, path| App.new(name: name, path: path) }
248
- end
249
-
250
- def save_task(task)
251
- data = load_tasks
252
- data['tasks'] ||= []
253
- data['tasks'].reject! { |t| t['name'] == task.name }
254
- data['tasks'] << task.to_h
255
- save_tasks(data)
256
- end
257
-
258
- def remove_task(name)
259
- data = load_tasks
260
- data['tasks'] ||= []
261
- data['tasks'].reject! { |t| t['name'] == name }
262
- save_tasks(data)
263
- end
264
-
265
- private
266
-
267
- def load_tasks
268
- if File.exist?(tasks_file)
269
- return YAML.safe_load_file(tasks_file) || { 'tasks' => [] }
270
- end
271
-
272
- # Load from individual task_*.yml files
273
- task_files = Dir.glob(File.join(File.dirname(tasks_file), 'task_*.yml'))
274
- if task_files.any?
275
- tasks = task_files.map do |file|
276
- short_name = File.basename(file, '.yml').sub(/^task_/, '')
277
- data = YAML.safe_load_file(file) || {}
278
- # Support parent key for subtasks, or infer from tree path
279
- parent = data['parent']
280
- if parent.nil? && data['tree']&.include?('/')
281
- parent = data['tree'].split('/').first
282
- end
283
- name = parent ? "#{parent}/#{short_name}" : short_name
284
- h = { 'name' => name }
285
- h['tree'] = data['tree'] if data['tree']
286
- h['session'] = data['session'] if data['session']
287
- h
288
- end
289
- return { 'tasks' => tasks }
290
- end
291
-
292
- assignments_file = File.join(File.dirname(tasks_file), 'assignments.yml')
293
- if File.exist?(assignments_file)
294
- raw = YAML.safe_load_file(assignments_file) || {}
295
- tasks = raw.map do |tree_path, task_name|
296
- h = { 'name' => task_name, 'tree' => tree_path }
297
- h['session'] = task_name if task_name.include?('/')
298
- h
299
- end
300
- data = { 'tasks' => tasks }
301
- save_tasks(data)
302
- return data
303
- end
304
-
305
- { 'tasks' => [] }
306
- end
307
-
308
- def save_tasks(data)
309
- FileUtils.mkdir_p(File.dirname(tasks_file))
310
- File.write(tasks_file, YAML.dump(data))
311
- end
312
- end
313
-
314
246
  attr_reader :hiiro, :scope, :environment
315
247
 
316
248
  def initialize(hiiro, scope: :task, environment: nil)
@@ -344,18 +276,16 @@ class TaskManager
344
276
  def task_by_name(name)
345
277
  return slash_lookup(name) if name.include?('/')
346
278
 
347
- tasks.find { |t|
348
- match_name = (scope == :subtask) ? t.short_name : t.name
349
- match_name.start_with?(name)
350
- }
279
+ key = (scope == :subtask) ? :short_name : :name
280
+ Hiiro::PrefixMatcher.new(tasks, key).find(name).first&.item
351
281
  end
352
282
 
353
283
  def task_by_tree(tree_name)
354
- tasks.find { |t| t.tree_name == tree_name }
284
+ environment.task_matcher.resolve(tree_name, :tree_name).resolved&.item
355
285
  end
356
286
 
357
287
  def task_by_session(session_name)
358
- tasks.find { |t| t.session_name == session_name }
288
+ environment.task_matcher.resolve(session_name, :session_name).resolved&.item
359
289
  end
360
290
 
361
291
  def current_task
@@ -619,19 +549,19 @@ class TaskManager
619
549
  return
620
550
  end
621
551
 
622
- matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
552
+ result = environment.app_matcher.find_all(app_name)
623
553
 
624
- case matches.count
554
+ case result.count
625
555
  when 0
626
556
  puts "ERROR: No matches found"
627
557
  puts
628
558
  puts "Possible Apps:"
629
559
  environment.all_apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
630
560
  when 1
631
- print matches.first.resolve(tree_root)
561
+ print result.first.item.resolve(tree_root)
632
562
  else
633
563
  puts "Multiple matches found:"
634
- matches.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) }
564
+ result.matches.each { |m| puts format(" %-20s => %s", m.item.name, m.item.relative_path) }
635
565
  end
636
566
  end
637
567
 
@@ -693,9 +623,9 @@ class TaskManager
693
623
  tree = environment.find_tree(task.tree_name)
694
624
  tree_root = tree ? tree.path : File.join(WORK_DIR, task.tree_name)
695
625
 
696
- matches = environment.all_apps.select { |a| a.name.start_with?(app_name) }
626
+ result = environment.app_matcher.find_all(app_name)
697
627
 
698
- case matches.count
628
+ case result.count
699
629
  when 0
700
630
  # Fallback: directory discovery
701
631
  exact = File.join(tree_root, app_name)
@@ -708,15 +638,15 @@ class TaskManager
708
638
  list_apps
709
639
  nil
710
640
  when 1
711
- app = matches.first
641
+ app = result.first.item
712
642
  [app.name, app.resolve(tree_root)]
713
643
  else
714
- exact = matches.find { |a| a.name == app_name }
644
+ exact = result.matches.find { |m| m.item.name == app_name }
715
645
  if exact
716
- [exact.name, exact.resolve(tree_root)]
646
+ [exact.item.name, exact.item.resolve(tree_root)]
717
647
  else
718
648
  puts "ERROR: '#{app_name}' matches multiple apps:"
719
- matches.each { |a| puts " #{a.name}" }
649
+ result.matches.each { |m| puts " #{m.item.name}" }
720
650
  nil
721
651
  end
722
652
  end
@@ -742,14 +672,106 @@ class TaskManager
742
672
  def sk_select(items)
743
673
  Hiiro::Sk.select(items)
744
674
  end
675
+
676
+ class Config
677
+ attr_reader :tasks_file, :apps_file
678
+
679
+ def initialize(tasks_file: nil, apps_file: nil)
680
+ @tasks_file = tasks_file || File.join(TASKS_DIR, 'tasks.yml')
681
+ @apps_file = apps_file || APPS_FILE
682
+ end
683
+
684
+ def tasks
685
+ data = load_tasks
686
+ (data['tasks'] || []).map { |h| Task.new(**h.transform_keys(&:to_sym)) }
687
+ end
688
+
689
+ def apps
690
+ return [] unless File.exist?(apps_file)
691
+ data = YAML.safe_load_file(apps_file) || {}
692
+ data.map { |name, path| App.new(name: name, path: path) }
693
+ end
694
+
695
+ def save_task(task)
696
+ data = load_tasks
697
+ data['tasks'] ||= []
698
+ data['tasks'].reject! { |t| t['name'] == task.name }
699
+ data['tasks'] << task.to_h.transform_keys(&:to_s)
700
+ save_tasks(data)
701
+ end
702
+
703
+ def remove_task(name)
704
+ data = load_tasks
705
+ data['tasks'] ||= []
706
+ data['tasks'].reject! { |t| t['name'] == name }
707
+ save_tasks(data)
708
+ end
709
+
710
+ private
711
+
712
+ def load_tasks
713
+ if File.exist?(tasks_file)
714
+ return YAML.safe_load_file(tasks_file) || { 'tasks' => [] }
715
+ end
716
+
717
+ # Load from individual task_*.yml files
718
+ task_files = Dir.glob(File.join(File.dirname(tasks_file), 'task_*.yml'))
719
+ if task_files.any?
720
+ tasks = task_files.map do |file|
721
+ short_name = File.basename(file, '.yml').sub(/^task_/, '')
722
+ data = YAML.safe_load_file(file) || {}
723
+ # Support parent key for subtasks, or infer from tree path
724
+ parent = data['parent']
725
+ if parent.nil? && data['tree']&.include?('/')
726
+ parent = data['tree'].split('/').first
727
+ end
728
+ name = parent ? "#{parent}/#{short_name}" : short_name
729
+ h = { 'name' => name }
730
+ h['tree'] = data['tree'] if data['tree']
731
+ h['session'] = data['session'] if data['session']
732
+ h
733
+ end
734
+ return { 'tasks' => tasks }
735
+ end
736
+
737
+ assignments_file = File.join(File.dirname(tasks_file), 'assignments.yml')
738
+ if File.exist?(assignments_file)
739
+ raw = YAML.safe_load_file(assignments_file) || {}
740
+ tasks = raw.map do |tree_path, task_name|
741
+ h = { 'name' => task_name, 'tree' => tree_path }
742
+ h['session'] = task_name if task_name.include?('/')
743
+ h
744
+ end
745
+ data = { 'tasks' => tasks }
746
+ save_tasks(data)
747
+ return data
748
+ end
749
+
750
+ { 'tasks' => [] }
751
+ end
752
+
753
+ def save_tasks(data)
754
+ FileUtils.mkdir_p(File.dirname(tasks_file))
755
+ File.write(tasks_file, YAML.dump(data))
756
+ end
757
+ end
745
758
  end
746
759
 
747
760
  module Tasks
748
761
  def self.load(hiiro)
749
762
  hiiro.load_plugin(Tmux)
763
+ attach_methods(hiiro)
750
764
  add_subcommands(hiiro)
751
765
  end
752
766
 
767
+ def self.attach_methods(hiiro)
768
+ hiiro.instance_eval do
769
+ def environment
770
+ @environment ||= Environment.current
771
+ end
772
+ end
773
+ end
774
+
753
775
  def self.add_subcommands(hiiro)
754
776
  hiiro.add_subcmd(:task) do |*args|
755
777
  mgr = TaskManager.new(hiiro, scope: :task)
data/script/test ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+
3
+ bundle exec rake test "$@"
4
+
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.55
4
+ version: 0.1.57
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-02-07 00:00:00.000000000 Z
11
+ date: 2026-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pry
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.14'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
27
55
  description: Build multi-command CLI tools with subcommand dispatch, abbreviation
28
56
  matching, and a plugin system. Similar to git or docker command structure.
29
57
  email:
@@ -79,6 +107,7 @@ files:
79
107
  - lib/hiiro/history.rb
80
108
  - lib/hiiro/history/entry.rb
81
109
  - lib/hiiro/options.rb
110
+ - lib/hiiro/prefix_matcher.rb
82
111
  - lib/hiiro/sk.rb
83
112
  - lib/hiiro/version.rb
84
113
  - notes
@@ -91,6 +120,7 @@ files:
91
120
  - script/install
92
121
  - script/publish
93
122
  - script/sync
123
+ - script/test
94
124
  - script/update
95
125
  homepage: https://github.com/unixsuperhero/hiiro
96
126
  licenses: