claude-task-master 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'pastel'
5
+ require 'fileutils'
6
+
7
+ module ClaudeTaskMaster
8
+ class CLI < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ desc 'start GOAL', 'Start working on a new goal'
14
+ long_desc <<~DESC
15
+ Start claude-task-master with a new goal. Claude will:
16
+
17
+ 1. Analyze the codebase
18
+ 2. Create a plan in .claude-task-master/plan.md
19
+ 3. Ask for success criteria
20
+ 4. Work autonomously until done
21
+
22
+ Examples:
23
+ claude-task-master start "build a REST API with user auth"
24
+ claude-task-master start "fix all TypeScript errors"
25
+ claude-task-master start "add dark mode to the UI"
26
+ DESC
27
+ option :criteria, type: :string, aliases: '-c', desc: 'Success criteria (will prompt if not provided)'
28
+ option :model, type: :string, default: 'sonnet', desc: 'Claude model to use (sonnet, opus, haiku)'
29
+ option :no_merge, type: :boolean, default: false, desc: 'Do not auto-merge PRs (require manual merge)'
30
+ option :max_sessions, type: :numeric, aliases: '-m', desc: 'Maximum number of sessions before stopping'
31
+ option :pause_on_pr, type: :boolean, default: false, desc: 'Pause after creating each PR for review'
32
+ option :verbose, type: :boolean, aliases: '-v', default: false, desc: 'Show verbose output'
33
+ def start(goal)
34
+ check_prerequisites!
35
+
36
+ criteria = options[:criteria] || prompt_for_criteria
37
+
38
+ state = State.new
39
+ loop_opts = {
40
+ no_merge: options[:no_merge],
41
+ max_sessions: options[:max_sessions],
42
+ pause_on_pr: options[:pause_on_pr],
43
+ verbose: options[:verbose]
44
+ }
45
+ loop = Loop.new(state:, model: options[:model], **loop_opts)
46
+ loop.run(goal:, criteria:)
47
+ end
48
+
49
+ desc 'resume', 'Resume previous work'
50
+ long_desc <<~DESC
51
+ Resume working from where you left off. State is loaded from
52
+ .claude-task-master/ directory.
53
+
54
+ Use this after:
55
+ - Pressing Ctrl+C to pause
56
+ - Restarting your terminal
57
+ - Coming back the next day
58
+ DESC
59
+ option :model, type: :string, default: 'sonnet', desc: 'Claude model to use'
60
+ option :no_merge, type: :boolean, default: false, desc: 'Do not auto-merge PRs'
61
+ option :max_sessions, type: :numeric, aliases: '-m', desc: 'Maximum sessions before stopping'
62
+ option :pause_on_pr, type: :boolean, default: false, desc: 'Pause after creating each PR'
63
+ option :verbose, type: :boolean, aliases: '-v', default: false, desc: 'Show verbose output'
64
+ def resume
65
+ check_prerequisites!
66
+
67
+ state = State.new
68
+ unless state.exists?
69
+ pastel = Pastel.new
70
+ puts pastel.red("No existing state found in .claude-task-master/")
71
+ puts "Run 'claude-task-master start \"your goal\"' to begin."
72
+ exit 1
73
+ end
74
+
75
+ loop_opts = {
76
+ no_merge: options[:no_merge],
77
+ max_sessions: options[:max_sessions],
78
+ pause_on_pr: options[:pause_on_pr],
79
+ verbose: options[:verbose]
80
+ }
81
+ loop = Loop.new(state:, model: options[:model], **loop_opts)
82
+ loop.resume
83
+ end
84
+
85
+ desc 'status', 'Show current status'
86
+ def status
87
+ state = State.new
88
+ pastel = Pastel.new
89
+
90
+ unless state.exists?
91
+ puts pastel.yellow("No active task. Run 'claude-task-master start \"goal\"' to begin.")
92
+ return
93
+ end
94
+
95
+ current = state.load_state
96
+
97
+ puts pastel.cyan.bold("claude-task-master status")
98
+ puts
99
+ puts "Goal: #{state.goal}"
100
+ puts "Criteria: #{state.criteria}"
101
+ puts
102
+ puts "Status: #{status_color(current[:status], pastel)}"
103
+ puts "Current task: #{current[:current_task] || 'none'}"
104
+ puts "PR: #{current[:pr_number] ? "##{current[:pr_number]}" : 'none'}"
105
+ puts "Sessions: #{current[:session_count]}"
106
+ puts "Started: #{current[:started_at]}"
107
+ puts "Updated: #{current[:updated_at]}"
108
+ puts
109
+ puts "State dir: #{state.dir}"
110
+ end
111
+
112
+ desc 'plan', 'Show the current plan'
113
+ def plan
114
+ state = State.new
115
+ pastel = Pastel.new
116
+
117
+ unless state.exists?
118
+ puts pastel.yellow("No active task.")
119
+ return
120
+ end
121
+
122
+ plan_content = state.plan
123
+ if plan_content
124
+ puts plan_content
125
+ else
126
+ puts pastel.yellow("No plan yet. Run 'claude-task-master resume' to generate one.")
127
+ end
128
+ end
129
+
130
+ desc 'logs', 'Show session logs'
131
+ option :session, type: :numeric, aliases: '-s', desc: 'Specific session number'
132
+ option :last, type: :numeric, aliases: '-l', default: 1, desc: 'Show last N sessions'
133
+ def logs
134
+ state = State.new
135
+ pastel = Pastel.new
136
+
137
+ unless state.exists?
138
+ puts pastel.yellow("No active task.")
139
+ return
140
+ end
141
+
142
+ logs_dir = File.join(state.dir, 'logs')
143
+ log_files = Dir.glob(File.join(logs_dir, 'session-*.md')).sort
144
+
145
+ if options[:session]
146
+ filename = format('session-%03d.md', options[:session])
147
+ path = File.join(logs_dir, filename)
148
+ if File.exist?(path)
149
+ puts File.read(path)
150
+ else
151
+ puts pastel.red("Session #{options[:session]} not found.")
152
+ end
153
+ else
154
+ log_files.last(options[:last]).each do |path|
155
+ puts pastel.cyan("=== #{File.basename(path)} ===")
156
+ puts File.read(path)
157
+ puts
158
+ end
159
+ end
160
+ end
161
+
162
+ desc 'version', 'Show version'
163
+ def version
164
+ puts "claude-task-master #{VERSION}"
165
+ end
166
+
167
+ desc 'doctor', 'Check prerequisites and system health'
168
+ def doctor
169
+ pastel = Pastel.new
170
+ all_good = true
171
+
172
+ puts pastel.cyan.bold("claude-task-master doctor")
173
+ puts
174
+
175
+ # Check Claude CLI
176
+ print "Claude CLI: "
177
+ if Claude.available?
178
+ version = Claude.version
179
+ puts pastel.green("#{version}")
180
+ else
181
+ puts pastel.red("NOT FOUND")
182
+ puts pastel.dim(" Install: npm install -g @anthropic-ai/claude-code")
183
+ all_good = false
184
+ end
185
+
186
+ # Check gh CLI
187
+ print "GitHub CLI: "
188
+ gh_version = `gh --version 2>&1`.split("\n").first rescue nil
189
+ if gh_version
190
+ puts pastel.green(gh_version)
191
+ else
192
+ puts pastel.red("NOT FOUND")
193
+ puts pastel.dim(" Install: https://cli.github.com/")
194
+ all_good = false
195
+ end
196
+
197
+ # Check gh auth
198
+ print "GitHub Auth: "
199
+ if GitHub.available?
200
+ puts pastel.green("authenticated")
201
+ else
202
+ puts pastel.yellow("NOT AUTHENTICATED")
203
+ puts pastel.dim(" Run: gh auth login")
204
+ all_good = false
205
+ end
206
+
207
+ # Check git
208
+ print "Git: "
209
+ git_version = `git --version 2>&1`.strip rescue nil
210
+ if git_version
211
+ puts pastel.green(git_version)
212
+ else
213
+ puts pastel.red("NOT FOUND")
214
+ all_good = false
215
+ end
216
+
217
+ # Check Ruby version
218
+ print "Ruby: "
219
+ if RUBY_VERSION >= '3.1.0'
220
+ puts pastel.green("#{RUBY_VERSION}")
221
+ else
222
+ puts pastel.yellow("#{RUBY_VERSION} (recommend 3.1+)")
223
+ end
224
+
225
+ # Check current repo
226
+ print "Git repo: "
227
+ if system('git rev-parse --git-dir > /dev/null 2>&1')
228
+ repo = GitHub.current_repo
229
+ puts pastel.green(repo || 'local repo')
230
+ else
231
+ puts pastel.yellow("not in a git repository")
232
+ end
233
+
234
+ # Check for CLAUDE.md
235
+ print "CLAUDE.md: "
236
+ if File.exist?('CLAUDE.md')
237
+ puts pastel.green("present")
238
+ else
239
+ puts pastel.dim("not found (optional)")
240
+ end
241
+
242
+ # Check for existing state
243
+ print "State dir: "
244
+ state = State.new
245
+ if state.exists?
246
+ current = state.load_state
247
+ puts pastel.cyan("exists (status: #{current[:status]})")
248
+ else
249
+ puts pastel.dim("none")
250
+ end
251
+
252
+ puts
253
+ if all_good
254
+ puts pastel.green.bold("All prerequisites met!")
255
+ else
256
+ puts pastel.red.bold("Some prerequisites missing. Fix them before running.")
257
+ exit 1
258
+ end
259
+ end
260
+
261
+ desc 'clean', 'Remove state directory and start fresh'
262
+ option :force, type: :boolean, aliases: '-f', default: false, desc: 'Skip confirmation'
263
+ def clean
264
+ state = State.new
265
+ pastel = Pastel.new
266
+
267
+ unless state.exists?
268
+ puts pastel.yellow("No state directory to clean.")
269
+ return
270
+ end
271
+
272
+ unless options[:force]
273
+ puts pastel.yellow("This will delete .claude-task-master/ and all session data.")
274
+ print "Are you sure? (y/N) "
275
+ response = $stdin.gets.chomp.downcase
276
+ unless response == 'y' || response == 'yes'
277
+ puts "Aborted."
278
+ return
279
+ end
280
+ end
281
+
282
+ FileUtils.rm_rf(state.dir)
283
+ puts pastel.green("Cleaned up .claude-task-master/")
284
+ end
285
+
286
+ desc 'context', 'Show or edit the context file'
287
+ def context
288
+ state = State.new
289
+ pastel = Pastel.new
290
+
291
+ unless state.exists?
292
+ puts pastel.yellow("No active task.")
293
+ return
294
+ end
295
+
296
+ context_path = File.join(state.dir, 'context.md')
297
+ if File.exist?(context_path)
298
+ puts File.read(context_path)
299
+ else
300
+ puts pastel.dim("No context file yet.")
301
+ end
302
+ end
303
+
304
+ desc 'progress', 'Show the progress log'
305
+ def progress
306
+ state = State.new
307
+ pastel = Pastel.new
308
+
309
+ unless state.exists?
310
+ puts pastel.yellow("No active task.")
311
+ return
312
+ end
313
+
314
+ progress_path = File.join(state.dir, 'progress.md')
315
+ if File.exist?(progress_path)
316
+ puts File.read(progress_path)
317
+ else
318
+ puts pastel.dim("No progress log yet.")
319
+ end
320
+ end
321
+
322
+ desc 'comments [PR_NUMBER]', 'Show PR review comments'
323
+ option :actionable, type: :boolean, aliases: '-a', default: false, desc: 'Show only actionable comments'
324
+ option :unresolved, type: :boolean, aliases: '-u', default: false, desc: 'Show only unresolved threads'
325
+ def comments(pr_number = nil)
326
+ state = State.new
327
+ pastel = Pastel.new
328
+
329
+ # Get PR number from state if not provided
330
+ if pr_number.nil? && state.exists?
331
+ current = state.load_state
332
+ pr_number = current[:pr_number]
333
+ end
334
+
335
+ if pr_number.nil?
336
+ puts pastel.yellow("No PR number provided and no active PR in state.")
337
+ puts "Usage: claude-task-master comments 123"
338
+ return
339
+ end
340
+
341
+ puts pastel.cyan.bold("PR ##{pr_number} Comments")
342
+ puts
343
+
344
+ if options[:unresolved]
345
+ threads = GitHub.unresolved_threads(pr_number.to_i)
346
+ if threads.empty?
347
+ puts pastel.green("No unresolved threads!")
348
+ else
349
+ puts pastel.yellow("#{threads.size} unresolved thread(s):")
350
+ threads.each do |thread|
351
+ puts
352
+ puts pastel.dim(" Author: #{thread[:author]}")
353
+ puts pastel.dim(" File: #{thread[:file_path]}:#{thread[:line]}")
354
+ puts " #{thread[:body]&.lines&.first&.strip}"
355
+ end
356
+ end
357
+ else
358
+ all_comments = GitHub.pr_comments(pr_number.to_i)
359
+ comments = options[:actionable] ? all_comments.select(&:actionable?) : all_comments
360
+
361
+ if comments.empty?
362
+ puts pastel.green(options[:actionable] ? "No actionable comments!" : "No comments!")
363
+ else
364
+ comments.each do |comment|
365
+ puts severity_badge(comment.severity, pastel)
366
+ puts pastel.dim(" #{comment.file_path}:#{comment.line_range}")
367
+ puts pastel.dim(" Author: #{comment.author}")
368
+ puts " #{comment.summary || comment.body&.lines&.first&.strip}"
369
+ puts pastel.dim(" #{comment.html_url}")
370
+ puts
371
+ end
372
+ end
373
+ end
374
+ rescue StandardError => e
375
+ puts pastel.red("Error fetching comments: #{e.message}")
376
+ end
377
+
378
+ desc 'pr [SUBCOMMAND]', 'PR management commands'
379
+ option :number, type: :numeric, aliases: '-n', desc: 'PR number (uses current if not specified)'
380
+ def pr(subcommand = 'status')
381
+ state = State.new
382
+ pastel = Pastel.new
383
+
384
+ pr_number = options[:number]
385
+ if pr_number.nil? && state.exists?
386
+ current = state.load_state
387
+ pr_number = current[:pr_number]
388
+ end
389
+
390
+ if pr_number.nil?
391
+ puts pastel.yellow("No PR number provided and no active PR in state.")
392
+ return
393
+ end
394
+
395
+ case subcommand
396
+ when 'status', 'info'
397
+ show_pr_info(pr_number, pastel)
398
+ when 'checks', 'ci'
399
+ show_pr_checks(pr_number, pastel)
400
+ when 'merge'
401
+ merge_current_pr(pr_number, pastel)
402
+ else
403
+ puts pastel.yellow("Unknown subcommand: #{subcommand}")
404
+ puts "Available: status, checks, merge"
405
+ end
406
+ end
407
+
408
+ # Default command (no args = resume or show help)
409
+ default_task :default_action
410
+
411
+ desc 'default_action', 'Default action', hide: true
412
+ def default_action
413
+ state = State.new
414
+ if state.exists?
415
+ invoke :resume
416
+ else
417
+ invoke :help
418
+ end
419
+ end
420
+
421
+ private
422
+
423
+ def check_prerequisites!
424
+ pastel = Pastel.new
425
+
426
+ unless Claude.available?
427
+ puts pastel.red("Claude CLI not found. Install it first:")
428
+ puts " npm install -g @anthropic-ai/claude-code"
429
+ exit 1
430
+ end
431
+
432
+ unless GitHub.available?
433
+ puts pastel.yellow("Warning: gh CLI not authenticated. PR features won't work.")
434
+ puts " Run: gh auth login"
435
+ end
436
+ end
437
+
438
+ def prompt_for_criteria
439
+ pastel = Pastel.new
440
+ puts pastel.cyan("What are your success criteria?")
441
+ puts pastel.dim("(e.g., 'tests pass, deploys to staging, no sentry errors for 10min')")
442
+ print "> "
443
+ $stdin.gets.chomp
444
+ end
445
+
446
+ def status_color(status, pastel)
447
+ case status
448
+ when 'success'
449
+ pastel.green(status)
450
+ when 'blocked'
451
+ pastel.red(status)
452
+ when 'ready', 'working'
453
+ pastel.cyan(status)
454
+ when 'planning'
455
+ pastel.yellow(status)
456
+ else
457
+ status
458
+ end
459
+ end
460
+
461
+ def severity_badge(severity, pastel)
462
+ case severity
463
+ when 'critical'
464
+ pastel.red.bold("[CRITICAL]")
465
+ when 'warning'
466
+ pastel.yellow.bold("[WARNING]")
467
+ when 'major'
468
+ pastel.magenta.bold("[MAJOR]")
469
+ when 'trivial'
470
+ pastel.dim("[trivial]")
471
+ when 'nitpick'
472
+ pastel.dim("[nitpick]")
473
+ when 'refactor'
474
+ pastel.blue("[refactor]")
475
+ when 'suggestion'
476
+ pastel.cyan("[suggestion]")
477
+ else
478
+ pastel.dim("[info]")
479
+ end
480
+ end
481
+
482
+ def show_pr_info(pr_number, pastel)
483
+ info = GitHub.pr_info(pr_number)
484
+
485
+ if info.nil?
486
+ puts pastel.red("Could not fetch PR ##{pr_number}")
487
+ return
488
+ end
489
+
490
+ puts pastel.cyan.bold("PR ##{info[:number]}: #{info[:title]}")
491
+ puts pastel.dim("State: ") + status_color(info[:state], pastel)
492
+ puts pastel.dim("Branch: #{info[:head_ref]} -> #{info[:base_ref]}")
493
+ puts pastel.dim("Mergeable: ") + (info[:mergeable] ? pastel.green("yes") : pastel.red("no"))
494
+ puts pastel.dim("URL: #{info[:url]}")
495
+ end
496
+
497
+ def show_pr_checks(pr_number, pastel)
498
+ status = GitHub.pr_status(pr_number)
499
+
500
+ puts pastel.cyan.bold("CI Status: ") + ci_status_color(status[:status], pastel)
501
+ puts
502
+
503
+ status[:checks].each do |check|
504
+ icon = case check[:conclusion] || check[:bucket]
505
+ when 'success', 'pass' then pastel.green('✓')
506
+ when 'failure', 'fail' then pastel.red('✗')
507
+ when 'pending' then pastel.yellow('○')
508
+ else pastel.dim('?')
509
+ end
510
+ puts " #{icon} #{check[:name]}"
511
+ end
512
+ end
513
+
514
+ def merge_current_pr(pr_number, pastel)
515
+ puts pastel.yellow("Merging PR ##{pr_number}...")
516
+
517
+ if GitHub.merge_pr(pr_number)
518
+ puts pastel.green("PR ##{pr_number} merged successfully!")
519
+ else
520
+ puts pastel.red("Failed to merge PR ##{pr_number}")
521
+ end
522
+ end
523
+
524
+ def ci_status_color(status, pastel)
525
+ case status
526
+ when :passing
527
+ pastel.green('PASSING')
528
+ when :failing
529
+ pastel.red('FAILING')
530
+ when :pending
531
+ pastel.yellow('PENDING')
532
+ else
533
+ pastel.dim('UNKNOWN')
534
+ end
535
+ end
536
+ end
537
+ end