ankit 0.0.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.
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