ankit 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/bin/ankit +6 -0
  2. data/lib/ankit/add_command.rb +28 -0
  3. data/lib/ankit/card.rb +66 -0
  4. data/lib/ankit/card_happening_command.rb +31 -0
  5. data/lib/ankit/challenge_command.rb +444 -0
  6. data/lib/ankit/coming_command.rb +90 -0
  7. data/lib/ankit/command.rb +40 -0
  8. data/lib/ankit/event.rb +62 -0
  9. data/lib/ankit/event_traversing_command.rb +37 -0
  10. data/lib/ankit/fail_command.rb +14 -0
  11. data/lib/ankit/find_command.rb +38 -0
  12. data/lib/ankit/hello_command.rb +22 -0
  13. data/lib/ankit/list_command.rb +26 -0
  14. data/lib/ankit/name_command.rb +16 -0
  15. data/lib/ankit/pass_command.rb +9 -0
  16. data/lib/ankit/round_command.rb +31 -0
  17. data/lib/ankit/runtime.rb +151 -0
  18. data/lib/ankit/score_command.rb +24 -0
  19. data/lib/ankit/text_reading_command.rb +23 -0
  20. data/lib/ankit.rb +3 -0
  21. data/test/card_test.rb +92 -0
  22. data/test/command_test.rb +406 -0
  23. data/test/data/bye_card.card +2 -0
  24. data/test/data/hello_card.card +2 -0
  25. data/test/data/hello_config.rb +4 -0
  26. data/test/data/hello_repo/anemone.journal +2 -0
  27. data/test/data/hello_repo/baobab.journal +2 -0
  28. data/test/data/hello_repo/cards/foo/hello.card +2 -0
  29. data/test/data/hello_repo/cards/foo/this_is_not_a_card.txt +1 -0
  30. data/test/data/hello_repo/cards/foo/vanilla-please.card +2 -0
  31. data/test/data/hope.card +1 -0
  32. data/test/data/luck.card +1 -0
  33. data/test/data/number_repo/anemone.journal +0 -0
  34. data/test/data/number_repo/cards/eight.card +2 -0
  35. data/test/data/number_repo/cards/five.card +2 -0
  36. data/test/data/number_repo/cards/four.card +2 -0
  37. data/test/data/number_repo/cards/one.card +2 -0
  38. data/test/data/number_repo/cards/seven.card +2 -0
  39. data/test/data/number_repo/cards/six.card +2 -0
  40. data/test/data/number_repo/cards/three.card +2 -0
  41. data/test/data/number_repo/cards/two.card +2 -0
  42. data/test/data/vanilla_repo/anemone.journal +0 -0
  43. data/test/event_test.rb +29 -0
  44. data/test/helpers.rb +54 -0
  45. data/test/progress_test.rb +99 -0
  46. data/test/runtime_test.rb +58 -0
  47. metadata +138 -0
data/bin/ankit ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'ankit'
4
+
5
+ Ankit::Runtime::run(ARGV)
6
+
@@ -0,0 +1,28 @@
1
+
2
+ require 'ankit/card'
3
+ require 'ankit/text_reading_command'
4
+
5
+ module Ankit
6
+ class AddCommand < TextReadingCommand
7
+ include CardNaming
8
+ available
9
+ define_options do |spec, options|
10
+ superclass.option_spec.call(spec, options)
11
+ spec.on("-d", "--dir DIR") { |d| options[:dir] = d }
12
+ end
13
+
14
+ def execute()
15
+ validate_options
16
+ each_text do |text|
17
+ card = Card.parse(text)
18
+ # TODO: gaurd ovewrite
19
+ # TODO: guard out-of-path write
20
+ filename = to_card_path(dest_dir, card.name)
21
+ File.open(filename, "w") { |f| f.write(text) }
22
+ runtime.stdout.write("#{filename}\n")
23
+ end
24
+ end
25
+
26
+ def dest_dir; options[:dir] || runtime.config.card_paths[0]; end
27
+ end
28
+ end
data/lib/ankit/card.rb ADDED
@@ -0,0 +1,66 @@
1
+
2
+
3
+ module Ankit
4
+ class Card
5
+
6
+ attr_reader :deckname, :source
7
+
8
+ def self.parse(text)
9
+ lines = text.split(/\r?\n/).select { |l| !/^\#/.match(l) and !/^\s*$/.match(l) }
10
+ return nil if lines.empty?
11
+
12
+ params = lines.inject({}) do |a, line|
13
+ case line
14
+ when /^(\w+)\:(.*)/
15
+ a[$1.downcase.to_sym] = $2.strip
16
+ end
17
+ a
18
+ end
19
+
20
+ self.new(params)
21
+ end
22
+
23
+ def initialize(params)
24
+ @params = params
25
+ end
26
+
27
+ def original() @params[:o]; end
28
+ def translation() @params[:t]; end
29
+
30
+ def name
31
+ original.gsub(/\W+/, "-").gsub(/^\-/, "").gsub(/\-$/, "").downcase
32
+ end
33
+
34
+ def match?(text)
35
+ return :match if plain_original == text
36
+ return :wrong if text.empty?
37
+
38
+ hiddens = decorated_original { |m| "*"*m[1].size }.chars.to_a
39
+ inside_essentials = to_enum(:diff_from_original, text).find do |ch|
40
+ ch.action != "=" && hiddens[ch.old_position] == "*"
41
+ end
42
+
43
+ inside_essentials ? :wrong : :typo
44
+ end
45
+
46
+ def diff_from_original(text, &block)
47
+ changes = Diff::LCS.sdiff(text, plain_original)
48
+ changes.map do |ch|
49
+ block.call(ch)
50
+ end.join("")
51
+ end
52
+
53
+ def decorated_original(&block)
54
+ decoed = original.gsub(/\[(.*?)\]/) { |t| block.call(Regexp.last_match) }
55
+ decoed != original ? decoed : decoed.gsub(/^(.*)$/) { |t| block.call(Regexp.last_match) }
56
+ end
57
+
58
+ def plain_original; decorated_original { |m| m[1] }; end
59
+ end
60
+
61
+ module CardNaming
62
+ def to_card_name(path) File.basename(path, ".card"); end
63
+ def to_card_path(dir, name) File.join(dir, "#{name}.card"); end
64
+ def card_wildcard_for(dir) File.join(dir, "*.card"); end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+
2
+ require 'ankit/card'
3
+ require 'ankit/command'
4
+ require 'ankit/event'
5
+ require 'ankit/event_traversing_command'
6
+ require 'ankit/round_command'
7
+ require 'fileutils'
8
+
9
+ module Ankit
10
+ module CardHappening
11
+ include CardNaming, EventTraversing, EventFormatting, RoundCounting
12
+
13
+ def make_happen(method_name, card_name, round_proceeding=latest_round)
14
+ last = EventTraversing.find_latest_event_for(runtime, card_name) || Event.for_card(card_name, "vanilla", last_round)
15
+ head = last.send(method_name, Envelope.fresh(round_proceeding))
16
+ FileUtils.touch(runtime.config.primary_journal)
17
+ open(runtime.config.primary_journal, "a") { |f| f.write("#{head.to_json}\n") }
18
+ self.round_proceeded
19
+ head
20
+ end
21
+ end
22
+
23
+ class CardHappeningCommand < Command
24
+ include EventFormatting, CardHappening
25
+
26
+ def execute()
27
+ head = make_happen(self.class::EVENT_HAPPENING, to_card_name(args[0]))
28
+ runtime.stdout.print("#{format_as_score(head)}\n")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,444 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'ankit/command'
4
+ require 'ankit/coming_command'
5
+ require 'ankit/fail_command'
6
+ require 'ankit/find_command'
7
+ require 'ankit/pass_command'
8
+ require 'ankit/round_command'
9
+ require 'highline'
10
+ require 'diff/lcs'
11
+
12
+ module Ankit
13
+
14
+ class StylableText
15
+ def self.styled_text(text, type)
16
+ case type
17
+ when :hidden
18
+ text.gsub(/\w/, "*")
19
+ when :failed
20
+ HighLine.color(text, HighLine::RED_STYLE)
21
+ when :warn
22
+ HighLine.color(text, HighLine::YELLOW_STYLE)
23
+ when :passed
24
+ HighLine.color(text, HighLine::GREEN_STYLE)
25
+ when :pending
26
+ HighLine.color(text, HighLine::DARK)
27
+ when :plus
28
+ HighLine.color(text, HighLine::RED_STYLE)
29
+ when :minus
30
+ HighLine.color(text, HighLine::REVERSE_STYLE)
31
+ else
32
+ raise
33
+ end
34
+ end
35
+
36
+ def initialize(text); @text = text; end
37
+
38
+ def decorated(type)
39
+ raise
40
+ decorated = @text.gsub(/\[(.*?)\]/) { |t|
41
+ p Regexp::last_match.offset(0)
42
+ self.class.styled_text($1, type)
43
+ }
44
+ decorated != @text ? decorated : self.class.styled_text(@text, type)
45
+ end
46
+
47
+ def diff(orig)
48
+ return @text if @text.empty?
49
+
50
+ changes = Diff::LCS.sdiff(orig, @text)
51
+ changes.map do |ch|
52
+ case ch.action
53
+ when "="
54
+ ch.new_element
55
+ when "!"
56
+ self.class.styled_text(ch.new_element, :plus)
57
+ when "-"
58
+ self.class.styled_text(ch.old_element, :minus)
59
+ when "+"
60
+ self.class.styled_text(ch.new_element, :plus)
61
+ else
62
+ raise
63
+ end
64
+ end.join("")
65
+ end
66
+ end
67
+
68
+ class Card
69
+ def hidden_original; decorated_original{ |m| StylableText.styled_text(m[1], :hidden) }; end
70
+ def hilighted_diff_from_original(text)
71
+ diff_from_original(text) do |ch|
72
+ case ch.action
73
+ when "="
74
+ ch.new_element
75
+ when "!"
76
+ StylableText.styled_text(ch.new_element, :plus)
77
+ when "-"
78
+ StylableText.styled_text(ch.old_element, :minus)
79
+ when "+"
80
+ StylableText.styled_text(ch.new_element, :plus)
81
+ else
82
+ raise
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ module Challenge
89
+ class Slot < Struct.new(:path, :rating, :event)
90
+ def maturity; self.event ? self.event.maturity : 0; end
91
+ end
92
+
93
+ class Session < Struct.new(:runtime, :npassed, :nfailed, :mature_names)
94
+ def self.make(runtime)
95
+ self.new(runtime, 0, 0, [])
96
+ end
97
+
98
+ def summary_text
99
+ total = self.npassed + self.nfailed
100
+ return "" if 0 == total
101
+ "#{self.npassed}/#{total} = #{self.npassed.to_f/total.to_f}, #mature: #{mature_names.size}"
102
+ end
103
+ end
104
+
105
+ class Progress
106
+ include CardHappening, CardNaming, RoundCounting
107
+
108
+ attr_reader :session, :slots, :index, :this_round
109
+
110
+ def initialize(session, slots)
111
+ @session, @slots, @index = session, slots, 0
112
+ @this_round = latest_round
113
+ end
114
+
115
+ def current_card
116
+ # XXX: might be better to cache
117
+ Card.parse(open(current_path, "r") { |f| f.read })
118
+ end
119
+
120
+ def round_delta
121
+ latest_round - this_round
122
+ end
123
+
124
+ def runtime; @session.runtime; end
125
+ def last_slot; @slots[@index-1]; end
126
+ def current_slot; @slots[@index]; end
127
+ def current_path; current_slot.path; end
128
+ def size; @slots.size; end
129
+ def over?; @slots.size <= @index; end
130
+ def npassed; @slots.count { |c| c.rating == :passed }; end
131
+ def nfailed; @slots.count { |c| c.rating == :failed }; end
132
+
133
+ def already_failed?; current_slot.rating == :failed; end
134
+
135
+ def attack
136
+ current_slot.rating = :attacking unless current_slot.rating
137
+ self
138
+ end
139
+
140
+ def fail
141
+ unless already_failed?
142
+ last_slot = current_slot
143
+ last_slot.rating = :failed
144
+ last_slot.event = make_happen(FailCommand::EVENT_HAPPENING, to_card_name(current_path), this_round)
145
+ end
146
+
147
+ self
148
+ end
149
+
150
+ def pass
151
+ unless already_failed?
152
+ last_slot = current_slot
153
+ last_slot.rating = :passed
154
+ last_slot.event = make_happen(PassCommand::EVENT_HAPPENING, to_card_name(current_path), this_round)
155
+ end
156
+
157
+ @index += 1
158
+ self
159
+ end
160
+
161
+ def indicator
162
+ @slots.inject("") do |a, i|
163
+ a += case i.rating
164
+ when :failed; "x"
165
+ when :passed; "o"
166
+ when :attacking; "*"
167
+ else; "-"
168
+ end
169
+ end
170
+ end
171
+
172
+ def styled_indicator
173
+ indicator.to_enum(:each_char).map do |i|
174
+ case i
175
+ when "x"; StylableText.styled_text(i, :failed)
176
+ when "o"; StylableText.styled_text(i, :passed)
177
+ when "-"; StylableText.styled_text(i, :pending)
178
+ else; i
179
+ end
180
+ end.join
181
+ end
182
+
183
+ def update_session
184
+ session.npassed += npassed
185
+ session.nfailed += nfailed
186
+ session.mature_names = (session.mature_names + slots.select{ |s| 1 < s.maturity }.map(&:path)).uniq
187
+ end
188
+
189
+ def maturities; slots.map(&:maturity); end
190
+ end
191
+
192
+ class State
193
+ attr_reader :progress, :last_answer
194
+
195
+ def initialize(progress, last_answer=nil)
196
+ @progress, @last_answer = progress, last_answer
197
+ end
198
+
199
+ def keep_pumping_until(&block)
200
+ state = self
201
+ until block.call(state)
202
+ state = state.pump
203
+ end
204
+ end
205
+
206
+ def clear_screen
207
+ runtime.stdout.print("\033[2J")
208
+ h = HighLine::SystemExtensions.terminal_size[0]
209
+ runtime.stdout.print("\033[#{h}0A")
210
+ end
211
+
212
+ def say(msg, type=:progress)
213
+ line.say(message_for(msg, type))
214
+ end
215
+
216
+ def show_summary_status
217
+ line.say("Round #{progress.this_round}: #{progress.styled_indicator}")
218
+ end
219
+
220
+ def show_breaking_status
221
+ show_summary_status
222
+ line.say("Maturity: #{progress.maturities.map(&:to_s).join(',')}")
223
+ line.say("Session: #{progress.session.summary_text}")
224
+ line.say("next round will be +#{progress.round_delta}")
225
+ end
226
+
227
+ def show_header
228
+ show_summary_status
229
+ line.say("\n")
230
+ end
231
+
232
+ def show_and_ask_enter(msg, type)
233
+ line.ask(message_for(msg, type) + " ") { |q| q.readline = true }
234
+ end
235
+
236
+ def ask(msg="", type=:ask)
237
+ line.ask(message_for(msg, type)) { |q| q.readline = true }
238
+ end
239
+
240
+ def over?; false; end
241
+ def runtime; progress.runtime; end
242
+ def line; progress.runtime.line; end
243
+ def session; progress.session; end
244
+
245
+ private
246
+
247
+ def message_for(body, type)
248
+ case type
249
+ when :progress
250
+ " "
251
+ when :fail
252
+ StylableText.styled_text("FAIL: ", :failed)
253
+ when :typo
254
+ StylableText.styled_text("TYPO: ", :warn)
255
+ when :pass
256
+ StylableText.styled_text("PASS: ", :passed)
257
+ when :ask
258
+ " > "
259
+ when :hit_return
260
+ " < "
261
+ when :cont
262
+ " "
263
+ else
264
+ raise "Unknown header type:#{type}"
265
+ end + body
266
+ end
267
+
268
+ def ask_header; " > "; end
269
+ end
270
+
271
+ # XXX: test
272
+ class EditState < State
273
+ def pump
274
+ # XXX: makes configurable
275
+ system("vi " + progress.current_path)
276
+ QuestionState.new(progress)
277
+ end
278
+ end
279
+
280
+ module CommandRecognizing
281
+ def may_pump_command(answered)
282
+ /^\/(\w+)/.match(answered) ? pump_command($1) : nil
283
+ end
284
+
285
+ def pump_command(command)
286
+ case command
287
+ when "edit"
288
+ EditState.new(progress)
289
+ else
290
+ raise
291
+ end
292
+ end
293
+ end
294
+
295
+ class QuestionState < State
296
+ include CommandRecognizing
297
+
298
+ def pump
299
+ progress.attack
300
+ clear_screen
301
+ show_header
302
+ card = progress.current_card
303
+ say("#{card.translation}")
304
+ say("#{card.hidden_original}", :cont)
305
+ answered = ask().strip
306
+ c = may_pump_command(answered)
307
+ return c if c
308
+ case card.match?(answered.strip)
309
+ when :match
310
+ PassedState.new(progress, answered)
311
+ when :wrong
312
+ FailedState.new(progress, answered)
313
+ when :typo
314
+ TypoState.new(progress, answered)
315
+ else
316
+ raise
317
+ end
318
+ end
319
+ end
320
+
321
+ class FailedStateBase < State
322
+ include CommandRecognizing
323
+
324
+ def pump
325
+ diff_from_original = progress.current_card.hilighted_diff_from_original(last_answer)
326
+ say("#{diff_from_original}", decoration_type)
327
+ answered = ask("", :hit_return)
328
+ c = may_pump_command(answered)
329
+ return c if c
330
+ mark_progress
331
+ QuestionState.new(progress)
332
+ end
333
+
334
+ end
335
+
336
+ class FailedState < FailedStateBase
337
+ def mark_progress
338
+ progress.fail
339
+ end
340
+
341
+ def decoration_type
342
+ :fail
343
+ end
344
+ end
345
+
346
+ class TypoState < FailedStateBase
347
+ def mark_progress
348
+
349
+ end
350
+
351
+ def decoration_type
352
+ :typo
353
+ end
354
+ end
355
+
356
+ class PassedState < State
357
+ include CommandRecognizing
358
+
359
+ def pump
360
+ progress.pass
361
+ last_maturity = progress.last_slot.event.maturity
362
+ say("Maturity: #{last_maturity}", :pass)
363
+ answered = ask("", :hit_return)
364
+ c = may_pump_command(answered)
365
+ return c if c
366
+ progress.over? ? BreakingState.new(progress) : QuestionState.new(progress)
367
+ end
368
+ end
369
+
370
+ class BreakingState < State
371
+ def pump
372
+ progress.update_session
373
+ clear_screen
374
+ show_breaking_status
375
+
376
+ case ask_more
377
+ when :yes
378
+ initial_state
379
+ when :no
380
+ OverState.new(progress)
381
+ else
382
+ # TODO: handle help
383
+ self
384
+ end
385
+ end
386
+
387
+ def ask_more
388
+ case line.ask("More(y/n/?) ").strip
389
+ when /^y/, ""
390
+ :yes
391
+ when /^n/
392
+ :no
393
+ else
394
+ :help
395
+ end
396
+ end
397
+
398
+ def coming_limit
399
+ progress.size
400
+ end
401
+ end
402
+
403
+ class OverState < State
404
+ def over?; true; end
405
+ end
406
+
407
+ module Approaching
408
+ def initial_state
409
+ slots = Coming.coming_paths(self.runtime).take(self.coming_limit).map { |path| Slot.new(path, nil) }
410
+ QuestionState.new(Progress.new(self.session, slots))
411
+ end
412
+ end
413
+
414
+ class BreakingState; include Approaching; end
415
+ end
416
+
417
+ class ChallengeCommand < Command
418
+ include Challenge, RoundCounting, Coming, Finding
419
+ include Challenge::Approaching
420
+ available
421
+
422
+ define_options do |spec, options|
423
+ spec.on("-l", "--limit N") { |n| options[:limit] = n.to_i }
424
+ end
425
+
426
+ DEFAULT_COUNT = 5
427
+
428
+ def session; @session ||= Challenge::Session.make(runtime); end
429
+
430
+ def execute()
431
+ Signal.trap("INT") do
432
+ STDERR.print("Quit.\n")
433
+ exit(0)
434
+ end
435
+
436
+ initial_state.keep_pumping_until { |state| state.over? }
437
+ Signal.trap("INT", "DEFAULT")
438
+ end
439
+
440
+ def coming_limit
441
+ options[:limit] or DEFAULT_COUNT
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,90 @@
1
+
2
+ require 'ankit/command'
3
+ require 'ankit/event_traversing_command'
4
+ require 'ankit/list_command'
5
+ require 'ankit/round_command'
6
+ require 'ankit/event'
7
+
8
+ module Ankit
9
+ class ComingCommand < Command
10
+ include RoundCounting
11
+
12
+ available
13
+ define_options do |spec, options|
14
+ spec.on("-n", "--name") { options[:name] = true }
15
+ end
16
+
17
+ DEFAULT_COUNT = 6
18
+
19
+ def find_command; @find_command ||= FindCommand.new(runtime); end
20
+
21
+ def execute()
22
+ toprint = to_enum(options[:name] ? :each_coming_names : :each_coming_paths)
23
+ toprint.take(0 <= count ? count : name_to_events.size).each { |i| runtime.stdout.print("#{i}\n") }
24
+ end
25
+
26
+ def each_coming_names(&block); each_coming_events { |e| block.call(e.name) }; end
27
+
28
+ def each_coming_paths(&block)
29
+ each_coming_events do |event|
30
+ found = self.find_command.path_for(event.name)
31
+ block.call(found) if found
32
+ end
33
+ end
34
+
35
+ def each_coming_events(&block)
36
+ name_to_events.values.sort_by(&:next_round).each(&block)
37
+ end
38
+
39
+ def each_existing_events(&block)
40
+ name_to_existing_events.values.sort_by(&:next_round).each(&block)
41
+ end
42
+
43
+ def name_to_events
44
+ @name_to_events ||= compute_name_to_events
45
+ end
46
+
47
+ def name_to_existing_events
48
+ EventTraversingCommand.new(runtime).to_enum(:each_event).reduce({}) do |a, e|
49
+ existing = a[e.name]
50
+ a[e.name] = e if existing.nil? or existing.round < e.round
51
+ a
52
+ end
53
+ end
54
+
55
+ def name_to_vanilla_events
56
+ vanilla_round = last_round
57
+ ListCommand.new(runtime).to_enum(:each_card_name).reduce({}) do |a, name|
58
+ a[name] = Event.for_card(name, "vanilla", Envelope.fresh(vanilla_round))
59
+ a
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def compute_name_to_events
66
+ existing = name_to_existing_events
67
+ vanilla = name_to_vanilla_events
68
+ vanilla.merge(existing)
69
+ end
70
+
71
+ def count
72
+ args.empty? ? DEFAULT_COUNT : args[0].to_i
73
+ end
74
+ end
75
+
76
+ module Coming
77
+ def self.coming_events(runtime)
78
+ ComingCommand.new(runtime).to_enum(:each_coming_events).to_a
79
+ end
80
+
81
+ def self.coming_paths(runtime)
82
+ ComingCommand.new(runtime).to_enum(:each_coming_paths).to_a
83
+ end
84
+
85
+ def self.existing_events(runtime)
86
+ ComingCommand.new(runtime).to_enum(:each_existing_events).to_a
87
+ end
88
+ end
89
+
90
+ end
@@ -0,0 +1,40 @@
1
+
2
+ require 'optparse'
3
+
4
+ module Ankit
5
+ class Command
6
+ attr_reader :runtime, :options, :args
7
+
8
+ COMMANDS = []
9
+
10
+ def self.available
11
+ COMMANDS.push(self)
12
+ end
13
+
14
+ def self.by_name
15
+ COMMANDS.inject({}) do |a, cls|
16
+ name = /(.*)\:\:(\w+)Command/.match(cls.name).to_a[-1].downcase
17
+ a[name] = cls
18
+ a
19
+ end
20
+ end
21
+
22
+ def self.define_options(&block) @option_spec = block end
23
+ def self.option_spec; @option_spec; end
24
+
25
+ def initialize(runtime, args=[])
26
+ @runtime = runtime
27
+ @options = {}
28
+ @args = OptionParser.new do |spec|
29
+ self.class.option_spec.call(spec, @options) if self.class.option_spec
30
+ end.parse(args)
31
+ end
32
+ end
33
+
34
+ class BadOptions < StandardError; end
35
+
36
+ class HelloCommand < Command
37
+ available
38
+ def execute; end
39
+ end
40
+ end