hiiro 0.1.366 → 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.
@@ -0,0 +1,788 @@
1
+ require 'fileutils'
2
+ require 'time'
3
+ require 'uri'
4
+ require 'shellwords'
5
+
6
+ class Hiiro
7
+ # Task CLI behind the `t`, `tt`, and `h task` executables.
8
+ #
9
+ # Hiiro.run(*ARGV, external_commands: false, builtin_commands: false) { Hiiro::TaskCli.setup(self) }
10
+ #
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.
16
+ class TaskCli
17
+ def self.setup(hiiro)
18
+ hiiro.extend(Commands)
19
+ hiiro.root_commands
20
+ end
21
+
22
+ # `tt ARGS...` is `t todo ARGS...`.
23
+ def self.setup_todo(hiiro)
24
+ hiiro.extend(Commands)
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
29
+ end
30
+ end
31
+ end
32
+
33
+ module Commands
34
+ class Error < Hiiro::Error; end
35
+
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
106
+ end
107
+
108
+ def single(args, optional: false)
109
+ raise Error, 'Too many arguments' if args.length > 1
110
+ raise Error, 'An argument is required; run t help' if !optional && args.empty?
111
+ args.first
112
+ end
113
+
114
+ def no_args!(args)
115
+ raise Error, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
116
+ end
117
+
118
+ def safe_name!(name)
119
+ unless name && name.match?(%r{\A[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)?\z}) && name.bytesize <= 120
120
+ raise Error, 'Use a name of 1-120 ASCII letters, digits, dots, underscores, or hyphens, starting with a letter or digit; one / separates a subtask from its parent'
121
+ end
122
+ name
123
+ end
124
+
125
+
126
+ def save_current(task)
127
+ pin = Hiiro::PinRecord.find_key('t', 'current_task') || Hiiro::PinRecord.new(command: 't', key: 'current_task')
128
+ pin.value = task.id
129
+ pin.save
130
+ end
131
+
132
+ def inside?(path, directory)
133
+ return false unless Dir.exist?(directory)
134
+ root = File.realpath(directory)
135
+ path == root || path.start_with?(root + File::SEPARATOR)
136
+ end
137
+
138
+ def ensure_home(task)
139
+ root = File.dirname(task.home)
140
+ FileUtils.mkdir_p(root)
141
+ if File.symlink?(task.home) || (File.exist?(task.home) && !Dir.exist?(task.home))
142
+ raise Error, "Task home must be a directory, not a symlink or file: #{task.home}"
143
+ end
144
+ FileUtils.mkdir_p(task.home)
145
+ raise Error, 'Task home escapes the notes work directory' unless inside?(File.realpath(task.home), root)
146
+ task.home
147
+ end
148
+
149
+ def create(name)
150
+ safe_name!(name)
151
+ record = Hiiro::TaskRecord.find_by_name(name)
152
+ if record
153
+ ensure_home(record)
154
+ puts "Task already exists: #{name}\n#{record.home}"
155
+ return record
156
+ end
157
+ now = Time.now.iso8601
158
+ record = Hiiro::TaskRecord.new(name: name, session: name, status: 'active', created_at: now, updated_at: now)
159
+ ensure_home(record)
160
+ record.save
161
+ puts "Created #{name}\n#{record.home}"
162
+ record
163
+ end
164
+
165
+ def open_todo_counts
166
+ Hiiro::TodoItem.exclude(task_name: nil).exclude(status: %w[done skip])
167
+ .group_and_count(:task_name, :subtask_name).all
168
+ .each_with_object(Hash.new(0)) do |row, counts|
169
+ name = [row[:task_name], row[:subtask_name]].compact.join('/')
170
+ counts[name] += row[:count]
171
+ end
172
+ end
173
+
174
+ def list_tasks
175
+ tasks = Hiiro::TaskRecord.all_as_list
176
+ if tasks.empty?
177
+ puts 'No tasks. Create one with t new NAME.'
178
+ return
179
+ end
180
+ counts = open_todo_counts
181
+ rows = tasks.map do |task|
182
+ count = counts[task.name]
183
+ label = count.zero? ? task.name : "#{task.name} (#{count})"
184
+ detail = []
185
+ detail << "next: #{task.next_action}" if task.next_action
186
+ detail << "waiting: #{task.waiting_on}" if task.waiting_on
187
+ [label, task.task_status, detail.join(' ')]
188
+ end
189
+ name_col = rows.map { |row| row[0].length }.max
190
+ status_col = rows.map { |row| row[1].length }.max
191
+ rows.each { |label, status, detail| puts format("%-#{name_col}s %-#{status_col}s %s", label, status, detail).rstrip }
192
+ end
193
+
194
+
195
+ def task_todos(task)
196
+ return Hiiro::TodoItem.where(task_name: nil, subtask_name: nil).order(:id) unless task
197
+ Hiiro::TodoItem.where(task_name: task.name, subtask_name: nil)
198
+ .or(Sequel.join([:task_name, :subtask_name], '/') => task.name)
199
+ .order(:id)
200
+ end
201
+
202
+ def add_todo(task, args)
203
+ text = args.join(' ')
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)
206
+ puts "Added todo #{item.id} to #{task&.name || '-'}: #{item.text}"
207
+ end
208
+
209
+ def remove_todo(task, args)
210
+ id = single(args)
211
+ raise Error, 'Todo ID must be an exact decimal ID from t todo' unless id.match?(/\A\d+\z/)
212
+ deleted = task_todos(task).where(id: id.to_i).delete
213
+ name = task&.name || '-'
214
+ raise Error, "Todo #{id} does not belong to task #{name}" unless deleted == 1
215
+ puts "Removed todo #{id} from #{name}"
216
+ end
217
+
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
232
+ end
233
+
234
+ def show(task)
235
+ puts "#{task.name} [#{task.task_status}]"
236
+ puts "Home: #{task.home}"
237
+ puts "Next: #{task.next_action}" if task.next_action
238
+ puts "Waiting: #{task.waiting_on}" if task.waiting_on
239
+ puts "Code: #{code_directory(task)}" if code_directory(task)
240
+ puts "Workspace label: #{workspace_label(task)}"
241
+ task.resources.each { |resource| print_resource(resource) }
242
+ documents(task).each { |path| puts "Document: #{path}" }
243
+ task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
244
+ end
245
+
246
+ def metadata(task, field, args)
247
+ raise Error, 'Use text or --clear, not both' if opts.clear && !args.empty?
248
+ if args.empty? && !opts.clear
249
+ puts task[field] if task[field]
250
+ return
251
+ end
252
+ text = opts.clear ? nil : args.join(' ')
253
+ changes = { field => text, updated_at: Time.now.iso8601 }
254
+ if field == :waiting_on
255
+ if text
256
+ changes.merge!(status: 'waiting', completed_at: nil, archived_at: nil)
257
+ elsif task.task_status == 'waiting'
258
+ changes[:status] = 'active'
259
+ end
260
+ end
261
+ task.update(changes)
262
+ show(task)
263
+ end
264
+
265
+ def set_status(task, state)
266
+ raise Error, "Status must be #{Hiiro::TaskRecord::STATUSES.join(', ')}" unless Hiiro::TaskRecord::STATUSES.include?(state)
267
+ now = Time.now.iso8601
268
+ changes = { status: state, updated_at: now }
269
+ case state
270
+ when 'done'
271
+ changes.merge!(completed_at: task.completed_at || now, archived_at: nil)
272
+ when 'archived'
273
+ changes[:archived_at] = task.archived_at || now
274
+ else
275
+ changes.merge!(completed_at: nil, archived_at: nil)
276
+ end
277
+ task.update(changes)
278
+ puts "#{task.name}\t#{task.task_status}"
279
+ end
280
+
281
+ def existing_path(path, directory: false)
282
+ expanded = File.expand_path(path)
283
+ valid = directory ? Dir.exist?(expanded) : File.file?(expanded)
284
+ raise Error, "#{directory ? 'Directory' : 'File'} not found: #{expanded}" unless valid
285
+ File.realpath(expanded)
286
+ end
287
+
288
+ def link_kind
289
+ kind = opts.kind || 'general'
290
+ raise Error, 'Link kind must be general, issue, or thread' unless %w[general issue thread].include?(kind)
291
+ kind
292
+ end
293
+
294
+ def add_resource(task, group, target)
295
+ kind = group == 'link' ? link_kind : group
296
+ if %w[directory file].include?(group)
297
+ target = existing_path(target, directory: group == 'directory')
298
+ else
299
+ uri = URI.parse(target)
300
+ raise Error, 'Use an absolute http or https URL' unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
301
+ end
302
+ raise Error, '--primary applies only to directories' if opts.fetch(:primary, false) && group != 'directory'
303
+ Hiiro::DB.connection.transaction do
304
+ resource = Hiiro::TaskResource.find_or_create(task_id: task.id, kind: kind, target: target) do |row|
305
+ row.label = opts.label
306
+ row.created_at = Time.now.iso8601
307
+ end
308
+ resource.update(label: opts.label) if opts.label
309
+ task.update(primary_directory: target, updated_at: Time.now.iso8601) if opts.fetch(:primary, false)
310
+ print_resource(resource)
311
+ end
312
+ rescue URI::InvalidURIError
313
+ raise Error, 'Use an absolute http or https URL'
314
+ end
315
+
316
+ def resources(group, task)
317
+ kinds = group == 'link' ? (opts.kind ? [link_kind] : %w[general issue thread pr]) : [group]
318
+ task.resources.where(kind: kinds).all
319
+ end
320
+
321
+ def print_resource(resource)
322
+ puts [resource.id, resource.kind, resource.label, resource.target].compact.join("\t")
323
+ end
324
+
325
+ def home_files(task)
326
+ return [] unless Dir.exist?(task.home)
327
+ raise Error, "Task home is a symlink: #{task.home}" if File.symlink?(task.home)
328
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: task.home).filter_map do |relative|
329
+ path = File.join(task.home, relative)
330
+ path if File.file?(path)
331
+ end.sort
332
+ end
333
+
334
+ def list_resources(group, task)
335
+ rows = resources(group, task)
336
+ rows.each { |resource| print_resource(resource) }
337
+ return unless group == 'file'
338
+ registered = rows.map(&:target)
339
+ home_files(task).each { |path| puts "home\t#{path}" unless registered.include?(File.realpath(path)) }
340
+ end
341
+
342
+ # Pick one value from [[value, names], ...]: exact name, then unique prefix
343
+ # via Hiiro::Matcher, or an interactive fuzzyfind when no reference is given.
344
+ def choose(kind, candidates, reference, hint: 'use an ID or full path/URL')
345
+ raise Error, "No #{kind} found" if candidates.empty?
346
+ if reference.nil?
347
+ return candidates.first.first if candidates.one?
348
+ chosen = fuzzyfind_from_map(candidates.to_h { |value, names| [names.first, value] })
349
+ return chosen if chosen
350
+ raise Error, "No #{kind} selected"
351
+ end
352
+ exact = candidates.select { |_, names| names.include?(reference) }.map(&:first).uniq
353
+ return exact.first if exact.one?
354
+ raise Error, "Ambiguous #{kind} #{reference}; #{hint}" if exact.length > 1
355
+ pairs = candidates.flat_map { |value, names| names.map { |name| [name, value] } }
356
+ found = Hiiro::Matcher.by_prefix(pairs, reference) { |pair| pair.first }.matches.map { |m| m.item.last }.uniq
357
+ raise Error, "No matching #{kind} #{reference}" if found.empty?
358
+ raise Error, "Ambiguous #{kind} #{reference}; #{hint}" unless found.one?
359
+ found.first
360
+ end
361
+
362
+ def open_resource(task, group, reference)
363
+ rows = resources(group, task)
364
+ candidates = rows.map { |row| [row.target, [row.id.to_s, row.target, row.label].compact] }
365
+ if group == 'file'
366
+ candidates.concat(home_files(task).map { |path| [path, [path, path.delete_prefix(task.home + '/'), File.basename(path)]] })
367
+ end
368
+ path = choose(group, candidates, reference)
369
+ existing_path(path, directory: group == 'directory') if %w[file directory].include?(group)
370
+ check_result(open_default(path), "Could not open #{path}")
371
+ end
372
+
373
+ def doc_prefix(task)
374
+ "task-#{task.id}-"
375
+ end
376
+
377
+ def new_doc(task, args)
378
+ name = safe_name!(args.shift&.delete_suffix('.md'))
379
+ path = File.join(ensure_home(task), "#{doc_prefix(task)}#{name}.md")
380
+ title = args.empty? ? name.tr('_-', ' ') : args.join(' ')
381
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write("# #{title}\n\n") }
382
+ puts path
383
+ end
384
+
385
+ def documents(task)
386
+ home_files(task).select { |path| File.extname(path).downcase == '.md' }
387
+ end
388
+
389
+ def open_doc(task, reference)
390
+ candidates = documents(task).map do |path|
391
+ short_name = File.basename(path, '.md').delete_prefix(doc_prefix(task))
392
+ [path, [short_name, path.delete_prefix(task.home + '/'), File.basename(path), File.basename(path, '.md'), "#{short_name}.md", path]]
393
+ end
394
+ path = choose('document', candidates, reference, hint: 'use its relative or absolute path')
395
+ check_result(system('mdoc', path), 'mdoc could not open the document; install mdoc and check its configuration')
396
+ end
397
+
398
+ def code_directory(task)
399
+ task.primary_directory || (task.tree && (task.tree.start_with?('/') ? task.tree : File.join(Hiiro::WORK_DIR, task.tree)))
400
+ end
401
+
402
+ def workspace_label(task)
403
+ (task.session || task.name).tr('.', '_')
404
+ end
405
+
406
+ def client
407
+ @client ||= begin
408
+ herdr = herdr_client
409
+ unless herdr.server_running?
410
+ raise Error, 'Herdr is not running; start Herdr for workspace/tab/pane commands, or supply a task name for task data'
411
+ end
412
+ herdr
413
+ end
414
+ end
415
+
416
+ def workspace_for(task, required: true)
417
+ label = workspace_label(task)
418
+ collisions = Hiiro::TaskRecord.all_as_list.select { |candidate| workspace_label(candidate) == label }
419
+ raise Error, "Workspace label #{label} is shared by tasks: #{collisions.map(&:name).join(', ')}" unless collisions.one?
420
+ matches = client.workspaces.select { |workspace| workspace.name == label }
421
+ raise Error, "Multiple Herdr workspaces have label #{label}" if matches.length > 1
422
+ raise Error, "Task workspace is not open; run t switch #{task.name}" if required && matches.empty?
423
+ matches.first
424
+ end
425
+
426
+ def start_directory(task)
427
+ path = opts&.fetch(:directory) || code_directory(task) || ensure_home(task)
428
+ existing_path(path, directory: true)
429
+ end
430
+
431
+ def open_workspace(task, save:)
432
+ open_task_workspace(task, start_directory(task))
433
+ save_current(task) if save
434
+ end
435
+
436
+ # Focus the task workspace or create it at directory. With optional: true,
437
+ # a stopped Herdr prints a hint instead of failing (used after worktree changes).
438
+ def open_task_workspace(task, directory, optional: false)
439
+ if optional && !herdr_client.server_running?
440
+ puts "Herdr is not running; run t switch #{task.name} to open the workspace"
441
+ return
442
+ end
443
+ workspace = workspace_for(task, required: false)
444
+ if workspace
445
+ check_result(client.focus_workspace(workspace.id))
446
+ else
447
+ workspace = client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
448
+ check_result(workspace, 'Herdr did not create the workspace')
449
+ end
450
+ puts workspace
451
+ end
452
+
453
+ # --- Worktrees (shared with h task via Hiiro::TaskManager) ---
454
+
455
+ def tree_manager
456
+ @tree_manager ||= Hiiro::TaskManager.new(self)
457
+ end
458
+
459
+ def tree_path(task)
460
+ return nil unless task.tree
461
+ task.tree.start_with?('/') ? task.tree : File.join(Hiiro::WORK_DIR, task.tree)
462
+ end
463
+
464
+ def show_tree(task)
465
+ raise Error, "No worktree for #{task.name}; run t tree new #{task.name}" unless task.tree
466
+ puts "#{task.tree}\t#{tree_path(task)}"
467
+ end
468
+
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
477
+ subtree = task.name.include?('/') ? task.name : "#{task.name}/main"
478
+ path = tree_manager.create_tree(subtree, sparse_groups: Array(sparse_groups))
479
+ raise Error, "Could not create worktree #{subtree}" unless path
480
+ task.update(tree: subtree, session: task.session || task.name, updated_at: Time.now.iso8601)
481
+ puts "Created worktree #{subtree}\n#{path}"
482
+ directory = path
483
+ if app_name
484
+ app = Hiiro::Environment.current.find_app(app_name)
485
+ raise Error, "Unknown app: #{app_name}" unless app
486
+ directory = app.resolve(path)
487
+ end
488
+ open_task_workspace(task, directory, optional: true)
489
+ end
490
+
491
+ def remove_tree(task)
492
+ raise Error, "#{task.name} has no worktree" unless task.tree
493
+ tree = task.tree
494
+ tree_manager.config.detach_tree(task.name)
495
+ Hiiro::TaskRecord.subtasks_of(task.name).each { |sub| tree_manager.config.detach_tree(sub.name) if sub.tree }
496
+ puts "Detached worktree #{tree} from #{task.name}; the directory stays for reuse or resume"
497
+ end
498
+
499
+ def resume_tree(task, reference)
500
+ raise Error, "#{task.name} already has worktree #{task.tree}" if task.tree
501
+ used = Hiiro::TaskRecord.exclude(tree: nil).select_map(:tree)
502
+ available = Hiiro::Tree.all.reject { |tree| used.include?(tree.name) || used.include?(tree.path) }
503
+ tree = choose('worktree', available.map { |t| [t, [t.name, t.path]] }, reference, hint: 'use its full name')
504
+ task.update(tree: tree.name, session: task.session || task.name, updated_at: Time.now.iso8601)
505
+ puts "Resumed #{task.name} from worktree #{tree.name}"
506
+ open_task_workspace(task, tree.path, optional: true)
507
+ end
508
+
509
+ def current_branch(task)
510
+ path = tree_path(task)
511
+ raise Error, "No worktree for #{task.name}" unless path && Dir.exist?(path)
512
+ branch = Hiiro::Git.new(nil, path).branch.to_s
513
+ branch.empty? || branch == 'HEAD' ? '(detached)' : branch
514
+ end
515
+
516
+ def run_shell(task, command)
517
+ Dir.chdir(start_directory(task))
518
+ command.empty? ? exec(ENV['SHELL'] || 'zsh') : exec(*command)
519
+ end
520
+
521
+ def cd_to(task)
522
+ pane = ENV['HERDR_PANE_ID']
523
+ raise Error, 'Not running inside a Herdr pane' unless pane
524
+ check_result(client.run_in_pane(pane, "cd #{start_directory(task).shellescape}"))
525
+ end
526
+
527
+ def show_workspace(task)
528
+ workspace = workspace_for(task)
529
+ puts workspace
530
+ client.tabs(workspace: workspace).each { |tab| puts " #{tab}" }
531
+ client.panes(workspace: workspace).each { |pane| puts " #{pane}\t#{pane.cwd}" }
532
+ end
533
+
534
+ def live_item(kind, items, reference)
535
+ choose(kind, items.map { |item| [item, [item.id, item.name].compact] }, reference, hint: 'use its live ID')
536
+ end
537
+
538
+ def new_tab(task, label)
539
+ result = client.new_tab(name: label, workspace: workspace_for(task), start_directory: start_directory(task), command: opts.command, focus: true)
540
+ check_result(result['tab'], 'Herdr did not create the tab')
541
+ puts result['tab']['tab_id']
542
+ end
543
+
544
+ def pane_action(task, action, args)
545
+ reference = args.shift
546
+ pane = live_item('pane', client.panes(workspace: workspace_for(task)), reference)
547
+ no_args!(args) unless action == 'run'
548
+ case action
549
+ when 'open'
550
+ check_result(client.focus_pane(pane.id))
551
+ when 'read'
552
+ text = client.read_pane(pane.id)
553
+ check_result(text, 'Herdr could not read the pane')
554
+ puts text
555
+ when 'split'
556
+ raise Error, 'Split direction must be right or down' unless %w[right down].include?(opts.direction)
557
+ created = client.split_pane(direction: opts.direction, target: pane.id, start_directory: start_directory(task), command: opts.command, focus: true)
558
+ check_result(created, 'Herdr did not split the pane')
559
+ puts created
560
+ when 'run'
561
+ args.shift if args.first == '--'
562
+ raise Error, 'A command is required' if args.empty?
563
+ check_result(client.run_in_pane(pane.id, args.shelljoin))
564
+ end
565
+ end
566
+
567
+ def check_result(result, message = 'Herdr command failed')
568
+ raise Error, message unless result
569
+ end
570
+
571
+ def run_ai(task, tool, argv)
572
+ directory = start_directory(task)
573
+ workspace = workspace_for(task, required: false)
574
+ workspace ||= client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
575
+ check_result(workspace, 'Herdr did not create the workspace')
576
+ puts Hiiro::TaskSessions.new(client, workspace: workspace, directory: directory).run(tool, argv)
577
+ end
578
+
579
+ def child(name, child_args, &block)
580
+ run_child(name, child_args, external_commands: false, builtin_commands: false) do
581
+ extend Commands
582
+ instance_eval(&block)
583
+ end
584
+ end
585
+
586
+ def root_commands
587
+ task_options
588
+ add_default { |*unexpected| no_args!(unexpected); list_tasks }
589
+ add_cmd(:help) { help }
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)
596
+ puts task.name
597
+ end
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)
611
+ end
612
+ %w[done archive].each do |name|
613
+ add_cmd(name, args: ['task?'], opts: TASK_OPTIONS) do
614
+ set_status(take_task_only(opts.args), name == 'archive' ? 'archived' : 'done')
615
+ end
616
+ end
617
+
618
+ add_cmd(:todo, args: ['command?'], passthrough: true) { child(:todo, args) { todo_commands } }
619
+
620
+ %w[directory link pr file].each do |group|
621
+ add_cmd(group, args: %i[command], passthrough: true) { child(group, args) { resource_commands(group) } }
622
+ end
623
+
624
+ add_cmd(:doc, args: %i[command], passthrough: true) do
625
+ child(:doc, args) do
626
+ task_options
627
+ add_cmd(:help) { help }
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
637
+ end
638
+ end
639
+
640
+ add_cmd(:tree, args: ['command?'], passthrough: true) do
641
+ child(:tree, args) do
642
+ task_options
643
+ add_option :app, desc: 'App directory to open in the workspace'
644
+ add_option :sparse, desc: 'Sparse checkout group (repeatable)', multi: true
645
+ add_default { |*rest| show_tree(take_task_only(rest, options: nil)) }
646
+ add_cmd(:help) { help }
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
659
+ end
660
+ end
661
+
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
669
+
670
+ add_option :directory, desc: 'Existing start directory'
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)
675
+ end
676
+
677
+ [%w[omp], %w[codex cdx], %w[claude cld]].each do |names|
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
682
+ end
683
+
684
+ add_cmd(:tab, args: %i[command], passthrough: true) do
685
+ child(:tab, args) do
686
+ task_options
687
+ add_option :directory, desc: 'Existing start directory'
688
+ add_option :command, desc: 'Command to run in the new tab'
689
+ add_cmd(:help) { help }
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))
701
+ check_result(client.focus_workspace(workspace.id))
702
+ check_result(client.focus_tab(tab.id))
703
+ end
704
+ end
705
+ end
706
+
707
+ add_cmd(:pane, args: %i[command], passthrough: true) do
708
+ child(:pane, args) do
709
+ task_options
710
+ add_option :directory, desc: 'Existing start directory'
711
+ add_option :command, desc: 'Command to run in the new pane'
712
+ add_option :direction, default: 'right', desc: 'Split direction: right or down'
713
+ add_cmd(:help) { help }
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
717
+ %w[open read].each do |action|
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)
726
+ end
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)
759
+ end
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
785
+ end
786
+ end
787
+ end
788
+ end