hiiro 0.1.71 → 0.1.72

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: ff87ba519316c2de7ddfada0355b3961219bafc7d1f8c6bf9963f2a63d22783d
4
- data.tar.gz: 47b52e2550d78babe9227c5ff729fbbca7fbbd87e44b6fcb25494d1dd136fb50
3
+ metadata.gz: 5f96d3544bba42161e843e3e9478eb09a72b0cf32b2ec0736ef9a3e074d247cc
4
+ data.tar.gz: 8cb67c7ae3a7de600bb43b75b8399fc998359a7ac869241485a582561d8225a5
5
5
  SHA512:
6
- metadata.gz: 2be2cbd5456bf9d88100ec1568f7a254090f55ce5bdd0dd11692b95fb65f0c88525899fb0bc2d53f5a50dfa666413f41bdf722375d3951a1b77c44f1509dc164
7
- data.tar.gz: 3085116536819a63eefc3b6cc5a28a990d4ef2ce160f1a25ee5738a8035ca41ee154cd0e80a9eeb3018e67e4e3f5c308628076fc3c8c3dc88252137afca242bb
6
+ metadata.gz: 3397f01c85baa33ad040dff94823fec9ed78475aafe2b18a008df3aea3766c2f55ef622988b2b9a4b8b0e34ed0679015bafecf76b295f744d72449c127680844
7
+ data.tar.gz: 8c9fd7fe66febe8af0eca35c980fbf550ec2fd1461d845be4d0f481825fb77aa0e22075753db294f0d5de79a3f3f23cef3da7859d0fe1d4a983d77db7d0f4018
data/CLAUDE.md CHANGED
@@ -78,11 +78,164 @@ hiiro.add_subcmd(:pwd) do |*args, **values|
78
78
  end
79
79
  ```
80
80
 
81
+ ## Creating Subcommands
82
+
83
+ There are two ways to add subcommands to Hiiro:
84
+
85
+ ### 1. External Bin File (Preferred)
86
+
87
+ Create a new executable `bin/h-<name>` that uses `Hiiro.run`:
88
+
89
+ ```ruby
90
+ #!/usr/bin/env ruby
91
+ require 'hiiro'
92
+
93
+ Hiiro.run(*ARGV, plugins: [:Tasks]) {
94
+ add_subcmd(:list) {
95
+ puts "Listing items..."
96
+ }
97
+
98
+ add_subcmd(:add) { |name, path=nil|
99
+ puts "Adding #{name} at #{path}"
100
+ }
101
+
102
+ add_subcmd(:remove) { |name=nil|
103
+ if name.nil?
104
+ puts "Usage: h mycommand remove <name>"
105
+ next
106
+ end
107
+ puts "Removed #{name}"
108
+ }
109
+ }
110
+ ```
111
+
112
+ This creates commands like `h mycommand list`, `h mycommand add foo /path`.
113
+
114
+ ### 2. Nested Subcommands via build_hiiro
115
+
116
+ For complex command hierarchies with shared state, create a nested Hiiro instance:
117
+
118
+ ```ruby
119
+ def self.add_subcommands(hiiro)
120
+ hiiro.add_subcmd(:task) do |*args|
121
+ tm = TaskManager.new(hiiro, scope: :task)
122
+ build_hiiro(hiiro, tm).run
123
+ end
124
+ end
125
+
126
+ def self.build_hiiro(parent_hiiro, tm)
127
+ bin_name = [parent_hiiro.bin, parent_hiiro.subcmd || ''].join('-')
128
+
129
+ Hiiro.init(bin_name:, args: parent_hiiro.args) do |h|
130
+ h.add_subcmd(:list) { tm.list }
131
+ h.add_subcmd(:start) { |name| tm.start_task(name) }
132
+ h.add_subcmd(:switch) { |name=nil|
133
+ name ||= tm.select_task_interactive
134
+ tm.switch_to_task(tm.task_by_name(name))
135
+ }
136
+ end
137
+ end
138
+ ```
139
+
140
+ This pattern:
141
+ - Passes remaining args from parent to child via `args: parent_hiiro.args`
142
+ - Allows shared state (like `tm`) across all nested subcommands
143
+ - Creates commands like `h task list`, `h task start foo`
144
+
145
+ ## Library Components (lib/)
146
+
147
+ The `lib/hiiro.rb` file and `lib/hiiro/` directory contain the core framework classes:
148
+
149
+ ### Hiiro (lib/hiiro.rb)
150
+
151
+ Main entry point with class methods:
152
+ - `Hiiro.run(*ARGV, plugins: [...]) { ... }` - Initialize and run immediately
153
+ - `Hiiro.init(*ARGV, plugins: [...]) { ... }` - Initialize without running (returns hiiro instance)
154
+
155
+ Instance methods available in subcommand blocks:
156
+ - `git` - Returns `Hiiro::Git` instance for git operations
157
+ - `sk(lines)` - Interactive selection via skim
158
+ - `pins` - Key-value storage per command
159
+ - `todo_manager` - Todo item management
160
+ - `history` - Command history tracking
161
+ - `attach_method(name, &block)` - Add methods to hiiro instance dynamically
162
+ - `make_child(subcmd, *args)` - Create nested Hiiro for sub-subcommands
163
+
164
+ ### Hiiro::PrefixMatcher (lib/hiiro/prefix_matcher.rb)
165
+
166
+ Handles abbreviation matching for commands and items:
167
+
168
+ ```ruby
169
+ matcher = Hiiro::PrefixMatcher.new(items, :name)
170
+ result = matcher.find("pre") # Find items where name starts with "pre"
171
+ result.match? # Any matches?
172
+ result.one? # Exactly one match?
173
+ result.ambiguous? # Multiple matches?
174
+ result.first&.item # Get first matching item
175
+ result.resolved&.item # Get exact or single match
176
+
177
+ # Path-based matching for hierarchical names (e.g., "task/subtask")
178
+ result = matcher.resolve_path("t/s")
179
+ ```
180
+
181
+ ### Hiiro::Git (lib/hiiro/git.rb)
182
+
183
+ Git operations wrapper with submodules for branches, worktrees, remotes, and PRs:
184
+
185
+ ```ruby
186
+ git = hiiro.git
187
+ git.root # Repository root path
188
+ git.branch # Current branch name
189
+ git.branches # List all branches
190
+ git.worktrees # List all worktrees
191
+ git.add_worktree(path, branch:) # Create worktree
192
+ git.move_worktree(from, to) # Rename worktree
193
+ git.current_pr # Get current PR info
194
+ ```
195
+
196
+ ### Hiiro::Sk (lib/hiiro/sk.rb)
197
+
198
+ Integration with `sk` (skim) fuzzy finder:
199
+
200
+ ```ruby
201
+ selected = Hiiro::Sk.select(["option1", "option2", "option3"])
202
+ # Returns selected string or nil if cancelled
203
+
204
+ value = Hiiro::Sk.map_select({ "Display 1" => "value1", "Display 2" => "value2" })
205
+ # Shows keys, returns corresponding value
206
+ ```
207
+
208
+ ### Hiiro::TodoManager (lib/hiiro/todo.rb)
209
+
210
+ Todo item management with task association:
211
+
212
+ ```ruby
213
+ tm = Hiiro::TodoManager.new
214
+ tm.add("Fix bug", tags: "urgent", task_info: { task_name: "feature" })
215
+ tm.start(0) # Mark item as started
216
+ tm.done(0) # Mark item as done
217
+ tm.active # Items not done/skipped
218
+ tm.filter_by_task("feature") # Items for specific task
219
+ ```
220
+
221
+ ### Hiiro::History (lib/hiiro/history.rb)
222
+
223
+ Command history tracking with tmux/git/task context:
224
+
225
+ ```ruby
226
+ History.track(cmd: full_command, hiiro: self) # Auto-called after commands
227
+ History.by_task("feature") # Filter by task
228
+ History.by_branch("main") # Filter by git branch
229
+ History.by_session("work") # Filter by tmux session
230
+ ```
231
+
81
232
  ## Key Files
82
233
 
83
234
  - `bin/h` - Core framework (~420 lines)
84
235
  - `bin/h-*` - External subcommands (tmux wrappers, video operations)
85
236
  - `plugins/*.rb` - Reusable plugin modules (Pins, Project, Task, Tmux, Notify)
237
+ - `lib/hiiro.rb` - Main Hiiro class and Runners
238
+ - `lib/hiiro/*.rb` - Supporting classes (Git, PrefixMatcher, Sk, Todo, History)
86
239
 
87
240
  ## External Dependencies
88
241
 
data/bin/h-branch CHANGED
@@ -241,8 +241,8 @@ checkout_branch = lambda { |branch = nil, *args|
241
241
  system('git', 'checkout', branch, *args)
242
242
  }
243
243
 
244
- hiiro.add_subcmd(:checkout, checkout_branch)
245
- hiiro.add_subcmd(:co, checkout_branch)
244
+ hiiro.add_subcmd(:checkout, &checkout_branch)
245
+ hiiro.add_subcmd(:co, &checkout_branch)
246
246
 
247
247
  remove_branch = lambda { |branch = nil, *args|
248
248
  unless branch
@@ -275,14 +275,35 @@ hiiro.add_subcmd(:duplicate) { |new_name = nil, source = nil|
275
275
  puts "Created branch '#{new_name}' from '#{source}'"
276
276
  }
277
277
 
278
+ hiiro.add_subcmd(:test) { |*args|
279
+ opts = Hiiro::Options.parse(args) do
280
+ option :remote, short: 'r', default: 'origin', desc: 'Remote name'
281
+ option :from, short: 'f', desc: 'Local branch or commit to push'
282
+ option :to, short: 't', desc: 'Remote branch name'
283
+ flag :force, short: 'F', desc: 'Force push'
284
+ flag :set_upstream, short: 'u', desc: 'Set upstream tracking'
285
+ end
286
+
287
+ {
288
+ remote_as_method: opts.remote,
289
+ remote_as_sym_key: opts[:remote],
290
+ remote_as_string_key: opts['remote'],
291
+ force_as_method: opts.force,
292
+ force_as_sym_key: opts[:force],
293
+ force_as_string_key: opts['force'],
294
+ }.each do |k,v|
295
+ puts "#{k} => #{v}"
296
+ end
297
+ }
298
+
278
299
  hiiro.add_subcmd(:push) { |*args|
279
- opts = Hiiro::Options.new(args) do
300
+ opts = Hiiro::Options.parse(args) do
280
301
  option :remote, short: 'r', default: 'origin', desc: 'Remote name'
281
302
  option :from, short: 'f', desc: 'Local branch or commit to push'
282
303
  option :to, short: 't', desc: 'Remote branch name'
283
304
  flag :force, short: 'F', desc: 'Force push'
284
305
  flag :set_upstream, short: 'u', desc: 'Set upstream tracking'
285
- end.parse!
306
+ end
286
307
 
287
308
  local_ref = opts[:from] || hiiro.git.branch
288
309
  remote_branch = opts[:to] || local_ref
data/lib/hiiro/options.rb CHANGED
@@ -1,167 +1,192 @@
1
1
  class Hiiro
2
2
  class Options
3
- class Definition
4
- attr_reader :name, :short, :type, :default, :desc, :multi
5
-
6
- def initialize(name, short: nil, type: :string, default: nil, desc: nil, multi: false)
7
- @name = name.to_sym
8
- @short = short&.to_s
9
- @type = type
10
- @default = default
11
- @desc = desc
12
- @multi = multi
13
- end
14
-
15
- def flag?
16
- type == :flag
17
- end
18
-
19
- def long_form
20
- "--#{name.to_s.tr('_', '-')}"
21
- end
22
-
23
- def short_form
24
- short ? "-#{short}" : nil
25
- end
26
-
27
- def match?(arg)
28
- arg == long_form || arg == short_form
29
- end
3
+ attr_reader :definitions
30
4
 
31
- def coerce(value)
32
- case type
33
- when :integer then value.to_i
34
- when :float then value.to_f
35
- else value
36
- end
37
- end
5
+ def self.parse(args, &block)
6
+ new(&block).parse!(args)
38
7
  end
39
8
 
40
- attr_reader :definitions, :values, :remaining_args
41
- alias args remaining_args
9
+ def self.setup(&block)
10
+ new(&block)
11
+ end
42
12
 
43
- def initialize(raw_args = [], &block)
44
- @raw_args = raw_args.dup
13
+ def initialize(&block)
45
14
  @definitions = {}
46
- @values = {}
47
- @remaining_args = []
48
- @parsed = false
49
-
50
15
  instance_eval(&block) if block
51
- parse! if block
52
16
  end
53
17
 
54
- # DSL: define a toggle flag (default false, presence inverts)
55
18
  def flag(name, short: nil, default: false, desc: nil)
56
19
  defn = Definition.new(name, short: short, type: :flag, default: default, desc: desc)
57
20
  @definitions[name.to_sym] = defn
58
21
  self
59
22
  end
60
23
 
61
- # DSL: define an option that takes a value
62
24
  def option(name, short: nil, type: :string, default: nil, desc: nil, multi: false)
63
25
  defn = Definition.new(name, short: short, type: type, default: default, desc: desc, multi: multi)
64
26
  @definitions[name.to_sym] = defn
65
27
  self
66
28
  end
67
29
 
68
- def parse!
69
- return self if @parsed
30
+ def parse(args)
31
+ Args.new(@definitions, args)
32
+ end
70
33
 
71
- @definitions.each do |name, defn|
72
- @values[name] = defn.multi ? [] : defn.default
73
- end
34
+ def parse!(args)
35
+ parse(args)
36
+ end
74
37
 
75
- args = @raw_args.dup
76
- while args.any?
77
- arg = args.shift
38
+ class Args
39
+ attr_reader :remaining_args, :original_args
40
+ alias args remaining_args
78
41
 
79
- if arg == '--'
80
- @remaining_args.concat(args)
81
- break
82
- elsif arg.start_with?('--')
83
- parse_long_option(arg, args)
84
- elsif arg.start_with?('-') && arg.length > 1
85
- parse_short_options(arg, args)
86
- else
87
- @remaining_args << arg
88
- end
42
+ def initialize(definitions, raw_args)
43
+ @definitions = definitions
44
+ @original_args = raw_args.dup.freeze
45
+ @values = {}
46
+ @remaining_args = []
47
+ do_parse(raw_args.dup)
89
48
  end
90
49
 
91
- @parsed = true
92
- self
93
- end
94
-
95
- def [](name)
96
- @values[name.to_sym]
97
- end
50
+ def [](name)
51
+ @values[name.to_sym]
52
+ end
98
53
 
99
- def method_missing(name, *args, &block)
100
- name_str = name.to_s
101
- if name_str.end_with?('?')
102
- key = name_str.chomp('?').to_sym
103
- return !!@values[key] if @definitions.key?(key)
104
- else
105
- return @values[name] if @definitions.key?(name)
54
+ def fetch(name, default = nil)
55
+ key = name.to_sym
56
+ return @values[key] if @values.key?(key)
57
+ return yield if block_given?
58
+ default
106
59
  end
107
- super
108
- end
109
60
 
110
- def respond_to_missing?(name, include_private = false)
111
- key = name.to_s.chomp('?').to_sym
112
- @definitions.key?(key) || super
113
- end
61
+ def uses_option?(name)
62
+ key = name.to_sym
63
+ return false unless @definitions.key?(key)
64
+ @values[key] != @definitions[key].default
65
+ end
114
66
 
115
- def to_h
116
- @values.dup
117
- end
67
+ def to_h
68
+ @values.dup
69
+ end
118
70
 
119
- private
71
+ def method_missing(name, *args, &block)
72
+ name_str = name.to_s
73
+ if name_str.end_with?('?')
74
+ key = name_str.chomp('?').to_sym
75
+ return !!@values[key] if @definitions.key?(key)
76
+ else
77
+ return @values[name] if @definitions.key?(name)
78
+ end
79
+ super
80
+ end
120
81
 
121
- def parse_long_option(arg, args)
122
- if arg.include?('=')
123
- opt, value = arg.split('=', 2)
124
- name = opt.sub(/^--/, '').tr('-', '_').to_sym
125
- else
126
- name = arg.sub(/^--/, '').tr('-', '_').to_sym
127
- value = nil
82
+ def respond_to_missing?(name, include_private = false)
83
+ key = name.to_s.chomp('?').to_sym
84
+ @definitions.key?(key) || super
128
85
  end
129
86
 
130
- defn = @definitions[name]
131
- return unless defn
87
+ def do_parse(args)
88
+ @definitions.each do |name, defn|
89
+ @values[name] = defn.multi ? [] : defn.default
90
+ end
132
91
 
133
- if defn.flag?
134
- @values[name] = !defn.default
135
- else
136
- value ||= args.shift
137
- store_value(defn, value)
92
+ while args.any?
93
+ arg = args.shift
94
+
95
+ if arg == '--'
96
+ @remaining_args.concat(args)
97
+ break
98
+ elsif arg.start_with?('--')
99
+ parse_long_option(arg, args)
100
+ elsif arg.start_with?('-') && arg.length > 1
101
+ parse_short_options(arg, args)
102
+ else
103
+ @remaining_args << arg
104
+ end
105
+ end
138
106
  end
139
- end
140
107
 
141
- def parse_short_options(arg, args)
142
- chars = arg.sub(/^-/, '').chars
108
+ def parse_long_option(arg, args)
109
+ if arg.include?('=')
110
+ opt, value = arg.split('=', 2)
111
+ name = opt.sub(/^--/, '').tr('-', '_').to_sym
112
+ else
113
+ name = arg.sub(/^--/, '').tr('-', '_').to_sym
114
+ value = nil
115
+ end
143
116
 
144
- chars.each_with_index do |char, idx|
145
- defn = @definitions.values.find { |d| d.short == char }
146
- next unless defn
117
+ defn = @definitions[name]
118
+ return unless defn
147
119
 
148
120
  if defn.flag?
149
- @values[defn.name] = !defn.default
150
- elsif idx == chars.length - 1
151
- store_value(defn, args.shift)
121
+ @values[name] = !defn.default
152
122
  else
153
- store_value(defn, chars[(idx + 1)..].join)
154
- break
123
+ value ||= args.shift
124
+ store_value(defn, value)
125
+ end
126
+ end
127
+
128
+ def parse_short_options(arg, args)
129
+ chars = arg.sub(/^-/, '').chars
130
+
131
+ chars.each_with_index do |char, idx|
132
+ defn = @definitions.values.find { |d| d.short == char }
133
+ next unless defn
134
+
135
+ if defn.flag?
136
+ @values[defn.name] = !defn.default
137
+ elsif idx == chars.length - 1
138
+ store_value(defn, args.shift)
139
+ else
140
+ store_value(defn, chars[(idx + 1)..].join)
141
+ break
142
+ end
143
+ end
144
+ end
145
+
146
+ def store_value(defn, value)
147
+ coerced = defn.coerce(value)
148
+ if defn.multi
149
+ @values[defn.name] << coerced
150
+ else
151
+ @values[defn.name] = coerced
155
152
  end
156
153
  end
157
154
  end
158
155
 
159
- def store_value(defn, value)
160
- coerced = defn.coerce(value)
161
- if defn.multi
162
- @values[defn.name] << coerced
163
- else
164
- @values[defn.name] = coerced
156
+ class Definition
157
+ attr_reader :name, :short, :type, :default, :desc, :multi
158
+
159
+ def initialize(name, short: nil, type: :string, default: nil, desc: nil, multi: false)
160
+ @name = name.to_sym
161
+ @short = short&.to_s
162
+ @type = type
163
+ @default = default
164
+ @desc = desc
165
+ @multi = multi
166
+ end
167
+
168
+ def flag?
169
+ type == :flag
170
+ end
171
+
172
+ def long_form
173
+ "--#{name.to_s.tr('_', '-')}"
174
+ end
175
+
176
+ def short_form
177
+ short ? "-#{short}" : nil
178
+ end
179
+
180
+ def match?(arg)
181
+ arg == long_form || arg == short_form
182
+ end
183
+
184
+ def coerce(value)
185
+ case type
186
+ when :integer then value.to_i
187
+ when :float then value.to_f
188
+ else value
189
+ end
165
190
  end
166
191
  end
167
192
  end
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.71"
2
+ VERSION = "0.1.72"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -144,8 +144,10 @@ class Hiiro
144
144
  runners.add_default(handler, **global_values, **values)
145
145
  end
146
146
 
147
- def add_subcommand(name, **values, &handler)
148
- runners.add_subcommand(name, handler, **global_values, **values)
147
+ def add_subcommand(*names, **values, &handler)
148
+ names.each do |name|
149
+ runners.add_subcommand(name, handler, **global_values, **values)
150
+ end
149
151
  end
150
152
  alias add_subcmd add_subcommand
151
153
 
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.71
4
+ version: 0.1.72
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-11 00:00:00.000000000 Z
11
+ date: 2026-02-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pry