hiiro 0.1.365 → 0.1.366

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.
data/bin/t ADDED
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib_dir = File.expand_path('../lib', File.dirname(File.realpath(__FILE__)))
4
+ $LOAD_PATH.unshift(lib_dir)
5
+ require File.join(lib_dir, 'hiiro')
6
+ require File.join(lib_dir, 'hiiro/task_scope')
7
+ require File.join(lib_dir, 'hiiro/task_sessions')
8
+ require 'fileutils'
9
+ require 'time'
10
+ require 'uri'
11
+
12
+ module TaskCommands
13
+ class Error < StandardError; end
14
+
15
+ def selected_task_scope
16
+ resolve(:task_scope)
17
+ end
18
+
19
+ def single(args, optional: false)
20
+ raise Error, 'Too many arguments' if args.length > 1
21
+ raise Error, 'An argument is required; run t help' if !optional && args.empty?
22
+ args.first
23
+ end
24
+
25
+ def no_args!(args)
26
+ raise Error, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
27
+ end
28
+
29
+ def safe_name!(name)
30
+ unless name && name.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) && name.bytesize <= 120
31
+ raise Error, 'Use a name of 1-120 ASCII letters, digits, dots, underscores, or hyphens, starting with a letter or digit'
32
+ end
33
+ name
34
+ end
35
+
36
+ def select_task
37
+ selected_task_scope.task!
38
+ end
39
+
40
+ def save_current(task)
41
+ value = task.id.to_s
42
+ Hiiro::PinRecord.dataset.insert_conflict(
43
+ target: %i[command key], update: { value_json: value }
44
+ ).insert(command: 't', key: 'current_task', value_json: value)
45
+ end
46
+
47
+ def inside?(path, directory)
48
+ return false unless Dir.exist?(directory)
49
+ root = File.realpath(directory)
50
+ path == root || path.start_with?(root + File::SEPARATOR)
51
+ end
52
+
53
+ def ensure_home(task)
54
+ root = File.dirname(task.home)
55
+ FileUtils.mkdir_p(root)
56
+ if File.symlink?(task.home) || (File.exist?(task.home) && !Dir.exist?(task.home))
57
+ raise Error, "Task home must be a directory, not a symlink or file: #{task.home}"
58
+ end
59
+ FileUtils.mkdir_p(task.home)
60
+ raise Error, 'Task home escapes the notes work directory' unless inside?(File.realpath(task.home), root)
61
+ task.home
62
+ end
63
+
64
+ def create(name)
65
+ safe_name!(name)
66
+ record = Hiiro::TaskRecord.find_by_name(name)
67
+ if record
68
+ ensure_home(record)
69
+ puts "Task already exists: #{name}\n#{record.home}"
70
+ return record
71
+ end
72
+ now = Time.now.iso8601
73
+ record = Hiiro::TaskRecord.new(name: name, session: name, status: 'active', created_at: now, updated_at: now)
74
+ ensure_home(record)
75
+ record.save
76
+ puts "Created #{name}\n#{record.home}"
77
+ record
78
+ end
79
+
80
+ def list_tasks
81
+ tasks = Hiiro::TaskRecord.all_as_list
82
+ tasks.each { |task| puts [task.name, task.task_status, task.next_action, task.waiting_on].compact.join("\t") }
83
+ puts 'No tasks. Create one with t TASK new or t TASK todo add TEXT.' if tasks.empty?
84
+ end
85
+
86
+
87
+ def task_todos(task)
88
+ return Hiiro::TodoItem.where(task_name: nil, subtask_name: nil).order(:id) unless task
89
+ Hiiro::TodoItem.where(task_name: task.name, subtask_name: nil)
90
+ .or(Sequel.join([:task_name, :subtask_name], '/') => task.name)
91
+ .order(:id)
92
+ end
93
+
94
+ def add_todo(args)
95
+ text = args.join(' ')
96
+ raise Error, 'Usage: t TASK todo add TEXT...; todo text cannot be blank' if text.strip.empty?
97
+ scope = selected_task_scope
98
+ task, item = Hiiro::DB.connection.transaction(mode: :immediate) do
99
+ selected = scope.task
100
+ selected ||= create(scope.reference) unless scope.orphan?
101
+ [selected, Hiiro::TodoItem.create(text: text, task_name: selected&.name)]
102
+ end
103
+ puts "Added todo #{item.id} to #{task&.name || '-'}: #{item.text}"
104
+ end
105
+
106
+ def remove_todo(args)
107
+ id = single(args)
108
+ raise Error, 'Todo ID must be an exact decimal ID from t TASK todo' unless id.match?(/\A\d+\z/)
109
+ scope = selected_task_scope
110
+ task = scope.orphan? ? nil : scope.task!
111
+ deleted = task_todos(task).where(id: id.to_i).delete
112
+ name = task&.name || '-'
113
+ raise Error, "Todo #{id} does not belong to task #{name}" unless deleted == 1
114
+ puts "Removed todo #{id} from #{name}"
115
+ end
116
+
117
+ def list_todos
118
+ scope = selected_task_scope
119
+ task = scope.orphan? ? nil : scope.task!
120
+ task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
121
+ end
122
+
123
+ def show(task)
124
+ puts "#{task.name} [#{task.task_status}]"
125
+ puts "Home: #{task.home}"
126
+ puts "Next: #{task.next_action}" if task.next_action
127
+ puts "Waiting: #{task.waiting_on}" if task.waiting_on
128
+ puts "Code: #{code_directory(task)}" if code_directory(task)
129
+ puts "Workspace label: #{workspace_label(task)}"
130
+ task.resources.each { |resource| print_resource(resource) }
131
+ documents(task).each { |path| puts "Document: #{path}" }
132
+ task_todos(task).each { |item| puts "Todo #{item.id} [#{item.status}]: #{item.text}" }
133
+ end
134
+
135
+ def metadata(field, args)
136
+ task = select_task
137
+ raise Error, 'Use text or --clear, not both' if opts.clear && !args.empty?
138
+ if args.empty? && !opts.clear
139
+ puts task[field] if task[field]
140
+ return
141
+ end
142
+ text = opts.clear ? nil : args.join(' ')
143
+ changes = { field => text, updated_at: Time.now.iso8601 }
144
+ if field == :waiting_on
145
+ if text
146
+ changes.merge!(status: 'waiting', completed_at: nil, archived_at: nil)
147
+ elsif task.task_status == 'waiting'
148
+ changes[:status] = 'active'
149
+ end
150
+ end
151
+ task.update(changes)
152
+ show(task)
153
+ end
154
+
155
+ def set_status(task, state)
156
+ raise Error, "Status must be #{Hiiro::TaskRecord::STATUSES.join(', ')}" unless Hiiro::TaskRecord::STATUSES.include?(state)
157
+ now = Time.now.iso8601
158
+ changes = { status: state, updated_at: now }
159
+ case state
160
+ when 'done'
161
+ changes.merge!(completed_at: task.completed_at || now, archived_at: nil)
162
+ when 'archived'
163
+ changes[:archived_at] = task.archived_at || now
164
+ else
165
+ changes.merge!(completed_at: nil, archived_at: nil)
166
+ end
167
+ task.update(changes)
168
+ puts "#{task.name}\t#{task.task_status}"
169
+ end
170
+
171
+ def existing_path(path, directory: false)
172
+ expanded = File.expand_path(path)
173
+ valid = directory ? Dir.exist?(expanded) : File.file?(expanded)
174
+ raise Error, "#{directory ? 'Directory' : 'File'} not found: #{expanded}" unless valid
175
+ File.realpath(expanded)
176
+ end
177
+
178
+ def link_kind
179
+ kind = opts.kind || 'general'
180
+ raise Error, 'Link kind must be general, issue, or thread' unless %w[general issue thread].include?(kind)
181
+ kind
182
+ end
183
+
184
+ def add_resource(group, target)
185
+ task = select_task
186
+ kind = group == 'link' ? link_kind : group
187
+ if %w[directory file].include?(group)
188
+ target = existing_path(target, directory: group == 'directory')
189
+ else
190
+ uri = URI.parse(target)
191
+ raise Error, 'Use an absolute http or https URL' unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
192
+ end
193
+ raise Error, '--primary applies only to directories' if opts.fetch(:primary, false) && group != 'directory'
194
+ Hiiro::DB.connection.transaction do
195
+ resource = Hiiro::TaskResource.find_or_create(task_id: task.id, kind: kind, target: target) do |row|
196
+ row.label = opts.label
197
+ row.created_at = Time.now.iso8601
198
+ end
199
+ resource.update(label: opts.label) if opts.label
200
+ task.update(primary_directory: target, updated_at: Time.now.iso8601) if opts.fetch(:primary, false)
201
+ print_resource(resource)
202
+ end
203
+ rescue URI::InvalidURIError
204
+ raise Error, 'Use an absolute http or https URL'
205
+ end
206
+
207
+ def resources(group, task)
208
+ kinds = group == 'link' ? (opts.kind ? [link_kind] : %w[general issue thread pr]) : [group]
209
+ task.resources.where(kind: kinds).all
210
+ end
211
+
212
+ def print_resource(resource)
213
+ puts [resource.id, resource.kind, resource.label, resource.target].compact.join("\t")
214
+ end
215
+
216
+ def home_files(task)
217
+ return [] unless Dir.exist?(task.home)
218
+ raise Error, "Task home is a symlink: #{task.home}" if File.symlink?(task.home)
219
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: task.home).filter_map do |relative|
220
+ path = File.join(task.home, relative)
221
+ path if File.file?(path)
222
+ end.sort
223
+ end
224
+
225
+ def list_resources(group, task)
226
+ rows = resources(group, task)
227
+ rows.each { |resource| print_resource(resource) }
228
+ return unless group == 'file'
229
+ registered = rows.map(&:target)
230
+ home_files(task).each { |path| puts "home\t#{path}" unless registered.include?(File.realpath(path)) }
231
+ end
232
+
233
+ def open_resource(group, reference)
234
+ task = select_task
235
+ rows = resources(group, task)
236
+ candidates = rows.map { |row| [row.target, [row.id.to_s, row.target, row.label].compact] }
237
+ if group == 'file'
238
+ candidates.concat(home_files(task).map { |path| [path, [path, path.delete_prefix(task.home + '/'), File.basename(path)]] })
239
+ end
240
+ matches = candidates.select { |_, names| reference.nil? || names.include?(reference) }.map(&:first).uniq
241
+ raise Error, "No matching #{group}; run t #{task.name} #{group} list" if matches.empty?
242
+ raise Error, "Ambiguous #{group}; use an ID or full path/URL" unless matches.one?
243
+ path = matches.first
244
+ existing_path(path, directory: group == 'directory') if %w[file directory].include?(group)
245
+ open_default(path)
246
+ end
247
+
248
+ def doc_prefix(task)
249
+ "task-#{task.id}-"
250
+ end
251
+
252
+ def new_doc(args)
253
+ name = safe_name!(args.shift&.delete_suffix('.md'))
254
+ task = select_task
255
+ path = File.join(ensure_home(task), "#{doc_prefix(task)}#{name}.md")
256
+ title = args.empty? ? name.tr('_-', ' ') : args.join(' ')
257
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write("# #{title}\n\n") }
258
+ puts path
259
+ end
260
+
261
+ def documents(task)
262
+ home_files(task).select { |path| File.extname(path).downcase == '.md' }
263
+ end
264
+
265
+ def open_doc(reference)
266
+ task = select_task
267
+ matches = documents(task).select do |path|
268
+ short_name = File.basename(path, '.md').delete_prefix(doc_prefix(task))
269
+ reference.nil? || [path, path.delete_prefix(task.home + '/'), File.basename(path), File.basename(path, '.md'), short_name, "#{short_name}.md"].include?(reference)
270
+ end
271
+ raise Error, "No matching document; run t #{task.name} doc list" if matches.empty?
272
+ raise Error, 'Ambiguous document; use its relative or absolute path' unless matches.one?
273
+ check_result(system('mdoc', matches.first), 'mdoc could not open the document; install mdoc and check its configuration')
274
+ end
275
+
276
+ def open_default(target)
277
+ executable = RUBY_PLATFORM.include?('darwin') ? 'open' : 'xdg-open'
278
+ check_result(system(executable, target), "#{executable} could not open #{target}")
279
+ end
280
+
281
+ def code_directory(task)
282
+ task.primary_directory || (task.tree && (task.tree.start_with?('/') ? task.tree : File.join(Hiiro::WORK_DIR, task.tree)))
283
+ end
284
+
285
+ def workspace_label(task)
286
+ (task.session || task.name).tr('.', '_')
287
+ end
288
+
289
+ def client
290
+ @client ||= begin
291
+ herdr = herdr_client
292
+ unless herdr.server_running?
293
+ raise Error, 'Herdr is not running; start Herdr for workspace/tab/pane commands, or supply a task name for task data'
294
+ end
295
+ herdr
296
+ end
297
+ end
298
+
299
+ def workspace_for(task, required: true)
300
+ label = workspace_label(task)
301
+ collisions = Hiiro::TaskRecord.all_as_list.select { |candidate| workspace_label(candidate) == label }
302
+ raise Error, "Workspace label #{label} is shared by tasks: #{collisions.map(&:name).join(', ')}" unless collisions.one?
303
+ matches = client.workspaces.select { |workspace| workspace.name == label }
304
+ raise Error, "Multiple Herdr workspaces have label #{label}" if matches.length > 1
305
+ raise Error, "Task workspace is not open; run t #{task.name} switch" if required && matches.empty?
306
+ matches.first
307
+ end
308
+
309
+ def start_directory(task)
310
+ path = opts&.fetch(:directory) || code_directory(task) || ensure_home(task)
311
+ existing_path(path, directory: true)
312
+ end
313
+
314
+ def open_workspace
315
+ task = select_task
316
+ workspace = workspace_for(task, required: false)
317
+ if workspace
318
+ check_result(client.focus_workspace(workspace.id))
319
+ else
320
+ workspace = client.new_workspace(workspace_label(task), start_directory: start_directory(task), focus: true)
321
+ check_result(workspace, 'Herdr did not create the workspace')
322
+ end
323
+ save_current(task) if selected_task_scope.explicit?
324
+ puts workspace
325
+ end
326
+
327
+ def show_workspace
328
+ workspace = workspace_for(select_task)
329
+ puts workspace
330
+ client.tabs(workspace: workspace).each { |tab| puts " #{tab}" }
331
+ client.panes(workspace: workspace).each { |pane| puts " #{pane}\t#{pane.cwd}" }
332
+ end
333
+
334
+ def live_item(items, reference)
335
+ matches = items.select { |item| item.id == reference || item.name == reference }
336
+ raise Error, "No tab/pane in this task workspace matches #{reference}" if matches.empty?
337
+ raise Error, "Ambiguous tab/pane label #{reference}; use its live ID" unless matches.one?
338
+ matches.first
339
+ end
340
+
341
+ def new_tab(label)
342
+ task = select_task
343
+ result = client.new_tab(name: label, workspace: workspace_for(task), start_directory: start_directory(task), command: opts.command, focus: true)
344
+ check_result(result['tab'], 'Herdr did not create the tab')
345
+ puts result['tab']['tab_id']
346
+ end
347
+
348
+ def pane_action(action, args)
349
+ reference = args.shift || raise(Error, 'A pane ID or label is required')
350
+ task = select_task
351
+ pane = live_item(client.panes(workspace: workspace_for(task)), reference)
352
+ no_args!(args) unless action == 'run'
353
+ case action
354
+ when 'open'
355
+ check_result(client.focus_pane(pane.id))
356
+ when 'read'
357
+ text = client.read_pane(pane.id)
358
+ check_result(text, 'Herdr could not read the pane')
359
+ puts text
360
+ when 'split'
361
+ raise Error, 'Split direction must be right or down' unless %w[right down].include?(opts.direction)
362
+ created = client.split_pane(direction: opts.direction, target: pane.id, start_directory: start_directory(task), command: opts.command, focus: true)
363
+ check_result(created, 'Herdr did not split the pane')
364
+ puts created
365
+ when 'run'
366
+ raise Error, 'A command is required' if args.empty?
367
+ check_result(client.run_in_pane(pane.id, args.shelljoin))
368
+ end
369
+ end
370
+
371
+ def check_result(result, message = 'Herdr command failed')
372
+ raise Error, message unless result
373
+ end
374
+
375
+ def run_ai(tool, argv)
376
+ task = select_task
377
+ directory = start_directory(task)
378
+ workspace = workspace_for(task, required: false)
379
+ workspace ||= client.new_workspace(workspace_label(task), start_directory: directory, focus: true)
380
+ check_result(workspace, 'Herdr did not create the workspace')
381
+ puts Hiiro::TaskSessions.new(client, workspace: workspace, directory: directory).run(tool, argv)
382
+ end
383
+
384
+ def run_task_scope(reference, command_args)
385
+ add_resolver(:task_scope, Hiiro::TaskScope.new(reference, herdr: -> { herdr_client }))
386
+ run_child(reference, command_args, external_commands: false) do
387
+ extend TaskCommands
388
+ task_commands
389
+ end
390
+ end
391
+
392
+ def task_commands
393
+ add_default do |*unexpected|
394
+ no_args!(unexpected)
395
+ show(select_task)
396
+ end
397
+ add_cmd(:help) { help }
398
+ add_cmd(:show) { no_args!(opts.args); show(select_task) }
399
+ add_cmd(:current) do
400
+ no_args!(opts.args)
401
+ task = select_task
402
+ save_current(task) if selected_task_scope.explicit?
403
+ puts task.name
404
+ end
405
+ add_cmd(:new) { no_args!(opts.args); create(selected_task_scope.reference) }
406
+ add_cmd(:next, args: ['text...'], opts: %i[clear]) { metadata(:next_action, opts.args) }
407
+ add_cmd(:waiting, args: ['text...'], opts: %i[clear]) { metadata(:waiting_on, opts.args) }
408
+ add_cmd(:status, args: ['state?']) do
409
+ state = single(opts.args, optional: true)
410
+ state ? set_status(select_task, state) : puts(select_task.task_status)
411
+ end
412
+ %w[done archive].each do |name|
413
+ add_cmd(name) do
414
+ no_args!(opts.args)
415
+ set_status(select_task, name == 'archive' ? 'archived' : 'done')
416
+ end
417
+ end
418
+
419
+ add_cmd(:todo, args: ['command?'], passthrough: true) do
420
+ run_child(:todo, args, external_commands: false) do
421
+ extend TaskCommands
422
+ add_default { |*unexpected| no_args!(unexpected); list_todos }
423
+ add_cmd(:help) { help }
424
+ add_cmd(:list, :ls) { no_args!(opts.args); list_todos }
425
+ add_cmd(:add, args: ['text...'], passthrough: true) do
426
+ if %w[-h --help].include?(args.first)
427
+ puts options.select([]).parse([]).help_text
428
+ else
429
+ add_todo(args)
430
+ end
431
+ end
432
+ add_cmd(:rm, args: %i[id]) { remove_todo(opts.args) }
433
+ end
434
+ end
435
+
436
+ %w[directory link pr file].each do |group|
437
+ add_cmd(group, args: %i[command], passthrough: true) do
438
+ run_child(group, args, external_commands: false) do
439
+ extend TaskCommands
440
+ add_option :label, desc: 'Resource label'
441
+ add_option :kind, desc: 'Link kind: general, issue, or thread' if group == 'link'
442
+ add_options = %i[label]
443
+ add_options << :primary if group == 'directory'
444
+ add_options << :kind if group == 'link'
445
+ read_options = group == 'link' ? %i[kind] : []
446
+ add_cmd(:help) { help }
447
+ add_cmd(:add, args: %i[target], opts: add_options) { add_resource(group, single(opts.args)) }
448
+ add_cmd(:list, :ls, opts: read_options) { no_args!(opts.args); list_resources(group, select_task) }
449
+ add_cmd(:open, args: ['reference?'], opts: read_options) { open_resource(group, single(opts.args, optional: true)) }
450
+ end
451
+ end
452
+ end
453
+
454
+ add_cmd(:doc, args: %i[command], passthrough: true) do
455
+ run_child(:doc, args, external_commands: false) do
456
+ extend TaskCommands
457
+ add_cmd(:help) { help }
458
+ add_cmd(:new, args: ['name', 'title...']) { new_doc(opts.args) }
459
+ add_cmd(:list, :ls) { no_args!(opts.args); documents(select_task).each { |path| puts path } }
460
+ add_cmd(:open, args: ['name?']) { open_doc(single(opts.args, optional: true)) }
461
+ end
462
+ end
463
+
464
+ add_option :directory, desc: 'Existing start directory'
465
+ add_cmd(:switch, :workspace, opts: %i[directory show]) do
466
+ no_args!(opts.args)
467
+ opts.show ? show_workspace : open_workspace
468
+ end
469
+
470
+ [%w[omp], %w[codex cdx], %w[claude cld]].each do |names|
471
+ add_cmd(*names, args: ['cli-args...'], passthrough: true) { run_ai(names.first, args) }
472
+ end
473
+
474
+ add_cmd(:tab, args: %i[command], passthrough: true) do
475
+ run_child(:tab, args, external_commands: false) do
476
+ extend TaskCommands
477
+ add_option :directory, desc: 'Existing start directory'
478
+ add_option :command, desc: 'Command to run in the new tab'
479
+ add_cmd(:help) { help }
480
+ add_cmd(:list, :ls) { no_args!(opts.args); client.tabs(workspace: workspace_for(select_task)).each { |tab| puts tab } }
481
+ add_cmd(:new, args: ['label?'], opts: %i[directory command]) { new_tab(single(opts.args, optional: true)) }
482
+ add_cmd(:open, args: %i[reference]) do
483
+ reference = single(opts.args)
484
+ workspace = workspace_for(select_task)
485
+ tab = live_item(client.tabs(workspace: workspace), reference)
486
+ check_result(client.focus_workspace(workspace.id))
487
+ check_result(client.focus_tab(tab.id))
488
+ end
489
+ end
490
+ end
491
+
492
+ add_cmd(:pane, args: %i[command], passthrough: true) do
493
+ run_child(:pane, args, external_commands: false) do
494
+ extend TaskCommands
495
+ add_option :directory, desc: 'Existing start directory'
496
+ add_option :command, desc: 'Command to run in the new pane'
497
+ add_option :direction, default: 'right', desc: 'Split direction: right or down'
498
+ add_cmd(:help) { help }
499
+ add_cmd(:list, :ls) { no_args!(opts.args); client.panes(workspace: workspace_for(select_task)).each { |pane| puts pane } }
500
+ %w[open read].each do |action|
501
+ add_cmd(action, args: %i[pane]) { pane_action(action, opts.args) }
502
+ end
503
+ add_cmd(:run, args: ['pane', 'command...']) { pane_action('run', opts.args) }
504
+ add_cmd(:split, args: %i[pane], opts: %i[directory command direction]) { pane_action('split', opts.args) }
505
+ end
506
+ end
507
+ end
508
+ end
509
+
510
+ Hiiro.run(*ARGV, external_commands: false) do
511
+ extend TaskCommands
512
+ runners.subcommands.clear
513
+ add_default do |reference = nil, *command_args|
514
+ if reference.nil?
515
+ list_tasks
516
+ elsif reference == 'help'
517
+ no_args!(command_args)
518
+ puts "Usage: t TASK [COMMAND ...]\n\nBare t lists tasks; t TASK shows one."
519
+ puts "Use . for the current task, or - for unassigned todos."
520
+ puts "Todo shortcut: tt TASK [COMMAND ...]\n\nTask commands:"
521
+ run_task_scope('TASK', ['help'])
522
+ else
523
+ run_task_scope(reference, command_args)
524
+ end
525
+ end
526
+ end
data/bin/tt ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib_dir = File.expand_path('../lib', File.dirname(File.realpath(__FILE__)))
4
+ $LOAD_PATH.unshift(lib_dir)
5
+ require File.join(lib_dir, 'hiiro')
6
+ require 'rbconfig'
7
+
8
+ task_bin = File.join(File.dirname(File.realpath(__FILE__)), 't')
9
+
10
+ Hiiro.run(*ARGV, external_commands: false) do
11
+ runners.subcommands.clear
12
+ add_default do |reference = nil, *todo_args|
13
+ if reference == 'help'
14
+ raise ArgumentError, 'Usage: tt TASK [COMMAND ...]' unless todo_args.empty?
15
+ exec RbConfig.ruby, task_bin, '.', 'todo', 'help'
16
+ else
17
+ exec RbConfig.ruby, task_bin, reference || '.', 'todo', *todo_args
18
+ end
19
+ end
20
+ end