hiiro 0.1.367 → 0.1.368

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.
@@ -4,39 +4,28 @@ require 'uri'
4
4
  require 'shellwords'
5
5
 
6
6
  class Hiiro
7
- # Task-first CLI behind the `t` and `tt` executables.
7
+ # Task CLI behind the `t`, `tt`, and `h task` executables.
8
8
  #
9
9
  # Hiiro.run(*ARGV, external_commands: false, builtin_commands: false) { Hiiro::TaskCli.setup(self) }
10
10
  #
11
- # Commands holds the helpers and the `add_cmd` declarations for each scope.
11
+ # Grammar: `t COMMAND [TASK] [ARGS...]`. Commands that act on a task read it from
12
+ # the first positional argument when that word names a task (exact or unique
13
+ # prefix); otherwise the current task is used and the word stays in the payload.
14
+ # `-t TASK` forces a task, `-f` picks one with a fuzzy finder, `.` is the current
15
+ # task, and `-` selects orphan todos in todo commands.
12
16
  class TaskCli
13
17
  def self.setup(hiiro)
14
18
  hiiro.extend(Commands)
15
- hiiro.add_default do |reference = nil, *command_args|
16
- if reference.nil? || %w[ls list].include?(reference)
17
- hiiro.no_args!(command_args)
18
- hiiro.list_tasks
19
- elsif reference == 'help'
20
- hiiro.no_args!(command_args)
21
- puts "Usage: t TASK [COMMAND ...]\n\nBare t, t ls, or t list lists tasks with open todo counts; t TASK shows one."
22
- puts "Use . for the current task, or - for unassigned todos."
23
- puts "Todo shortcut: tt TASK [COMMAND ...]\n\nTask commands:"
24
- hiiro.run_task_scope('TASK', ['help'])
25
- else
26
- hiiro.run_task_scope(reference, command_args)
27
- end
28
- end
19
+ hiiro.root_commands
29
20
  end
30
21
 
31
- # `tt [TASK] [COMMAND ...]` is `t TASK todo [COMMAND ...]` with `.` as the default task.
22
+ # `tt ARGS...` is `t todo ARGS...`.
32
23
  def self.setup_todo(hiiro)
33
24
  hiiro.extend(Commands)
34
- hiiro.add_default do |reference = nil, *todo_args|
35
- if reference == 'help'
36
- hiiro.no_args!(todo_args)
37
- hiiro.run_task_scope('.', %w[todo help])
38
- else
39
- hiiro.run_task_scope(reference || '.', ['todo', *todo_args])
25
+ hiiro.add_default do |*todo_args|
26
+ hiiro.run_child(:todo, todo_args, external_commands: false, builtin_commands: false) do
27
+ extend Commands
28
+ todo_commands
40
29
  end
41
30
  end
42
31
  end
@@ -44,8 +33,76 @@ class Hiiro
44
33
  module Commands
45
34
  class Error < Hiiro::Error; end
46
35
 
47
- def selected_task_scope
48
- resolve(:task_scope)
36
+ TASK_OPTIONS = %i[task find].freeze
37
+
38
+ def task_options
39
+ add_option :task, short: :t, desc: 'Task name (exact or unique prefix)'
40
+ add_flag :find, short: :f, desc: 'Choose the task with a fuzzy finder'
41
+ end
42
+
43
+ def task_names
44
+ Hiiro::TaskRecord.all_as_list.map(&:name)
45
+ end
46
+
47
+ # Exact name, else unique prefix; ambiguous prefixes fail; nil when nothing matches.
48
+ def lookup_task(reference)
49
+ result = Hiiro::Matcher.new(Hiiro::TaskRecord.all_as_list, :name).by_prefix(reference.to_s)
50
+ match = result.resolved
51
+ raise Error, "Ambiguous task #{reference}: #{result.matches.map { |m| m.item.name }.join(', ')}" if !match && result.ambiguous?
52
+ match&.item
53
+ end
54
+
55
+ def lookup_task!(reference)
56
+ lookup_task(reference) || raise(Error, "Task not found: #{reference}; run t new #{reference}")
57
+ end
58
+
59
+ def current_task
60
+ Hiiro::CurrentTask.new(herdr: -> { herdr_client }, pin: true).resolve!
61
+ end
62
+
63
+ # Old h task rule: [task, remaining_args]. The first positional is the task
64
+ # when it names one; otherwise the current task is used and args are untouched.
65
+ # `.` is the current task. With orphan: true, `-` yields a nil task (orphan todos).
66
+ # Passthrough commands pass options: nil and get -t/-f parsed from the raw args.
67
+ def take_task(args, orphan: false, options: opts)
68
+ args = args.dup
69
+ forced = options&.fetch(:task)
70
+ find = options&.fetch(:find)
71
+ if options.nil?
72
+ if %w[-t --task].include?(args.first) && args.length > 1
73
+ args.shift
74
+ forced = args.shift
75
+ elsif args.first.to_s.start_with?('--task=')
76
+ forced = args.shift.delete_prefix('--task=')
77
+ elsif %w[-f --find].include?(args.first)
78
+ args.shift
79
+ find = true
80
+ end
81
+ end
82
+ return [lookup_task!(forced), args] if forced
83
+ if find
84
+ name = fuzzyfind(task_names)
85
+ raise Error, 'No task selected' if name.nil? || name.strip.empty?
86
+ return [lookup_task!(name.strip), args]
87
+ end
88
+ first = args.first
89
+ if first == '.'
90
+ args.shift
91
+ return [current_task, args]
92
+ elsif first == '-' && orphan
93
+ args.shift
94
+ return [nil, args]
95
+ elsif first && !first.start_with?('-') && (task = lookup_task(first))
96
+ args.shift
97
+ return [task, args]
98
+ end
99
+ [current_task, args]
100
+ end
101
+
102
+ def take_task_only(args, options: opts)
103
+ task, rest = take_task(args, options: options)
104
+ no_args!(rest)
105
+ task
49
106
  end
50
107
 
51
108
  def single(args, optional: false)
@@ -65,9 +122,6 @@ class Hiiro
65
122
  name
66
123
  end
67
124
 
68
- def select_task
69
- selected_task_scope.task!
70
- end
71
125
 
72
126
  def save_current(task)
73
127
  pin = Hiiro::PinRecord.find_key('t', 'current_task') || Hiiro::PinRecord.new(command: 't', key: 'current_task')
@@ -120,7 +174,7 @@ class Hiiro
120
174
  def list_tasks
121
175
  tasks = Hiiro::TaskRecord.all_as_list
122
176
  if tasks.empty?
123
- puts 'No tasks. Create one with t TASK new or t TASK todo add TEXT.'
177
+ puts 'No tasks. Create one with t new NAME.'
124
178
  return
125
179
  end
126
180
  counts = open_todo_counts
@@ -145,33 +199,36 @@ class Hiiro
145
199
  .order(:id)
146
200
  end
147
201
 
148
- def add_todo(args)
202
+ def add_todo(task, args)
149
203
  text = args.join(' ')
150
- raise Error, 'Usage: t TASK todo add TEXT...; todo text cannot be blank' if text.strip.empty?
151
- scope = selected_task_scope
152
- task, item = Hiiro::DB.connection.transaction(mode: :immediate) do
153
- selected = scope.task
154
- selected ||= create(scope.reference) unless scope.orphan?
155
- [selected, Hiiro::TodoItem.create(text: text, task_name: selected&.name)]
156
- end
204
+ raise Error, 'Usage: t todo add [TASK] TEXT...; todo text cannot be blank' if text.strip.empty?
205
+ item = Hiiro::TodoItem.create(text: text, task_name: task&.name)
157
206
  puts "Added todo #{item.id} to #{task&.name || '-'}: #{item.text}"
158
207
  end
159
208
 
160
- def remove_todo(args)
209
+ def remove_todo(task, args)
161
210
  id = single(args)
162
- raise Error, 'Todo ID must be an exact decimal ID from t TASK todo' unless id.match?(/\A\d+\z/)
163
- scope = selected_task_scope
164
- task = scope.orphan? ? nil : scope.task!
211
+ raise Error, 'Todo ID must be an exact decimal ID from t todo' unless id.match?(/\A\d+\z/)
165
212
  deleted = task_todos(task).where(id: id.to_i).delete
166
213
  name = task&.name || '-'
167
214
  raise Error, "Todo #{id} does not belong to task #{name}" unless deleted == 1
168
215
  puts "Removed todo #{id} from #{name}"
169
216
  end
170
217
 
171
- def list_todos
172
- scope = selected_task_scope
173
- task = scope.orphan? ? nil : scope.task!
174
- task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
218
+ def list_todos(task, plain: false)
219
+ task_todos(task).each { |item| puts plain ? item.text : "Todo #{item.id} [#{item.status}]: #{item.text}" }
220
+ end
221
+
222
+ def find_todo(task, args)
223
+ id = single(args)
224
+ raise Error, 'Todo ID must be an exact decimal ID from t todo' unless id.match?(/\A\d+\z/)
225
+ item = task_todos(task).where(id: id.to_i).first
226
+ raise Error, "Todo #{id} does not belong to task #{task&.name || '-'}" unless item
227
+ item
228
+ end
229
+
230
+ def show_todo(task, args)
231
+ puts find_todo(task, args).text
175
232
  end
176
233
 
177
234
  def show(task)
@@ -186,8 +243,7 @@ class Hiiro
186
243
  task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
187
244
  end
188
245
 
189
- def metadata(field, args)
190
- task = select_task
246
+ def metadata(task, field, args)
191
247
  raise Error, 'Use text or --clear, not both' if opts.clear && !args.empty?
192
248
  if args.empty? && !opts.clear
193
249
  puts task[field] if task[field]
@@ -235,8 +291,7 @@ class Hiiro
235
291
  kind
236
292
  end
237
293
 
238
- def add_resource(group, target)
239
- task = select_task
294
+ def add_resource(task, group, target)
240
295
  kind = group == 'link' ? link_kind : group
241
296
  if %w[directory file].include?(group)
242
297
  target = existing_path(target, directory: group == 'directory')
@@ -304,8 +359,7 @@ class Hiiro
304
359
  found.first
305
360
  end
306
361
 
307
- def open_resource(group, reference)
308
- task = select_task
362
+ def open_resource(task, group, reference)
309
363
  rows = resources(group, task)
310
364
  candidates = rows.map { |row| [row.target, [row.id.to_s, row.target, row.label].compact] }
311
365
  if group == 'file'
@@ -320,9 +374,8 @@ class Hiiro
320
374
  "task-#{task.id}-"
321
375
  end
322
376
 
323
- def new_doc(args)
377
+ def new_doc(task, args)
324
378
  name = safe_name!(args.shift&.delete_suffix('.md'))
325
- task = select_task
326
379
  path = File.join(ensure_home(task), "#{doc_prefix(task)}#{name}.md")
327
380
  title = args.empty? ? name.tr('_-', ' ') : args.join(' ')
328
381
  File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write("# #{title}\n\n") }
@@ -333,8 +386,7 @@ class Hiiro
333
386
  home_files(task).select { |path| File.extname(path).downcase == '.md' }
334
387
  end
335
388
 
336
- def open_doc(reference)
337
- task = select_task
389
+ def open_doc(task, reference)
338
390
  candidates = documents(task).map do |path|
339
391
  short_name = File.basename(path, '.md').delete_prefix(doc_prefix(task))
340
392
  [path, [short_name, path.delete_prefix(task.home + '/'), File.basename(path), File.basename(path, '.md'), "#{short_name}.md", path]]
@@ -367,7 +419,7 @@ class Hiiro
367
419
  raise Error, "Workspace label #{label} is shared by tasks: #{collisions.map(&:name).join(', ')}" unless collisions.one?
368
420
  matches = client.workspaces.select { |workspace| workspace.name == label }
369
421
  raise Error, "Multiple Herdr workspaces have label #{label}" if matches.length > 1
370
- raise Error, "Task workspace is not open; run t #{task.name} switch" if required && matches.empty?
422
+ raise Error, "Task workspace is not open; run t switch #{task.name}" if required && matches.empty?
371
423
  matches.first
372
424
  end
373
425
 
@@ -376,17 +428,16 @@ class Hiiro
376
428
  existing_path(path, directory: true)
377
429
  end
378
430
 
379
- def open_workspace
380
- task = select_task
431
+ def open_workspace(task, save:)
381
432
  open_task_workspace(task, start_directory(task))
382
- save_current(task) if selected_task_scope.explicit?
433
+ save_current(task) if save
383
434
  end
384
435
 
385
436
  # Focus the task workspace or create it at directory. With optional: true,
386
437
  # a stopped Herdr prints a hint instead of failing (used after worktree changes).
387
438
  def open_task_workspace(task, directory, optional: false)
388
439
  if optional && !herdr_client.server_running?
389
- puts "Herdr is not running; run t #{task.name} switch to open the workspace"
440
+ puts "Herdr is not running; run t switch #{task.name} to open the workspace"
390
441
  return
391
442
  end
392
443
  workspace = workspace_for(task, required: false)
@@ -411,14 +462,18 @@ class Hiiro
411
462
  end
412
463
 
413
464
  def show_tree(task)
414
- raise Error, "No worktree for #{task.name}; run t #{task.name} tree new" unless task.tree
465
+ raise Error, "No worktree for #{task.name}; run t tree new #{task.name}" unless task.tree
415
466
  puts "#{task.tree}\t#{tree_path(task)}"
416
467
  end
417
468
 
418
- def new_tree(app_name, sparse_groups)
419
- scope = selected_task_scope
420
- task = scope.explicit? ? (scope.task || create(scope.reference)) : scope.task!
421
- raise Error, "#{task.name} already has worktree #{task.tree}; run t #{task.name} tree rm first" if task.tree
469
+ # tree new [NAME]: NAME may be an existing task, a new task to create, or absent (current task).
470
+ def new_tree(args, app_name, sparse_groups)
471
+ task = if args.empty? then current_task
472
+ elsif (found = lookup_task(args.first)) then found
473
+ else create(safe_name!(args.first))
474
+ end
475
+ no_args!(args.drop(1))
476
+ raise Error, "#{task.name} already has worktree #{task.tree}; run t tree rm #{task.name} first" if task.tree
422
477
  subtree = task.name.include?('/') ? task.name : "#{task.name}/main"
423
478
  path = tree_manager.create_tree(subtree, sparse_groups: Array(sparse_groups))
424
479
  raise Error, "Could not create worktree #{subtree}" unless path
@@ -441,8 +496,7 @@ class Hiiro
441
496
  puts "Detached worktree #{tree} from #{task.name}; the directory stays for reuse or resume"
442
497
  end
443
498
 
444
- def resume_tree(reference)
445
- task = select_task
499
+ def resume_tree(task, reference)
446
500
  raise Error, "#{task.name} already has worktree #{task.tree}" if task.tree
447
501
  used = Hiiro::TaskRecord.exclude(tree: nil).select_map(:tree)
448
502
  available = Hiiro::Tree.all.reject { |tree| used.include?(tree.name) || used.include?(tree.path) }
@@ -470,8 +524,8 @@ class Hiiro
470
524
  check_result(client.run_in_pane(pane, "cd #{start_directory(task).shellescape}"))
471
525
  end
472
526
 
473
- def show_workspace
474
- workspace = workspace_for(select_task)
527
+ def show_workspace(task)
528
+ workspace = workspace_for(task)
475
529
  puts workspace
476
530
  client.tabs(workspace: workspace).each { |tab| puts " #{tab}" }
477
531
  client.panes(workspace: workspace).each { |pane| puts " #{pane}\t#{pane.cwd}" }
@@ -481,16 +535,14 @@ class Hiiro
481
535
  choose(kind, items.map { |item| [item, [item.id, item.name].compact] }, reference, hint: 'use its live ID')
482
536
  end
483
537
 
484
- def new_tab(label)
485
- task = select_task
538
+ def new_tab(task, label)
486
539
  result = client.new_tab(name: label, workspace: workspace_for(task), start_directory: start_directory(task), command: opts.command, focus: true)
487
540
  check_result(result['tab'], 'Herdr did not create the tab')
488
541
  puts result['tab']['tab_id']
489
542
  end
490
543
 
491
- def pane_action(action, args)
544
+ def pane_action(task, action, args)
492
545
  reference = args.shift
493
- task = select_task
494
546
  pane = live_item('pane', client.panes(workspace: workspace_for(task)), reference)
495
547
  no_args!(args) unless action == 'run'
496
548
  case action
@@ -506,6 +558,7 @@ class Hiiro
506
558
  check_result(created, 'Herdr did not split the pane')
507
559
  puts created
508
560
  when 'run'
561
+ args.shift if args.first == '--'
509
562
  raise Error, 'A command is required' if args.empty?
510
563
  check_result(client.run_in_pane(pane.id, args.shelljoin))
511
564
  end
@@ -515,8 +568,7 @@ class Hiiro
515
568
  raise Error, message unless result
516
569
  end
517
570
 
518
- def run_ai(tool, argv)
519
- task = select_task
571
+ def run_ai(task, tool, argv)
520
572
  directory = start_directory(task)
521
573
  workspace = workspace_for(task, required: false)
522
574
  workspace ||= client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
@@ -524,126 +576,128 @@ class Hiiro
524
576
  puts Hiiro::TaskSessions.new(client, workspace: workspace, directory: directory).run(tool, argv)
525
577
  end
526
578
 
527
- def run_task_scope(reference, command_args)
528
- add_resolver(:task_scope, Hiiro::TaskScope.new(reference, herdr: -> { herdr_client }))
529
- run_child(reference, command_args, external_commands: false, builtin_commands: false) do
579
+ def child(name, child_args, &block)
580
+ run_child(name, child_args, external_commands: false, builtin_commands: false) do
530
581
  extend Commands
531
- task_commands
582
+ instance_eval(&block)
532
583
  end
533
584
  end
534
585
 
535
- def task_commands
536
- add_default do |*unexpected|
537
- no_args!(unexpected)
538
- show(select_task)
539
- end
586
+ def root_commands
587
+ task_options
588
+ add_default { |*unexpected| no_args!(unexpected); list_tasks }
540
589
  add_cmd(:help) { help }
541
- add_cmd(:show) { no_args!(opts.args); show(select_task) }
542
- add_cmd(:current) do
543
- no_args!(opts.args)
544
- task = select_task
545
- save_current(task) if selected_task_scope.explicit?
590
+ add_cmd(:list, :ls) { no_args!(opts.args); list_tasks }
591
+ add_cmd(:show, args: ['task?'], opts: TASK_OPTIONS) { show(take_task_only(opts.args)) }
592
+ add_cmd(:current, args: ['task?'], opts: TASK_OPTIONS) { puts take_task_only(opts.args).name }
593
+ add_cmd(:use, :pin, args: %i[task], opts: TASK_OPTIONS) do
594
+ task = take_task_only(opts.args)
595
+ save_current(task)
546
596
  puts task.name
547
597
  end
548
- add_cmd(:new) { no_args!(opts.args); create(selected_task_scope.reference) }
549
- add_cmd(:next, args: ['text...'], opts: %i[clear]) { metadata(:next_action, opts.args) }
550
- add_cmd(:waiting, args: ['text...'], opts: %i[clear]) { metadata(:waiting_on, opts.args) }
551
- add_cmd(:status, args: ['state?']) do
552
- state = single(opts.args, optional: true)
553
- state ? set_status(select_task, state) : puts(select_task.task_status)
598
+ add_cmd(:new, args: %i[name]) { create(single(opts.args)) }
599
+ add_cmd(:next, args: ['task?', 'text...'], opts: [*TASK_OPTIONS, :clear]) do
600
+ task, rest = take_task(opts.args)
601
+ metadata(task, :next_action, rest)
602
+ end
603
+ add_cmd(:waiting, args: ['task?', 'text...'], opts: [*TASK_OPTIONS, :clear]) do
604
+ task, rest = take_task(opts.args)
605
+ metadata(task, :waiting_on, rest)
606
+ end
607
+ add_cmd(:status, args: ['task?', 'state?'], opts: TASK_OPTIONS) do
608
+ task, rest = take_task(opts.args)
609
+ state = single(rest, optional: true)
610
+ state ? set_status(task, state) : puts(task.task_status)
554
611
  end
555
612
  %w[done archive].each do |name|
556
- add_cmd(name) do
557
- no_args!(opts.args)
558
- set_status(select_task, name == 'archive' ? 'archived' : 'done')
613
+ add_cmd(name, args: ['task?'], opts: TASK_OPTIONS) do
614
+ set_status(take_task_only(opts.args), name == 'archive' ? 'archived' : 'done')
559
615
  end
560
616
  end
561
617
 
562
- add_cmd(:todo, args: ['command?'], passthrough: true) do
563
- run_child(:todo, args, external_commands: false, builtin_commands: false) do
564
- extend Commands
565
- add_default { |*unexpected| no_args!(unexpected); list_todos }
566
- add_cmd(:help) { help }
567
- add_cmd(:list, :ls) { no_args!(opts.args); list_todos }
568
- add_cmd(:add, args: ['text...'], passthrough: true) do
569
- if %w[-h --help].include?(args.first)
570
- puts options.select([]).parse([]).help_text
571
- else
572
- add_todo(args)
573
- end
574
- end
575
- add_cmd(:rm, args: %i[id]) { remove_todo(opts.args) }
576
- end
577
- end
618
+ add_cmd(:todo, args: ['command?'], passthrough: true) { child(:todo, args) { todo_commands } }
578
619
 
579
620
  %w[directory link pr file].each do |group|
580
- add_cmd(group, args: %i[command], passthrough: true) do
581
- run_child(group, args, external_commands: false, builtin_commands: false) do
582
- extend Commands
583
- add_option :label, desc: 'Resource label'
584
- add_option :kind, desc: 'Link kind: general, issue, or thread' if group == 'link'
585
- add_options = %i[label]
586
- add_options << :primary if group == 'directory'
587
- add_options << :kind if group == 'link'
588
- read_options = group == 'link' ? %i[kind] : []
589
- add_cmd(:help) { help }
590
- add_cmd(:add, args: %i[target], opts: add_options) { add_resource(group, single(opts.args)) }
591
- add_cmd(:list, :ls, opts: read_options) { no_args!(opts.args); list_resources(group, select_task) }
592
- add_cmd(:open, args: ['reference?'], opts: read_options) { open_resource(group, single(opts.args, optional: true)) }
593
- end
594
- end
621
+ add_cmd(group, args: %i[command], passthrough: true) { child(group, args) { resource_commands(group) } }
595
622
  end
596
623
 
597
624
  add_cmd(:doc, args: %i[command], passthrough: true) do
598
- run_child(:doc, args, external_commands: false, builtin_commands: false) do
599
- extend Commands
625
+ child(:doc, args) do
626
+ task_options
600
627
  add_cmd(:help) { help }
601
- add_cmd(:new, args: ['name', 'title...']) { new_doc(opts.args) }
602
- add_cmd(:list, :ls) { no_args!(opts.args); documents(select_task).each { |path| puts path } }
603
- add_cmd(:open, args: ['name?']) { open_doc(single(opts.args, optional: true)) }
628
+ add_cmd(:new, args: ['task?', 'name', 'title...'], opts: TASK_OPTIONS) do
629
+ task, rest = take_task(opts.args)
630
+ new_doc(task, rest)
631
+ end
632
+ add_cmd(:list, :ls, args: ['task?'], opts: TASK_OPTIONS) { documents(take_task_only(opts.args)).each { |path| puts path } }
633
+ add_cmd(:open, args: ['task?', 'name?'], opts: TASK_OPTIONS) do
634
+ task, rest = take_task(opts.args)
635
+ open_doc(task, single(rest, optional: true))
636
+ end
604
637
  end
605
638
  end
606
639
 
607
640
  add_cmd(:tree, args: ['command?'], passthrough: true) do
608
- run_child(:tree, args, external_commands: false, builtin_commands: false) do
609
- extend Commands
641
+ child(:tree, args) do
642
+ task_options
610
643
  add_option :app, desc: 'App directory to open in the workspace'
611
644
  add_option :sparse, desc: 'Sparse checkout group (repeatable)', multi: true
612
- add_default { |*unexpected| no_args!(unexpected); show_tree(select_task) }
645
+ add_default { |*rest| show_tree(take_task_only(rest, options: nil)) }
613
646
  add_cmd(:help) { help }
614
- add_cmd(:new, opts: %i[app sparse]) { no_args!(opts.args); new_tree(opts.app, opts.sparse) }
615
- add_cmd(:rm, :remove) { no_args!(opts.args); remove_tree(select_task) }
616
- add_cmd(:resume, args: ['worktree?']) { resume_tree(single(opts.args, optional: true)) }
647
+ add_cmd(:new, args: ['name?'], opts: [*TASK_OPTIONS, :app, :sparse]) do
648
+ if opts.task || opts.find
649
+ new_tree([take_task_only(opts.args).name], opts.app, opts.sparse)
650
+ else
651
+ new_tree(opts.args, opts.app, opts.sparse)
652
+ end
653
+ end
654
+ add_cmd(:rm, :remove, args: ['task?'], opts: TASK_OPTIONS) { remove_tree(take_task_only(opts.args)) }
655
+ add_cmd(:resume, args: ['task?', 'worktree?'], opts: TASK_OPTIONS) do
656
+ task, rest = take_task(opts.args)
657
+ resume_tree(task, single(rest, optional: true))
658
+ end
617
659
  end
618
660
  end
619
661
 
620
- add_cmd(:path) { no_args!(opts.args); puts start_directory(select_task) }
621
- add_cmd(:branch) { no_args!(opts.args); puts current_branch(select_task) }
622
- add_cmd(:cd) { no_args!(opts.args); cd_to(select_task) }
623
- add_cmd(:sh, args: ['command...'], passthrough: true) { run_shell(select_task, args) }
662
+ add_cmd(:path, args: ['task?'], opts: TASK_OPTIONS) { puts start_directory(take_task_only(opts.args)) }
663
+ add_cmd(:branch, args: ['task?'], opts: TASK_OPTIONS) { puts current_branch(take_task_only(opts.args)) }
664
+ add_cmd(:cd, args: ['task?'], opts: TASK_OPTIONS) { cd_to(take_task_only(opts.args)) }
665
+ add_cmd(:sh, args: ['task?', 'command...'], passthrough: true) do
666
+ task, rest = take_task(args, options: nil)
667
+ run_shell(task, rest)
668
+ end
624
669
 
625
670
  add_option :directory, desc: 'Existing start directory'
626
- add_cmd(:switch, :workspace, opts: %i[directory show]) do
627
- no_args!(opts.args)
628
- opts.show ? show_workspace : open_workspace
671
+ add_cmd(:switch, :workspace, args: ['task?'], opts: [*TASK_OPTIONS, :directory, :show]) do
672
+ explicit = opts.task || opts.find || (opts.args.first && opts.args.first != '.')
673
+ task = take_task_only(opts.args)
674
+ opts.show ? show_workspace(task) : open_workspace(task, save: explicit)
629
675
  end
630
676
 
631
677
  [%w[omp], %w[codex cdx], %w[claude cld]].each do |names|
632
- add_cmd(*names, args: ['cli-args...'], passthrough: true) { run_ai(names.first, args) }
678
+ add_cmd(*names, args: ['task?', 'cli-args...'], passthrough: true) do
679
+ task, rest = take_task(args, options: nil)
680
+ run_ai(task, names.first, rest)
681
+ end
633
682
  end
634
683
 
635
684
  add_cmd(:tab, args: %i[command], passthrough: true) do
636
- run_child(:tab, args, external_commands: false, builtin_commands: false) do
637
- extend Commands
685
+ child(:tab, args) do
686
+ task_options
638
687
  add_option :directory, desc: 'Existing start directory'
639
688
  add_option :command, desc: 'Command to run in the new tab'
640
689
  add_cmd(:help) { help }
641
- add_cmd(:list, :ls) { no_args!(opts.args); client.tabs(workspace: workspace_for(select_task)).each { |tab| puts tab } }
642
- add_cmd(:new, args: ['label?'], opts: %i[directory command]) { new_tab(single(opts.args, optional: true)) }
643
- add_cmd(:open, args: ['reference?']) do
644
- reference = single(opts.args, optional: true)
645
- workspace = workspace_for(select_task)
646
- tab = live_item('tab', client.tabs(workspace: workspace), reference)
690
+ add_cmd(:list, :ls, args: ['task?'], opts: TASK_OPTIONS) do
691
+ client.tabs(workspace: workspace_for(take_task_only(opts.args))).each { |tab| puts tab }
692
+ end
693
+ add_cmd(:new, args: ['task?', 'label?'], opts: [*TASK_OPTIONS, :directory, :command]) do
694
+ task, rest = take_task(opts.args)
695
+ new_tab(task, single(rest, optional: true))
696
+ end
697
+ add_cmd(:open, args: ['task?', 'reference?'], opts: TASK_OPTIONS) do
698
+ task, rest = take_task(opts.args)
699
+ workspace = workspace_for(task)
700
+ tab = live_item('tab', client.tabs(workspace: workspace), single(rest, optional: true))
647
701
  check_result(client.focus_workspace(workspace.id))
648
702
  check_result(client.focus_tab(tab.id))
649
703
  end
@@ -651,20 +705,83 @@ class Hiiro
651
705
  end
652
706
 
653
707
  add_cmd(:pane, args: %i[command], passthrough: true) do
654
- run_child(:pane, args, external_commands: false, builtin_commands: false) do
655
- extend Commands
708
+ child(:pane, args) do
709
+ task_options
656
710
  add_option :directory, desc: 'Existing start directory'
657
711
  add_option :command, desc: 'Command to run in the new pane'
658
712
  add_option :direction, default: 'right', desc: 'Split direction: right or down'
659
713
  add_cmd(:help) { help }
660
- add_cmd(:list, :ls) { no_args!(opts.args); client.panes(workspace: workspace_for(select_task)).each { |pane| puts pane } }
714
+ add_cmd(:list, :ls, args: ['task?'], opts: TASK_OPTIONS) do
715
+ client.panes(workspace: workspace_for(take_task_only(opts.args))).each { |pane| puts pane }
716
+ end
661
717
  %w[open read].each do |action|
662
- add_cmd(action, args: ['pane?']) { pane_action(action, opts.args) }
718
+ add_cmd(action, args: ['task?', 'pane?'], opts: TASK_OPTIONS) do
719
+ task, rest = take_task(opts.args)
720
+ pane_action(task, action, rest)
721
+ end
722
+ end
723
+ add_cmd(:run, args: ['task?', 'pane', 'command...'], passthrough: true) do
724
+ task, rest = take_task(args, options: nil)
725
+ pane_action(task, 'run', rest)
663
726
  end
664
- add_cmd(:run, args: ['pane', 'command...']) { pane_action('run', opts.args) }
665
- add_cmd(:split, args: %i[pane], opts: %i[directory command direction]) { pane_action('split', opts.args) }
727
+ add_cmd(:split, args: ['task?', 'pane'], opts: [*TASK_OPTIONS, :directory, :command, :direction]) do
728
+ task, rest = take_task(opts.args)
729
+ pane_action(task, 'split', rest)
730
+ end
731
+ end
732
+ end
733
+ end
734
+
735
+ def todo_commands
736
+ task_options
737
+ add_option :plain, type: :flag, desc: 'Print only todo text, one per line'
738
+ add_default do |*rest|
739
+ task, rest = take_task(rest, orphan: true, options: nil)
740
+ no_args!(rest)
741
+ list_todos(task)
742
+ end
743
+ add_cmd(:help) { help }
744
+ add_cmd(:list, :ls, args: ['task?'], opts: [*TASK_OPTIONS, :plain]) do
745
+ task, rest = take_task(opts.args, orphan: true)
746
+ no_args!(rest)
747
+ list_todos(task, plain: opts.plain)
748
+ end
749
+ add_cmd(:show, args: ['task?', 'id'], opts: TASK_OPTIONS) do
750
+ task, rest = take_task(opts.args, orphan: true)
751
+ show_todo(task, rest)
752
+ end
753
+ add_cmd(:add, args: ['task?', 'text...'], passthrough: true) do
754
+ task, rest = %w[-h --help].include?(args.first) ? [nil, args] : take_task(args, orphan: true, options: nil)
755
+ if %w[-h --help].include?(rest.first)
756
+ puts options.select(TASK_OPTIONS).parse([]).help_text
757
+ else
758
+ add_todo(task, rest)
666
759
  end
667
760
  end
761
+ add_cmd(:rm, args: ['task?', 'id'], opts: TASK_OPTIONS) do
762
+ task, rest = take_task(opts.args, orphan: true)
763
+ remove_todo(task, rest)
764
+ end
765
+ end
766
+
767
+ def resource_commands(group)
768
+ task_options
769
+ add_option :label, desc: 'Resource label'
770
+ add_option :kind, desc: 'Link kind: general, issue, or thread' if group == 'link'
771
+ add_options = [*TASK_OPTIONS, :label]
772
+ add_options << :primary if group == 'directory'
773
+ add_options << :kind if group == 'link'
774
+ read_options = group == 'link' ? [*TASK_OPTIONS, :kind] : TASK_OPTIONS
775
+ add_cmd(:help) { help }
776
+ add_cmd(:add, args: ['task?', 'target'], opts: add_options) do
777
+ task, rest = take_task(opts.args)
778
+ add_resource(task, group, single(rest))
779
+ end
780
+ add_cmd(:list, :ls, args: ['task?'], opts: read_options) { list_resources(group, take_task_only(opts.args)) }
781
+ add_cmd(:open, args: ['task?', 'reference?'], opts: read_options) do
782
+ task, rest = take_task(opts.args)
783
+ open_resource(task, group, single(rest, optional: true))
784
+ end
668
785
  end
669
786
  end
670
787
  end