aia 2.0.0.0.pre.alpha → 2.0.0.0.pre.beta2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +3 -223
  4. data/.reek.yml +160 -0
  5. data/.rubocop_strict.yml +15 -0
  6. data/.version +1 -1
  7. data/CHANGELOG.md +10 -5
  8. data/README.md +1 -1
  9. data/Rakefile +0 -113
  10. data/_typos.toml +10 -0
  11. data/aia.gemspec +92 -0
  12. data/config/aia.yml +13 -0
  13. data/lib/aia/chat_loop.rb +8 -52
  14. data/lib/aia/config/cli_parser.rb +66 -57
  15. data/lib/aia/config/mcp_parser.rb +27 -19
  16. data/lib/aia/config/validator.rb +83 -94
  17. data/lib/aia/config.rb +3 -2
  18. data/lib/aia/content_extractor.rb +2 -0
  19. data/lib/aia/cost_calculator.rb +1 -0
  20. data/lib/aia/debate_handler.rb +28 -20
  21. data/lib/aia/delegate_handler.rb +7 -3
  22. data/lib/aia/directive.rb +10 -8
  23. data/lib/aia/directives/configuration_directives.rb +115 -101
  24. data/lib/aia/directives/context_directives.rb +31 -28
  25. data/lib/aia/directives/execution_directives.rb +18 -13
  26. data/lib/aia/directives/model_directives.rb +134 -154
  27. data/lib/aia/directives/trakflow_directives.rb +27 -9
  28. data/lib/aia/directives/utility_directives.rb +71 -71
  29. data/lib/aia/directives/web_and_file_directives.rb +63 -50
  30. data/lib/aia/layered_orchestrator.rb +66 -43
  31. data/lib/aia/logger.rb +21 -21
  32. data/lib/aia/mcp_connection_manager.rb +50 -40
  33. data/lib/aia/mcp_server_config.rb +30 -0
  34. data/lib/aia/mcp_utility.rb +7 -4
  35. data/lib/aia/mention_router.rb +7 -50
  36. data/lib/aia/network_builder.rb +10 -5
  37. data/lib/aia/network_memory_manager.rb +6 -6
  38. data/lib/aia/pipeline_orchestrator.rb +20 -10
  39. data/lib/aia/plugin_monitor.rb +9 -6
  40. data/lib/aia/prompt_decomposer.rb +5 -3
  41. data/lib/aia/prompt_handler.rb +43 -49
  42. data/lib/aia/robot_factory.rb +12 -8
  43. data/lib/aia/robot_namer.rb +2 -8
  44. data/lib/aia/session.rb +7 -3
  45. data/lib/aia/session_tracker.rb +39 -41
  46. data/lib/aia/similarity_scorer.rb +5 -3
  47. data/lib/aia/special_mode_handler.rb +52 -32
  48. data/lib/aia/speech.rb +67 -0
  49. data/lib/aia/startup_coordinator.rb +1 -0
  50. data/lib/aia/streaming_runner.rb +6 -3
  51. data/lib/aia/system_prompt_assembler.rb +11 -7
  52. data/lib/aia/task_coordinator.rb +10 -5
  53. data/lib/aia/timing.rb +15 -0
  54. data/lib/aia/tool_filter.rb +1 -0
  55. data/lib/aia/tool_filter_strategy.rb +28 -25
  56. data/lib/aia/tool_introspection.rb +17 -0
  57. data/lib/aia/tool_loader.rb +12 -6
  58. data/lib/aia/tools/task_board_tool.rb +1 -0
  59. data/lib/aia/trakflow_bridge.rb +5 -3
  60. data/lib/aia/turn_state.rb +1 -0
  61. data/lib/aia/ui_presenter.rb +67 -59
  62. data/lib/aia/utility.rb +5 -3
  63. data/lib/aia/variable_input_collector.rb +1 -0
  64. data/lib/aia/verification_network.rb +1 -2
  65. data/lib/aia.rb +16 -0
  66. metadata +15 -9
  67. data/.quality/flay_baseline.txt +0 -1
  68. data/.quality/flog_baseline.txt +0 -29
  69. data/.quality/reek_baseline.txt +0 -80
@@ -16,6 +16,7 @@ module AIA
16
16
  #
17
17
  # @param config [AIA::Config] the configuration to validate
18
18
  # @return [AIA::Config] the validated configuration
19
+ # :reek:TooManyStatements -- top-level validation pipeline: one named step per line, executed in fixed order
19
20
  def tailor(config)
20
21
  remaining_args = config.remaining_args&.dup || []
21
22
  config.remaining_args = nil
@@ -101,24 +102,25 @@ module AIA
101
102
 
102
103
  def handle_executable_prompt(config)
103
104
  return unless config.prompt_id.nil?
104
- return unless config.context_files && !config.context_files.empty?
105
105
 
106
- candidate = config.context_files.first
106
+ files = config.context_files
107
+ return unless files && !files.empty?
108
+
109
+ candidate = files.first
107
110
  return unless File.exist?(candidate) && File.readable?(candidate)
108
111
 
109
112
  first_line = File.open(candidate, &:readline).strip rescue nil
110
113
  return unless first_line&.start_with?('#!')
111
114
 
112
- config.context_files.shift
115
+ files.shift
113
116
  config.executable_prompt_content = File.read(candidate).lines[1..].join
114
117
  config.prompt_id = '__EXECUTABLE_PROMPT__'
115
118
  end
116
119
 
117
120
  def handle_stdin_as_prompt(config)
118
121
  return unless config.prompt_id.nil?
119
- return unless config.stdin_content && !config.stdin_content.strip.empty?
120
-
121
122
  content = config.stdin_content
123
+ return unless content && !content.strip.empty?
122
124
 
123
125
  if content.lines.first&.strip&.start_with?('#!')
124
126
  content = content.lines[1..].join
@@ -137,24 +139,24 @@ module AIA
137
139
  end
138
140
 
139
141
  def process_role_configuration(config)
140
- role = config.prompts.role
142
+ prompts = config.prompts
143
+ role = prompts.role
141
144
  return if role.nil? || role.empty?
142
145
 
143
- roles_prefix = config.prompts.roles_prefix
146
+ roles_prefix = prompts.roles_prefix
144
147
  unless AIA::SkillUtils.path_based_id?(role) || roles_prefix.nil? || roles_prefix.empty? || role.start_with?(roles_prefix)
145
- config.prompts.role = "#{roles_prefix}/#{role}"
146
- role = config.prompts.role
148
+ role = "#{roles_prefix}/#{role}"
149
+ prompts.role = role
147
150
  end
148
151
 
149
- config.prompts.roles_dir ||= File.join(config.prompts.dir, roles_prefix.to_s)
152
+ prompts.roles_dir ||= File.join(prompts.dir, roles_prefix.to_s)
150
153
 
151
154
  return if config.flags&.chat == true
152
155
 
153
156
  return unless config.prompt_id.nil? || config.prompt_id.empty?
154
- return if role.nil? || role.empty?
155
157
  config.prompt_id = role
156
- config.pipeline.prepend(config.prompt_id)
157
- config.prompts.role = ''
158
+ config.pipeline.prepend(role)
159
+ prompts.role = ''
158
160
  end
159
161
 
160
162
  def validate_plugins_dir(config)
@@ -172,9 +174,10 @@ module AIA
172
174
  end
173
175
 
174
176
  def normalize_boolean_flags(config)
175
- normalize_boolean_flag(config.flags, :chat)
176
- normalize_boolean_flag(config.flags, :fuzzy)
177
- normalize_boolean_flag(config.flags, :consensus)
177
+ flags = config.flags
178
+ normalize_boolean_flag(flags, :chat)
179
+ normalize_boolean_flag(flags, :fuzzy)
180
+ normalize_boolean_flag(flags, :consensus)
178
181
  end
179
182
 
180
183
  def normalize_boolean_flag(flags_section, flag)
@@ -211,21 +214,19 @@ module AIA
211
214
  else
212
215
  label = mcp_filter_active?(config) ? "Active" : "Configured"
213
216
  puts "#{label} MCP servers:\n\n"
214
- servers.each do |server|
215
- name = AIA::Utility.server_name(server) || '(unnamed)'
216
- transport = server[:transport] || server['transport'] || {}
217
- command = transport[:command] || transport['command'] || server[:command] || server['command'] || '(no command)'
218
- args = transport[:args] || transport['args'] || server[:args] || server['args'] || []
219
- args_str = args.empty? ? '' : " #{args.join(' ')}"
220
- puts " #{name}"
221
- puts " command: #{command}#{args_str}"
222
- puts
223
- end
217
+ servers.each { |server| print_mcp_server(McpServerConfig.from_hash(server)) }
224
218
  end
225
219
 
226
220
  :early_exit
227
221
  end
228
222
 
223
+ def print_mcp_server(server_config)
224
+ args_str = server_config.args.empty? ? '' : " #{server_config.args.join(' ')}"
225
+ puts " #{server_config.name || '(unnamed)'}"
226
+ puts " command: #{server_config.command || '(no command)'}#{args_str}"
227
+ puts
228
+ end
229
+
229
230
  def handle_list_tools(config)
230
231
  return unless config.list_tools
231
232
 
@@ -266,8 +267,8 @@ module AIA
266
267
  end
267
268
 
268
269
  def print_tool_terminal(tool, width, indent)
269
- name = tool.respond_to?(:name) ? tool.name : tool.class.name
270
- desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''
270
+ name = ToolIntrospection.tool_name(tool)
271
+ desc = ToolIntrospection.tool_description(tool).strip
271
272
 
272
273
  puts " #{name}"
273
274
  unless desc.empty?
@@ -278,6 +279,7 @@ module AIA
278
279
  puts
279
280
  end
280
281
 
282
+ # :reek:TooManyStatements -- sequential markdown report: header, local tools section, one section per MCP server
281
283
  def list_tools_markdown(local_tools, mcp_tool_groups)
282
284
  total = local_tools.size + mcp_tool_groups.values.sum(&:size)
283
285
  sources = 1 + mcp_tool_groups.size
@@ -301,8 +303,8 @@ module AIA
301
303
  end
302
304
 
303
305
  def print_tool_markdown(tool)
304
- name = tool.respond_to?(:name) ? tool.name : tool.class.name
305
- desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''
306
+ name = ToolIntrospection.tool_name(tool)
307
+ desc = ToolIntrospection.tool_description(tool).strip
306
308
 
307
309
  puts "### `#{name}`"
308
310
  puts
@@ -360,7 +362,13 @@ module AIA
360
362
  end
361
363
 
362
364
  def load_local_tools(config)
363
- # Load required libraries
365
+ require_configured_libs(config)
366
+ require_tool_files(config)
367
+ instantiable_tool_classes
368
+ end
369
+
370
+ # :reek:DuplicateMethodCall -- each e.message belongs to a different rescue clause exception; nothing to hoist
371
+ def require_configured_libs(config)
364
372
  Array(config.require_libs).each do |lib|
365
373
  require lib
366
374
  rescue LoadError => e
@@ -369,20 +377,10 @@ module AIA
369
377
  rescue StandardError => e
370
378
  $stderr.puts "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
371
379
  end
380
+ end
372
381
 
373
- # Load tool files
374
- Array(config.tools&.paths).each do |path|
375
- expanded = File.expand_path(path)
376
- if File.exist?(expanded)
377
- require expanded
378
- else
379
- $stderr.puts "Warning: Tool file not found: #{path}"
380
- end
381
- rescue LoadError, StandardError => e
382
- $stderr.puts "Warning: Failed to load tool '#{path}': #{e.message}"
383
- end
384
-
385
- # Scan ObjectSpace for RubyLLM::Tool subclasses
382
+ # Scan ObjectSpace for RubyLLM::Tool subclasses that can be instantiated.
383
+ def instantiable_tool_classes
386
384
  ObjectSpace.each_object(Class).select do |klass|
387
385
  next false unless defined?(RubyLLM::Tool) && klass < RubyLLM::Tool
388
386
 
@@ -402,65 +400,55 @@ module AIA
402
400
  result.empty? ? normalized : result
403
401
  end
404
402
 
405
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
403
+ # :reek:TooManyStatements -- per-server connect loop with progress prints on each outcome path
406
404
  def load_mcp_tools_grouped(config)
407
405
  servers = filter_mcp_servers(config)
408
406
  return {} if servers.empty?
409
407
 
410
408
  quiet_mcp_logger
411
409
 
412
- groups = {}
413
- default_timeout = 8_000
410
+ servers.each_with_object({}) do |server, groups|
411
+ server_config = McpServerConfig.from_hash(server)
412
+ $stderr.print "MCP: Connecting to #{server_config.name}..."
413
+ $stderr.flush
414
414
 
415
- # rubocop:disable Metrics/BlockLength
416
- servers.each do |server|
417
- name = AIA::Utility.server_name(server)
418
- transport = server[:transport] || server['transport'] || {}
419
- command = transport[:command] || transport['command'] || server[:command] || server['command']
420
- args = transport[:args] || transport['args'] || server[:args] || server['args'] || []
421
- env = transport[:env] || transport['env'] || server[:env] || server['env'] || {}
415
+ tools = collect_server_tools(server_config)
416
+ if tools
417
+ groups[server_config.name] = tools
418
+ $stderr.puts " #{tools.size} tools"
419
+ else
420
+ $stderr.puts " failed"
421
+ end
422
+ rescue StandardError => e
423
+ $stderr.puts " error: #{e.message}"
424
+ end
425
+ end
422
426
 
423
- raw_timeout = server[:timeout] || server['timeout'] || default_timeout
424
- timeout = raw_timeout.to_i < 1000 ? (raw_timeout.to_i * 1000) : raw_timeout.to_i
425
- timeout = [timeout, 30_000].min
427
+ # Connect to one server and return its tools, or nil when the client
428
+ # never comes alive.
429
+ def collect_server_tools(server_config)
430
+ mcp_add_client(server_config)
431
+ client = RubyLLM::MCP.clients[server_config.name]
432
+ client.start
433
+ return nil unless client.alive?
426
434
 
427
- mcp_config = { command: command, args: Array(args) }
428
- mcp_config[:env] = env unless env.empty?
435
+ client.tools rescue []
436
+ end
429
437
 
430
- begin
431
- $stderr.print "MCP: Connecting to #{name}..."
432
- $stderr.flush
433
-
434
- client = begin
435
- RubyLLM::MCP.add_client(
436
- name: name, transport_type: :stdio,
437
- config: mcp_config, request_timeout: timeout, start: false
438
- )
439
- rescue ArgumentError
440
- RubyLLM::MCP.add_client(
441
- name: name, transport_type: :stdio,
442
- config: mcp_config, start: false
443
- )
444
- end
445
-
446
- client = RubyLLM::MCP.clients[name]
447
- client.start
448
-
449
- if client.alive?
450
- server_tools = client.tools rescue []
451
- groups[name] = server_tools
452
- $stderr.puts " #{server_tools.size} tools"
453
- else
454
- $stderr.puts " failed"
455
- end
456
- rescue StandardError => e
457
- $stderr.puts " error: #{e.message}"
458
- end
459
- # rubocop:enable Metrics/BlockLength
460
- end
461
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
438
+ def mcp_add_client(server_config)
439
+ mcp_config = { command: server_config.command, args: server_config.args }
440
+ mcp_config[:env] = server_config.env unless server_config.env.empty?
462
441
 
463
- groups
442
+ RubyLLM::MCP.add_client(
443
+ name: server_config.name, transport_type: :stdio,
444
+ config: mcp_config, request_timeout: server_config.timeout_ms, start: false
445
+ )
446
+ rescue ArgumentError
447
+ # Older ruby_llm-mcp versions do not accept request_timeout
448
+ RubyLLM::MCP.add_client(
449
+ name: server_config.name, transport_type: :stdio,
450
+ config: mcp_config, start: false
451
+ )
464
452
  end
465
453
  # rubocop:enable Metrics/ModuleLength
466
454
 
@@ -500,9 +488,10 @@ module AIA
500
488
  end
501
489
 
502
490
  def prepare_pipeline(config)
503
- return if config.prompt_id.nil? || config.prompt_id.empty? || config.prompt_id == config.pipeline.first
491
+ prompt_id = config.prompt_id
492
+ return if prompt_id.nil? || prompt_id.empty? || prompt_id == config.pipeline.first
504
493
 
505
- config.pipeline.prepend(config.prompt_id)
494
+ config.pipeline.prepend(prompt_id)
506
495
  end
507
496
 
508
497
  def validate_pipeline_prompts(config)
data/lib/aia/config.rb CHANGED
@@ -259,7 +259,7 @@ module AIA
259
259
  end
260
260
 
261
261
  # Convert config to hash (for dump, etc.)
262
- # rubocop:disable Metrics/AbcSize
262
+ # rubocop:disable-next Metrics/AbcSize
263
263
  def to_h
264
264
  {
265
265
  service: service.to_h,
@@ -287,11 +287,12 @@ module AIA
287
287
  context_files: context_files
288
288
  }
289
289
  end
290
- # rubocop:enable Metrics/AbcSize
291
290
 
292
291
  private
293
292
 
294
293
  # Load a config file that REPLACES the user's personal config.
294
+ # :reek:TooManyStatements -- single case dispatch mapping YAML keys to typed config setters; one branch per key family
295
+ # :reek:DuplicateMethodCall -- Array(value) sits in mutually exclusive case branches; hoisting it would coerce hash-section values too
295
296
  def load_extra_config(path)
296
297
  path = File.expand_path(path)
297
298
 
@@ -34,6 +34,7 @@ module AIA
34
34
  #
35
35
  # @param flow_result [SimpleFlow::Result]
36
36
  # @return [String]
37
+ # :reek:TooManyStatements -- one pass duck-types reply/content/duration into labeled sections
37
38
  def extract_network_content(flow_result)
38
39
  parts = []
39
40
  flow_result.context.each do |task_name, robot_result|
@@ -97,6 +98,7 @@ module AIA
97
98
  # @param tracker [SessionTracker, nil] to record the turn
98
99
  # @param decisions [Hash, nil] routing decisions for tracker
99
100
  # @return [String] the extracted content
101
+ # :reek:LongParameterList { max_params: 7 } -- presentation seam: each keyword is an independent optional aspect
100
102
  def present_result(result, ui_presenter:, streamed_content: nil, prompt: nil, elapsed: nil, tracker: nil, decisions: nil)
101
103
  content = streamed_content || extract_content(result)
102
104
 
@@ -13,6 +13,7 @@ module AIA
13
13
  # @param output_tokens [Integer]
14
14
  # @return [Hash] { available: true, total_cost:, input_cost:, output_cost: }
15
15
  # or { available: false } when pricing is unavailable
16
+ # :reek:TooManyStatements -- linear price lookup: guard, registry fetch, per-direction cost math, result hash
16
17
  def self.calculate(model_id:, input_tokens:, output_tokens:)
17
18
  return { available: false } unless model_id && defined?(RubyLLM::Models)
18
19
 
@@ -36,7 +36,7 @@ module AIA
36
36
  #
37
37
  # @param context [HandlerContext] — reads context.prompt
38
38
  # @return [String, nil] formatted debate results, or nil if not applicable
39
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
39
+ # :reek:TooManyStatements -- debate orchestration: per-round async fan-out, convergence check, tracking; stages share round state
40
40
  def handle(context)
41
41
  prompt = context.prompt
42
42
  return nil unless @robot.network?
@@ -55,33 +55,19 @@ module AIA
55
55
  rounds = []
56
56
 
57
57
  MAX_ROUNDS.times do |round|
58
+ round_no = round + 1
58
59
  round_context = build_round_context(prompt, rounds)
59
60
 
60
- round_results = Sync do
61
- barrier = Async::Barrier.new
62
- tasks = robots.map do |robot|
63
- barrier.async do
64
- @ui_presenter.display_info(" Round #{round + 1}: #{robot.name}...")
65
- result = robot.run(round_context, mcp: :inherit, tools: :inherit)
66
- content = extract_content(result)
67
- write_to_memory(round, robot.name, content)
68
- { robot: robot.name, content: content }
69
- rescue => e
70
- FailedResponse.new(robot_name: robot.name, error_message: e.message)
71
- end
72
- end
73
- barrier.wait
74
- tasks.map(&:wait)
75
- end
61
+ round_results = run_debate_round(robots, round_context, round, round_no)
76
62
 
77
- raise DebateError, "All robots failed in round #{round + 1}" if
63
+ raise DebateError, "All robots failed in round #{round_no}" if
78
64
  round_results.all?(FailedResponse)
79
65
 
80
66
  previous = rounds.last
81
67
  rounds << round_results
82
68
 
83
69
  if converged?(round, round_results, previous)
84
- @ui_presenter.display_info(" Converged in round #{round + 1}.")
70
+ @ui_presenter.display_info(" Converged in round #{round_no}.")
85
71
  break
86
72
  end
87
73
  end
@@ -94,10 +80,32 @@ module AIA
94
80
 
95
81
  format_rounds(rounds)
96
82
  end
97
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
98
83
 
99
84
  private
100
85
 
86
+ # Run every robot once against the round context, concurrently.
87
+ # Returns one entry per robot: a result hash, or FailedResponse on error.
88
+ # :reek:TooManyStatements -- async fan-out with per-robot progress, memory write, and failure capture
89
+ def run_debate_round(robots, round_context, round, round_no)
90
+ Sync do
91
+ barrier = Async::Barrier.new
92
+ tasks = robots.map do |robot|
93
+ barrier.async do
94
+ name = robot.name
95
+ @ui_presenter.display_info(" Round #{round_no}: #{name}...")
96
+ result = robot.run(round_context, mcp: :inherit, tools: :inherit)
97
+ content = extract_content(result)
98
+ write_to_memory(round, name, content)
99
+ { robot: name, content: content }
100
+ rescue => e
101
+ FailedResponse.new(robot_name: name, error_message: e.message)
102
+ end
103
+ end
104
+ barrier.wait
105
+ tasks.map(&:wait)
106
+ end
107
+ end
108
+
101
109
  def build_round_context(prompt, rounds)
102
110
  return prompt if rounds.empty?
103
111
 
@@ -27,6 +27,7 @@ module AIA
27
27
  #
28
28
  # @param context [HandlerContext] — reads context.prompt
29
29
  # @return [String, nil] combined results, or nil if not applicable
30
+ # :reek:TooManyStatements -- three-step delegation pipeline (decompose, plan, execute) sharing intermediate results
30
31
  def handle(context)
31
32
  prompt = context.prompt
32
33
  return nil unless @robot.network?
@@ -71,6 +72,7 @@ module AIA
71
72
 
72
73
  private
73
74
 
75
+ # :reek:TooManyStatements -- per-step loop: resolve assignee, announce, execute, record to memory and results
74
76
  def execute_steps(executor, prompt, plan, steps, robots)
75
77
  results = []
76
78
 
@@ -78,13 +80,15 @@ module AIA
78
80
  step_def = steps[i]
79
81
  assignee = robots.values.find { |r| r.name == step_def[:assignee] }
80
82
  assignee ||= robots.values.first
83
+ name = assignee.name
84
+ title = step_def[:title]
81
85
 
82
- @ui_presenter.display_info(" #{assignee.name}: #{step_def[:title]}...")
86
+ @ui_presenter.display_info(" #{name}: #{title}...")
83
87
 
84
88
  content = executor.execute(task, assignee, step_def, prompt, results)
85
89
 
86
- write_to_memory(i, assignee.name, step_def[:title], content)
87
- results << { robot: assignee.name, task: step_def[:title], content: content }
90
+ write_to_memory(i, name, title, content)
91
+ results << { robot: name, task: title, content: content }
88
92
  end
89
93
 
90
94
  results
data/lib/aia/directive.rb CHANGED
@@ -68,6 +68,7 @@ module AIA
68
68
  # Application-level concern: formats help text using DIRECTIVE_PREFIX
69
69
  # and all registered directive subclasses.
70
70
 
71
+ # :reek:TooManyStatements -- sequential terminal report: header, one section per directive category, footer
71
72
  def help
72
73
  puts
73
74
  puts "Available Directives"
@@ -84,14 +85,7 @@ module AIA
84
85
  puts "-" * cat.length
85
86
 
86
87
  klass.directive_descriptions.each do |method_name, description|
87
- aliases = klass.directive_aliases[method_name] || []
88
- alias_text = if aliases.any?
89
- " (aliases: #{aliases.map { |a| "#{DIRECTIVE_PREFIX}#{a}" }.join(', ')})"
90
- else
91
- ""
92
- end
93
-
94
- puts " #{DIRECTIVE_PREFIX}#{method_name}#{alias_text}"
88
+ puts " #{DIRECTIVE_PREFIX}#{method_name}#{alias_text_for(klass, method_name)}"
95
89
  puts " #{description}"
96
90
  puts
97
91
 
@@ -102,6 +96,14 @@ module AIA
102
96
  puts "\nTotal: #{total} directives available"
103
97
  ""
104
98
  end
99
+
100
+ # Format the "(aliases: ...)" suffix for a directive, or "" when it has none.
101
+ def alias_text_for(klass, method_name)
102
+ aliases = klass.directive_aliases[method_name] || []
103
+ return "" if aliases.empty?
104
+
105
+ " (aliases: #{aliases.map { |a| "#{DIRECTIVE_PREFIX}#{a}" }.join(', ')})"
106
+ end
105
107
  end
106
108
 
107
109
  private