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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/CLAUDE.md +1 -0
- data/README.md +79 -53
- data/bin/h-alias +28 -0
- data/bin/h-bin +20 -0
- data/bin/h-env +25 -0
- data/bin/h-herdr +156 -0
- data/bin/h-task +1 -0
- data/bin/t +1 -0
- data/bin/tt +1 -0
- data/docs/h-bin.md +49 -2
- data/docs/h-herdr.md +41 -0
- data/docs/h-subtask.md +5 -44
- data/docs/h-task.md +18 -712
- data/docs/h.md +2 -2
- data/docs/t.md +89 -376
- data/docs/why-t.md +23 -25
- data/exe/h +1 -0
- data/exe/t +7 -0
- data/exe/tt +7 -0
- data/herdr-plugin/herdr-plugin.toml +47 -0
- data/lib/hiiro/current_task.rb +92 -0
- data/lib/hiiro/error.rb +5 -0
- data/lib/hiiro/git/worktrees.rb +1 -1
- data/lib/hiiro/git.rb +8 -2
- data/lib/hiiro/task_cli.rb +788 -0
- data/lib/hiiro/task_sessions.rb +1 -1
- data/lib/hiiro/tasks.rb +40 -27
- data/lib/hiiro/version.rb +1 -1
- data/lib/hiiro.rb +22 -20
- metadata +14 -2
- data/bin/t +0 -526
- data/bin/tt +0 -20
- data/lib/hiiro/task_scope.rb +0 -92
|
@@ -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
|