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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +1 -0
- data/README.md +47 -9
- data/bin/h-bin +20 -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-subtask.md +5 -44
- data/docs/h-task.md +20 -557
- data/docs/h.md +1 -2
- data/docs/t.md +17 -7
- data/docs/why-t.md +2 -2
- data/exe/h +1 -0
- data/exe/t +7 -0
- data/exe/tt +7 -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 +671 -0
- data/lib/hiiro/task_scope.rb +2 -43
- 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 +23 -20
- metadata +9 -1
- data/bin/t +0 -526
- data/bin/tt +0 -20
|
@@ -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
|