hiiro 0.1.366 → 0.1.367

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,671 @@
1
+ require 'fileutils'
2
+ require 'time'
3
+ require 'uri'
4
+ require 'shellwords'
5
+
6
+ class Hiiro
7
+ # Task-first CLI behind the `t` and `tt` executables.
8
+ #
9
+ # Hiiro.run(*ARGV, external_commands: false, builtin_commands: false) { Hiiro::TaskCli.setup(self) }
10
+ #
11
+ # Commands holds the helpers and the `add_cmd` declarations for each scope.
12
+ class TaskCli
13
+ def self.setup(hiiro)
14
+ 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
29
+ end
30
+
31
+ # `tt [TASK] [COMMAND ...]` is `t TASK todo [COMMAND ...]` with `.` as the default task.
32
+ def self.setup_todo(hiiro)
33
+ 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])
40
+ end
41
+ end
42
+ end
43
+
44
+ module Commands
45
+ class Error < Hiiro::Error; end
46
+
47
+ def selected_task_scope
48
+ resolve(:task_scope)
49
+ end
50
+
51
+ def single(args, optional: false)
52
+ raise Error, 'Too many arguments' if args.length > 1
53
+ raise Error, 'An argument is required; run t help' if !optional && args.empty?
54
+ args.first
55
+ end
56
+
57
+ def no_args!(args)
58
+ raise Error, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
59
+ end
60
+
61
+ def safe_name!(name)
62
+ 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
63
+ 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'
64
+ end
65
+ name
66
+ end
67
+
68
+ def select_task
69
+ selected_task_scope.task!
70
+ end
71
+
72
+ def save_current(task)
73
+ pin = Hiiro::PinRecord.find_key('t', 'current_task') || Hiiro::PinRecord.new(command: 't', key: 'current_task')
74
+ pin.value = task.id
75
+ pin.save
76
+ end
77
+
78
+ def inside?(path, directory)
79
+ return false unless Dir.exist?(directory)
80
+ root = File.realpath(directory)
81
+ path == root || path.start_with?(root + File::SEPARATOR)
82
+ end
83
+
84
+ def ensure_home(task)
85
+ root = File.dirname(task.home)
86
+ FileUtils.mkdir_p(root)
87
+ if File.symlink?(task.home) || (File.exist?(task.home) && !Dir.exist?(task.home))
88
+ raise Error, "Task home must be a directory, not a symlink or file: #{task.home}"
89
+ end
90
+ FileUtils.mkdir_p(task.home)
91
+ raise Error, 'Task home escapes the notes work directory' unless inside?(File.realpath(task.home), root)
92
+ task.home
93
+ end
94
+
95
+ def create(name)
96
+ safe_name!(name)
97
+ record = Hiiro::TaskRecord.find_by_name(name)
98
+ if record
99
+ ensure_home(record)
100
+ puts "Task already exists: #{name}\n#{record.home}"
101
+ return record
102
+ end
103
+ now = Time.now.iso8601
104
+ record = Hiiro::TaskRecord.new(name: name, session: name, status: 'active', created_at: now, updated_at: now)
105
+ ensure_home(record)
106
+ record.save
107
+ puts "Created #{name}\n#{record.home}"
108
+ record
109
+ end
110
+
111
+ def open_todo_counts
112
+ Hiiro::TodoItem.exclude(task_name: nil).exclude(status: %w[done skip])
113
+ .group_and_count(:task_name, :subtask_name).all
114
+ .each_with_object(Hash.new(0)) do |row, counts|
115
+ name = [row[:task_name], row[:subtask_name]].compact.join('/')
116
+ counts[name] += row[:count]
117
+ end
118
+ end
119
+
120
+ def list_tasks
121
+ tasks = Hiiro::TaskRecord.all_as_list
122
+ if tasks.empty?
123
+ puts 'No tasks. Create one with t TASK new or t TASK todo add TEXT.'
124
+ return
125
+ end
126
+ counts = open_todo_counts
127
+ rows = tasks.map do |task|
128
+ count = counts[task.name]
129
+ label = count.zero? ? task.name : "#{task.name} (#{count})"
130
+ detail = []
131
+ detail << "next: #{task.next_action}" if task.next_action
132
+ detail << "waiting: #{task.waiting_on}" if task.waiting_on
133
+ [label, task.task_status, detail.join(' ')]
134
+ end
135
+ name_col = rows.map { |row| row[0].length }.max
136
+ status_col = rows.map { |row| row[1].length }.max
137
+ rows.each { |label, status, detail| puts format("%-#{name_col}s %-#{status_col}s %s", label, status, detail).rstrip }
138
+ end
139
+
140
+
141
+ def task_todos(task)
142
+ return Hiiro::TodoItem.where(task_name: nil, subtask_name: nil).order(:id) unless task
143
+ Hiiro::TodoItem.where(task_name: task.name, subtask_name: nil)
144
+ .or(Sequel.join([:task_name, :subtask_name], '/') => task.name)
145
+ .order(:id)
146
+ end
147
+
148
+ def add_todo(args)
149
+ 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
157
+ puts "Added todo #{item.id} to #{task&.name || '-'}: #{item.text}"
158
+ end
159
+
160
+ def remove_todo(args)
161
+ 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!
165
+ deleted = task_todos(task).where(id: id.to_i).delete
166
+ name = task&.name || '-'
167
+ raise Error, "Todo #{id} does not belong to task #{name}" unless deleted == 1
168
+ puts "Removed todo #{id} from #{name}"
169
+ end
170
+
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}" }
175
+ end
176
+
177
+ def show(task)
178
+ puts "#{task.name} [#{task.task_status}]"
179
+ puts "Home: #{task.home}"
180
+ puts "Next: #{task.next_action}" if task.next_action
181
+ puts "Waiting: #{task.waiting_on}" if task.waiting_on
182
+ puts "Code: #{code_directory(task)}" if code_directory(task)
183
+ puts "Workspace label: #{workspace_label(task)}"
184
+ task.resources.each { |resource| print_resource(resource) }
185
+ documents(task).each { |path| puts "Document: #{path}" }
186
+ task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
187
+ end
188
+
189
+ def metadata(field, args)
190
+ task = select_task
191
+ raise Error, 'Use text or --clear, not both' if opts.clear && !args.empty?
192
+ if args.empty? && !opts.clear
193
+ puts task[field] if task[field]
194
+ return
195
+ end
196
+ text = opts.clear ? nil : args.join(' ')
197
+ changes = { field => text, updated_at: Time.now.iso8601 }
198
+ if field == :waiting_on
199
+ if text
200
+ changes.merge!(status: 'waiting', completed_at: nil, archived_at: nil)
201
+ elsif task.task_status == 'waiting'
202
+ changes[:status] = 'active'
203
+ end
204
+ end
205
+ task.update(changes)
206
+ show(task)
207
+ end
208
+
209
+ def set_status(task, state)
210
+ raise Error, "Status must be #{Hiiro::TaskRecord::STATUSES.join(', ')}" unless Hiiro::TaskRecord::STATUSES.include?(state)
211
+ now = Time.now.iso8601
212
+ changes = { status: state, updated_at: now }
213
+ case state
214
+ when 'done'
215
+ changes.merge!(completed_at: task.completed_at || now, archived_at: nil)
216
+ when 'archived'
217
+ changes[:archived_at] = task.archived_at || now
218
+ else
219
+ changes.merge!(completed_at: nil, archived_at: nil)
220
+ end
221
+ task.update(changes)
222
+ puts "#{task.name}\t#{task.task_status}"
223
+ end
224
+
225
+ def existing_path(path, directory: false)
226
+ expanded = File.expand_path(path)
227
+ valid = directory ? Dir.exist?(expanded) : File.file?(expanded)
228
+ raise Error, "#{directory ? 'Directory' : 'File'} not found: #{expanded}" unless valid
229
+ File.realpath(expanded)
230
+ end
231
+
232
+ def link_kind
233
+ kind = opts.kind || 'general'
234
+ raise Error, 'Link kind must be general, issue, or thread' unless %w[general issue thread].include?(kind)
235
+ kind
236
+ end
237
+
238
+ def add_resource(group, target)
239
+ task = select_task
240
+ kind = group == 'link' ? link_kind : group
241
+ if %w[directory file].include?(group)
242
+ target = existing_path(target, directory: group == 'directory')
243
+ else
244
+ uri = URI.parse(target)
245
+ raise Error, 'Use an absolute http or https URL' unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
246
+ end
247
+ raise Error, '--primary applies only to directories' if opts.fetch(:primary, false) && group != 'directory'
248
+ Hiiro::DB.connection.transaction do
249
+ resource = Hiiro::TaskResource.find_or_create(task_id: task.id, kind: kind, target: target) do |row|
250
+ row.label = opts.label
251
+ row.created_at = Time.now.iso8601
252
+ end
253
+ resource.update(label: opts.label) if opts.label
254
+ task.update(primary_directory: target, updated_at: Time.now.iso8601) if opts.fetch(:primary, false)
255
+ print_resource(resource)
256
+ end
257
+ rescue URI::InvalidURIError
258
+ raise Error, 'Use an absolute http or https URL'
259
+ end
260
+
261
+ def resources(group, task)
262
+ kinds = group == 'link' ? (opts.kind ? [link_kind] : %w[general issue thread pr]) : [group]
263
+ task.resources.where(kind: kinds).all
264
+ end
265
+
266
+ def print_resource(resource)
267
+ puts [resource.id, resource.kind, resource.label, resource.target].compact.join("\t")
268
+ end
269
+
270
+ def home_files(task)
271
+ return [] unless Dir.exist?(task.home)
272
+ raise Error, "Task home is a symlink: #{task.home}" if File.symlink?(task.home)
273
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: task.home).filter_map do |relative|
274
+ path = File.join(task.home, relative)
275
+ path if File.file?(path)
276
+ end.sort
277
+ end
278
+
279
+ def list_resources(group, task)
280
+ rows = resources(group, task)
281
+ rows.each { |resource| print_resource(resource) }
282
+ return unless group == 'file'
283
+ registered = rows.map(&:target)
284
+ home_files(task).each { |path| puts "home\t#{path}" unless registered.include?(File.realpath(path)) }
285
+ end
286
+
287
+ # Pick one value from [[value, names], ...]: exact name, then unique prefix
288
+ # via Hiiro::Matcher, or an interactive fuzzyfind when no reference is given.
289
+ def choose(kind, candidates, reference, hint: 'use an ID or full path/URL')
290
+ raise Error, "No #{kind} found" if candidates.empty?
291
+ if reference.nil?
292
+ return candidates.first.first if candidates.one?
293
+ chosen = fuzzyfind_from_map(candidates.to_h { |value, names| [names.first, value] })
294
+ return chosen if chosen
295
+ raise Error, "No #{kind} selected"
296
+ end
297
+ exact = candidates.select { |_, names| names.include?(reference) }.map(&:first).uniq
298
+ return exact.first if exact.one?
299
+ raise Error, "Ambiguous #{kind} #{reference}; #{hint}" if exact.length > 1
300
+ pairs = candidates.flat_map { |value, names| names.map { |name| [name, value] } }
301
+ found = Hiiro::Matcher.by_prefix(pairs, reference) { |pair| pair.first }.matches.map { |m| m.item.last }.uniq
302
+ raise Error, "No matching #{kind} #{reference}" if found.empty?
303
+ raise Error, "Ambiguous #{kind} #{reference}; #{hint}" unless found.one?
304
+ found.first
305
+ end
306
+
307
+ def open_resource(group, reference)
308
+ task = select_task
309
+ rows = resources(group, task)
310
+ candidates = rows.map { |row| [row.target, [row.id.to_s, row.target, row.label].compact] }
311
+ if group == 'file'
312
+ candidates.concat(home_files(task).map { |path| [path, [path, path.delete_prefix(task.home + '/'), File.basename(path)]] })
313
+ end
314
+ path = choose(group, candidates, reference)
315
+ existing_path(path, directory: group == 'directory') if %w[file directory].include?(group)
316
+ check_result(open_default(path), "Could not open #{path}")
317
+ end
318
+
319
+ def doc_prefix(task)
320
+ "task-#{task.id}-"
321
+ end
322
+
323
+ def new_doc(args)
324
+ name = safe_name!(args.shift&.delete_suffix('.md'))
325
+ task = select_task
326
+ path = File.join(ensure_home(task), "#{doc_prefix(task)}#{name}.md")
327
+ title = args.empty? ? name.tr('_-', ' ') : args.join(' ')
328
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write("# #{title}\n\n") }
329
+ puts path
330
+ end
331
+
332
+ def documents(task)
333
+ home_files(task).select { |path| File.extname(path).downcase == '.md' }
334
+ end
335
+
336
+ def open_doc(reference)
337
+ task = select_task
338
+ candidates = documents(task).map do |path|
339
+ short_name = File.basename(path, '.md').delete_prefix(doc_prefix(task))
340
+ [path, [short_name, path.delete_prefix(task.home + '/'), File.basename(path), File.basename(path, '.md'), "#{short_name}.md", path]]
341
+ end
342
+ path = choose('document', candidates, reference, hint: 'use its relative or absolute path')
343
+ check_result(system('mdoc', path), 'mdoc could not open the document; install mdoc and check its configuration')
344
+ end
345
+
346
+ def code_directory(task)
347
+ task.primary_directory || (task.tree && (task.tree.start_with?('/') ? task.tree : File.join(Hiiro::WORK_DIR, task.tree)))
348
+ end
349
+
350
+ def workspace_label(task)
351
+ (task.session || task.name).tr('.', '_')
352
+ end
353
+
354
+ def client
355
+ @client ||= begin
356
+ herdr = herdr_client
357
+ unless herdr.server_running?
358
+ raise Error, 'Herdr is not running; start Herdr for workspace/tab/pane commands, or supply a task name for task data'
359
+ end
360
+ herdr
361
+ end
362
+ end
363
+
364
+ def workspace_for(task, required: true)
365
+ label = workspace_label(task)
366
+ collisions = Hiiro::TaskRecord.all_as_list.select { |candidate| workspace_label(candidate) == label }
367
+ raise Error, "Workspace label #{label} is shared by tasks: #{collisions.map(&:name).join(', ')}" unless collisions.one?
368
+ matches = client.workspaces.select { |workspace| workspace.name == label }
369
+ 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?
371
+ matches.first
372
+ end
373
+
374
+ def start_directory(task)
375
+ path = opts&.fetch(:directory) || code_directory(task) || ensure_home(task)
376
+ existing_path(path, directory: true)
377
+ end
378
+
379
+ def open_workspace
380
+ task = select_task
381
+ open_task_workspace(task, start_directory(task))
382
+ save_current(task) if selected_task_scope.explicit?
383
+ end
384
+
385
+ # Focus the task workspace or create it at directory. With optional: true,
386
+ # a stopped Herdr prints a hint instead of failing (used after worktree changes).
387
+ def open_task_workspace(task, directory, optional: false)
388
+ if optional && !herdr_client.server_running?
389
+ puts "Herdr is not running; run t #{task.name} switch to open the workspace"
390
+ return
391
+ end
392
+ workspace = workspace_for(task, required: false)
393
+ if workspace
394
+ check_result(client.focus_workspace(workspace.id))
395
+ else
396
+ workspace = client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
397
+ check_result(workspace, 'Herdr did not create the workspace')
398
+ end
399
+ puts workspace
400
+ end
401
+
402
+ # --- Worktrees (shared with h task via Hiiro::TaskManager) ---
403
+
404
+ def tree_manager
405
+ @tree_manager ||= Hiiro::TaskManager.new(self)
406
+ end
407
+
408
+ def tree_path(task)
409
+ return nil unless task.tree
410
+ task.tree.start_with?('/') ? task.tree : File.join(Hiiro::WORK_DIR, task.tree)
411
+ end
412
+
413
+ def show_tree(task)
414
+ raise Error, "No worktree for #{task.name}; run t #{task.name} tree new" unless task.tree
415
+ puts "#{task.tree}\t#{tree_path(task)}"
416
+ end
417
+
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
422
+ subtree = task.name.include?('/') ? task.name : "#{task.name}/main"
423
+ path = tree_manager.create_tree(subtree, sparse_groups: Array(sparse_groups))
424
+ raise Error, "Could not create worktree #{subtree}" unless path
425
+ task.update(tree: subtree, session: task.session || task.name, updated_at: Time.now.iso8601)
426
+ puts "Created worktree #{subtree}\n#{path}"
427
+ directory = path
428
+ if app_name
429
+ app = Hiiro::Environment.current.find_app(app_name)
430
+ raise Error, "Unknown app: #{app_name}" unless app
431
+ directory = app.resolve(path)
432
+ end
433
+ open_task_workspace(task, directory, optional: true)
434
+ end
435
+
436
+ def remove_tree(task)
437
+ raise Error, "#{task.name} has no worktree" unless task.tree
438
+ tree = task.tree
439
+ tree_manager.config.detach_tree(task.name)
440
+ Hiiro::TaskRecord.subtasks_of(task.name).each { |sub| tree_manager.config.detach_tree(sub.name) if sub.tree }
441
+ puts "Detached worktree #{tree} from #{task.name}; the directory stays for reuse or resume"
442
+ end
443
+
444
+ def resume_tree(reference)
445
+ task = select_task
446
+ raise Error, "#{task.name} already has worktree #{task.tree}" if task.tree
447
+ used = Hiiro::TaskRecord.exclude(tree: nil).select_map(:tree)
448
+ available = Hiiro::Tree.all.reject { |tree| used.include?(tree.name) || used.include?(tree.path) }
449
+ tree = choose('worktree', available.map { |t| [t, [t.name, t.path]] }, reference, hint: 'use its full name')
450
+ task.update(tree: tree.name, session: task.session || task.name, updated_at: Time.now.iso8601)
451
+ puts "Resumed #{task.name} from worktree #{tree.name}"
452
+ open_task_workspace(task, tree.path, optional: true)
453
+ end
454
+
455
+ def current_branch(task)
456
+ path = tree_path(task)
457
+ raise Error, "No worktree for #{task.name}" unless path && Dir.exist?(path)
458
+ branch = Hiiro::Git.new(nil, path).branch.to_s
459
+ branch.empty? || branch == 'HEAD' ? '(detached)' : branch
460
+ end
461
+
462
+ def run_shell(task, command)
463
+ Dir.chdir(start_directory(task))
464
+ command.empty? ? exec(ENV['SHELL'] || 'zsh') : exec(*command)
465
+ end
466
+
467
+ def cd_to(task)
468
+ pane = ENV['HERDR_PANE_ID']
469
+ raise Error, 'Not running inside a Herdr pane' unless pane
470
+ check_result(client.run_in_pane(pane, "cd #{start_directory(task).shellescape}"))
471
+ end
472
+
473
+ def show_workspace
474
+ workspace = workspace_for(select_task)
475
+ puts workspace
476
+ client.tabs(workspace: workspace).each { |tab| puts " #{tab}" }
477
+ client.panes(workspace: workspace).each { |pane| puts " #{pane}\t#{pane.cwd}" }
478
+ end
479
+
480
+ def live_item(kind, items, reference)
481
+ choose(kind, items.map { |item| [item, [item.id, item.name].compact] }, reference, hint: 'use its live ID')
482
+ end
483
+
484
+ def new_tab(label)
485
+ task = select_task
486
+ result = client.new_tab(name: label, workspace: workspace_for(task), start_directory: start_directory(task), command: opts.command, focus: true)
487
+ check_result(result['tab'], 'Herdr did not create the tab')
488
+ puts result['tab']['tab_id']
489
+ end
490
+
491
+ def pane_action(action, args)
492
+ reference = args.shift
493
+ task = select_task
494
+ pane = live_item('pane', client.panes(workspace: workspace_for(task)), reference)
495
+ no_args!(args) unless action == 'run'
496
+ case action
497
+ when 'open'
498
+ check_result(client.focus_pane(pane.id))
499
+ when 'read'
500
+ text = client.read_pane(pane.id)
501
+ check_result(text, 'Herdr could not read the pane')
502
+ puts text
503
+ when 'split'
504
+ raise Error, 'Split direction must be right or down' unless %w[right down].include?(opts.direction)
505
+ created = client.split_pane(direction: opts.direction, target: pane.id, start_directory: start_directory(task), command: opts.command, focus: true)
506
+ check_result(created, 'Herdr did not split the pane')
507
+ puts created
508
+ when 'run'
509
+ raise Error, 'A command is required' if args.empty?
510
+ check_result(client.run_in_pane(pane.id, args.shelljoin))
511
+ end
512
+ end
513
+
514
+ def check_result(result, message = 'Herdr command failed')
515
+ raise Error, message unless result
516
+ end
517
+
518
+ def run_ai(tool, argv)
519
+ task = select_task
520
+ directory = start_directory(task)
521
+ workspace = workspace_for(task, required: false)
522
+ workspace ||= client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
523
+ check_result(workspace, 'Herdr did not create the workspace')
524
+ puts Hiiro::TaskSessions.new(client, workspace: workspace, directory: directory).run(tool, argv)
525
+ end
526
+
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
530
+ extend Commands
531
+ task_commands
532
+ end
533
+ end
534
+
535
+ def task_commands
536
+ add_default do |*unexpected|
537
+ no_args!(unexpected)
538
+ show(select_task)
539
+ end
540
+ 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?
546
+ puts task.name
547
+ 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)
554
+ end
555
+ %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')
559
+ end
560
+ end
561
+
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
578
+
579
+ %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
595
+ end
596
+
597
+ add_cmd(:doc, args: %i[command], passthrough: true) do
598
+ run_child(:doc, args, external_commands: false, builtin_commands: false) do
599
+ extend Commands
600
+ 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)) }
604
+ end
605
+ end
606
+
607
+ add_cmd(:tree, args: ['command?'], passthrough: true) do
608
+ run_child(:tree, args, external_commands: false, builtin_commands: false) do
609
+ extend Commands
610
+ add_option :app, desc: 'App directory to open in the workspace'
611
+ add_option :sparse, desc: 'Sparse checkout group (repeatable)', multi: true
612
+ add_default { |*unexpected| no_args!(unexpected); show_tree(select_task) }
613
+ 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)) }
617
+ end
618
+ end
619
+
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) }
624
+
625
+ 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
629
+ end
630
+
631
+ [%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) }
633
+ end
634
+
635
+ add_cmd(:tab, args: %i[command], passthrough: true) do
636
+ run_child(:tab, args, external_commands: false, builtin_commands: false) do
637
+ extend Commands
638
+ add_option :directory, desc: 'Existing start directory'
639
+ add_option :command, desc: 'Command to run in the new tab'
640
+ 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)
647
+ check_result(client.focus_workspace(workspace.id))
648
+ check_result(client.focus_tab(tab.id))
649
+ end
650
+ end
651
+ end
652
+
653
+ add_cmd(:pane, args: %i[command], passthrough: true) do
654
+ run_child(:pane, args, external_commands: false, builtin_commands: false) do
655
+ extend Commands
656
+ add_option :directory, desc: 'Existing start directory'
657
+ add_option :command, desc: 'Command to run in the new pane'
658
+ add_option :direction, default: 'right', desc: 'Split direction: right or down'
659
+ add_cmd(:help) { help }
660
+ add_cmd(:list, :ls) { no_args!(opts.args); client.panes(workspace: workspace_for(select_task)).each { |pane| puts pane } }
661
+ %w[open read].each do |action|
662
+ add_cmd(action, args: ['pane?']) { pane_action(action, opts.args) }
663
+ 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) }
666
+ end
667
+ end
668
+ end
669
+ end
670
+ end
671
+ end