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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -343
- data/README.md +73 -14
- data/bin/t +526 -0
- data/bin/tt +20 -0
- data/docs/h-task.md +120 -56
- data/docs/t.md +427 -0
- data/docs/why-t.md +306 -0
- data/lib/hiiro/task_scope.rb +92 -0
- data/lib/hiiro/task_sessions.rb +51 -0
- data/lib/hiiro/version.rb +1 -1
- metadata +8 -2
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
|