aia 1.1.1 → 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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. data/main.just +0 -76
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'word_wrapper'
4
- require_relative '../adapter/gem_activator'
5
4
  require_relative '../skill_utils'
6
5
 
7
6
  # lib/aia/config/validator.rb
@@ -10,12 +9,14 @@ require_relative '../skill_utils'
10
9
  # Handles prompt ID extraction, context file validation, role processing, etc.
11
10
 
12
11
  module AIA
12
+ # rubocop:disable Metrics/ModuleLength
13
13
  module ConfigValidator
14
14
  class << self
15
15
  # Tailor and validate the configuration
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
@@ -24,8 +25,7 @@ module AIA
24
25
  stdin_content = process_stdin_content
25
26
  config.stdin_content = stdin_content if stdin_content && !stdin_content.strip.empty?
26
27
 
27
- # Require tool files early so AIA::Directive subclasses defined in them
28
- # are registered into PM.directives before PromptHandler.new is called.
28
+ # Tool files may define AIA::Directive subclasses used by PromptManager.
29
29
  require_tool_files(config)
30
30
 
31
31
  # Process arguments and validate
@@ -33,30 +33,34 @@ module AIA
33
33
  validate_and_set_context_files(config, remaining_args)
34
34
  handle_executable_prompt(config)
35
35
  handle_stdin_as_prompt(config)
36
- handle_dump_config(config)
37
- handle_mcp_list(config)
38
- handle_list_tools(config)
39
- handle_list_skills(config)
40
- handle_completion_script(config)
36
+ return :early_exit if handle_early_exits(config) == :early_exit
41
37
  validate_required_prompt_id(config)
42
38
  process_role_configuration(config)
39
+ validate_plugins_dir(config)
43
40
  handle_fuzzy_search_prompt_id(config)
44
41
  normalize_boolean_flags(config)
45
42
  validate_final_prompt_requirements(config)
46
- configure_prompt_manager(config)
47
43
  prepare_pipeline(config)
48
44
  validate_pipeline_prompts(config)
49
45
 
50
46
  config
51
47
  end
52
48
 
49
+ def handle_early_exits(config)
50
+ return :early_exit if handle_dump_config(config) == :early_exit
51
+ return :early_exit if handle_mcp_list(config) == :early_exit
52
+ return :early_exit if handle_list_tools(config) == :early_exit
53
+ return :early_exit if handle_list_skills(config) == :early_exit
54
+ :early_exit if handle_completion_script(config) == :early_exit
55
+ end
56
+
53
57
  def process_stdin_content
54
58
  stdin_content = String.new
55
59
 
56
- if !STDIN.tty? && !STDIN.closed?
60
+ if !$stdin.tty? && !$stdin.closed?
57
61
  begin
58
- stdin_content << "\n" + STDIN.read
59
- STDIN.reopen('/dev/tty')
62
+ stdin_content << ("\n" + $stdin.read)
63
+ $stdin.reopen('/dev/tty')
60
64
  rescue => _
61
65
  # If we can't reopen, continue without error
62
66
  end
@@ -71,9 +75,8 @@ module AIA
71
75
  maybe_id = remaining_args.first
72
76
  maybe_id_plus = File.join(config.prompts.dir, maybe_id + config.prompts.extname)
73
77
 
74
- if AIA.bad_file?(maybe_id) && AIA.good_file?(maybe_id_plus)
75
- config.prompt_id = remaining_args.shift
76
- end
78
+ return unless AIA.bad_file?(maybe_id) && AIA.good_file?(maybe_id_plus)
79
+ config.prompt_id = remaining_args.shift
77
80
  end
78
81
 
79
82
  def validate_and_set_context_files(config, remaining_args)
@@ -81,8 +84,16 @@ module AIA
81
84
 
82
85
  bad_files = remaining_args.reject { |filename| AIA.good_file?(filename) }
83
86
  if bad_files.any?
84
- warn "Error: The following files do not exist: #{bad_files.join(', ')}"
85
- exit 1
87
+ # Detect likely flag typos: "_B" instead of "-B", "_CD" instead of "-CD", etc.
88
+ flag_typos = bad_files.grep(/\A_[A-Za-z]/)
89
+ if flag_typos.any?
90
+ suggestions = flag_typos.map { |f| "-#{f[1..]}" }.join(', ')
91
+ raise AIA::ConfigurationError,
92
+ "Unknown argument(s): #{flag_typos.join(', ')}\n" \
93
+ "Did you mean: #{suggestions}?\n" \
94
+ "Flags require a dash prefix (e.g., -B not _B)."
95
+ end
96
+ raise AIA::ConfigurationError, "The following files do not exist: #{bad_files.join(', ')}"
86
97
  end
87
98
 
88
99
  config.context_files ||= []
@@ -90,70 +101,70 @@ module AIA
90
101
  end
91
102
 
92
103
  def handle_executable_prompt(config)
93
- # Auto-detect: no prompt_id, first context_file starts with shebang
94
104
  return unless config.prompt_id.nil?
95
- return unless config.context_files && !config.context_files.empty?
96
105
 
97
- candidate = config.context_files.first
106
+ files = config.context_files
107
+ return unless files && !files.empty?
108
+
109
+ candidate = files.first
98
110
  return unless File.exist?(candidate) && File.readable?(candidate)
99
111
 
100
112
  first_line = File.open(candidate, &:readline).strip rescue nil
101
113
  return unless first_line&.start_with?('#!')
102
114
 
103
- # This is an executable prompt — the file content IS the prompt
104
- config.context_files.shift
115
+ files.shift
105
116
  config.executable_prompt_content = File.read(candidate).lines[1..].join
106
117
  config.prompt_id = '__EXECUTABLE_PROMPT__'
107
118
  end
108
119
 
109
120
  def handle_stdin_as_prompt(config)
110
121
  return unless config.prompt_id.nil?
111
- return unless config.stdin_content && !config.stdin_content.strip.empty?
112
-
113
122
  content = config.stdin_content
123
+ return unless content && !content.strip.empty?
114
124
 
115
- # Strip shebang line if present (e.g., piped from an executable prompt)
116
125
  if content.lines.first&.strip&.start_with?('#!')
117
126
  content = content.lines[1..].join
118
127
  end
119
128
 
120
129
  config.executable_prompt_content = content
121
- config.stdin_content = nil # prevent double-processing in build_prompt_text
130
+ config.stdin_content = nil
122
131
  config.prompt_id = '__EXECUTABLE_PROMPT__'
123
132
  end
124
133
 
125
134
  def validate_required_prompt_id(config)
126
- return unless config.prompt_id.nil? && !(config.flags.chat == true) && !(config.flags.fuzzy == true)
135
+ return unless config.prompt_id.nil? && config.flags.chat != true && config.flags.fuzzy != true
127
136
 
128
- warn "Error: A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
129
- exit 1
137
+ raise AIA::ConfigurationError,
138
+ "A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
130
139
  end
131
140
 
132
141
  def process_role_configuration(config)
133
- role = config.prompts.role
142
+ prompts = config.prompts
143
+ role = prompts.role
134
144
  return if role.nil? || role.empty?
135
145
 
136
- roles_prefix = config.prompts.roles_prefix
137
-
146
+ roles_prefix = prompts.roles_prefix
138
147
  unless AIA::SkillUtils.path_based_id?(role) || roles_prefix.nil? || roles_prefix.empty? || role.start_with?(roles_prefix)
139
- config.prompts.role = "#{roles_prefix}/#{role}"
140
- role = config.prompts.role
148
+ role = "#{roles_prefix}/#{role}"
149
+ prompts.role = role
141
150
  end
142
151
 
143
- 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)
144
153
 
145
- # In chat-only mode (no prompt_id), leave the role configured so ChatLoop
146
- # can inject it as initial context. Promoting it to prompt_id would cause
147
- # PM to receive the role path as a literal string rather than file content.
148
154
  return if config.flags&.chat == true
149
155
 
150
- if config.prompt_id.nil? || config.prompt_id.empty?
151
- unless role.nil? || role.empty?
152
- config.prompt_id = role
153
- config.pipeline.prepend(config.prompt_id)
154
- config.prompts.role = ''
155
- end
156
- end
156
+ return unless config.prompt_id.nil? || config.prompt_id.empty?
157
+ config.prompt_id = role
158
+ config.pipeline.prepend(role)
159
+ prompts.role = ''
160
+ end
161
+
162
+ def validate_plugins_dir(config)
163
+ plugins_dir = config.paths&.plugins_dir
164
+ return if plugins_dir.nil? || plugins_dir.to_s.strip.empty?
165
+ return if Dir.exist?(plugins_dir)
166
+
167
+ $stderr.puts "Warning: configured plugins directory does not exist: #{plugins_dir}"
157
168
  end
158
169
 
159
170
  def handle_fuzzy_search_prompt_id(config)
@@ -163,9 +174,10 @@ module AIA
163
174
  end
164
175
 
165
176
  def normalize_boolean_flags(config)
166
- normalize_boolean_flag(config.flags, :chat)
167
- normalize_boolean_flag(config.flags, :fuzzy)
168
- 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)
169
181
  end
170
182
 
171
183
  def normalize_boolean_flag(flags_section, flag)
@@ -188,12 +200,12 @@ module AIA
188
200
  return unless config.dump_file
189
201
 
190
202
  dump_config(config, config.dump_file)
191
- exit 0
203
+ :early_exit
192
204
  end
193
205
 
194
206
  def handle_mcp_list(config)
195
207
  return unless config.mcp_list
196
- return if config.list_tools # defer to handle_list_tools for combined output
208
+ return if config.list_tools
197
209
 
198
210
  servers = filter_mcp_servers(config)
199
211
 
@@ -202,18 +214,17 @@ module AIA
202
214
  else
203
215
  label = mcp_filter_active?(config) ? "Active" : "Configured"
204
216
  puts "#{label} MCP servers:\n\n"
205
- servers.each do |server|
206
- name = server[:name] || server['name'] || '(unnamed)'
207
- command = server[:command] || server['command'] || '(no command)'
208
- args = server[:args] || server['args'] || []
209
- args_str = args.empty? ? '' : " #{args.join(' ')}"
210
- puts " #{name}"
211
- puts " command: #{command}#{args_str}"
212
- puts
213
- end
217
+ servers.each { |server| print_mcp_server(McpServerConfig.from_hash(server)) }
214
218
  end
215
219
 
216
- exit 0
220
+ :early_exit
221
+ end
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
217
228
  end
218
229
 
219
230
  def handle_list_tools(config)
@@ -228,7 +239,7 @@ module AIA
228
239
 
229
240
  if local_tools.empty? && mcp_tool_groups.empty?
230
241
  $stderr.puts "No tools available."
231
- exit 0
242
+ return :early_exit
232
243
  end
233
244
 
234
245
  if $stdout.tty?
@@ -237,41 +248,7 @@ module AIA
237
248
  list_tools_markdown(local_tools, mcp_tool_groups)
238
249
  end
239
250
 
240
- exit 0
241
- end
242
-
243
- def handle_list_skills(config)
244
- return unless config.list_skills
245
-
246
- skills_dir = AIA.config.skills.dir
247
-
248
- unless Dir.exist?(skills_dir)
249
- $stderr.puts "No skills directory found at #{skills_dir}"
250
- exit 0
251
- end
252
-
253
- skill_dirs = Dir.glob("*/SKILL.md", base: skills_dir).map { |f| File.dirname(f) }.sort
254
-
255
- if skill_dirs.empty?
256
- $stderr.puts "No skills found in #{skills_dir}"
257
- exit 0
258
- end
259
-
260
- skill_dirs.each do |skill_name|
261
- skill_md = File.join(skills_dir, skill_name, 'SKILL.md')
262
- fm = AIA::SkillUtils.parse_front_matter(skill_md)
263
-
264
- puts "## #{skill_name}"
265
- puts
266
- puts "| Key | Value |"
267
- puts "|-----|-------|"
268
- fm.each do |key, value|
269
- puts "| #{key} | #{value} |"
270
- end
271
- puts
272
- end
273
-
274
- exit 0
251
+ :early_exit
275
252
  end
276
253
 
277
254
  def list_tools_terminal(local_tools, mcp_tool_groups)
@@ -290,8 +267,8 @@ module AIA
290
267
  end
291
268
 
292
269
  def print_tool_terminal(tool, width, indent)
293
- name = tool.respond_to?(:name) ? tool.name : tool.class.name
294
- desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''
270
+ name = ToolIntrospection.tool_name(tool)
271
+ desc = ToolIntrospection.tool_description(tool).strip
295
272
 
296
273
  puts " #{name}"
297
274
  unless desc.empty?
@@ -302,6 +279,7 @@ module AIA
302
279
  puts
303
280
  end
304
281
 
282
+ # :reek:TooManyStatements -- sequential markdown report: header, local tools section, one section per MCP server
305
283
  def list_tools_markdown(local_tools, mcp_tool_groups)
306
284
  total = local_tools.size + mcp_tool_groups.values.sum(&:size)
307
285
  sources = 1 + mcp_tool_groups.size
@@ -325,30 +303,25 @@ module AIA
325
303
  end
326
304
 
327
305
  def print_tool_markdown(tool)
328
- name = tool.respond_to?(:name) ? tool.name : tool.class.name
329
- desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''
306
+ name = ToolIntrospection.tool_name(tool)
307
+ desc = ToolIntrospection.tool_description(tool).strip
330
308
 
331
309
  puts "### `#{name}`"
332
310
  puts
333
- unless desc.empty?
334
- puts nest_markdown_headings(desc, 3)
335
- puts
336
- end
311
+ return if desc.empty?
312
+ puts nest_markdown_headings(desc, 3)
313
+ puts
337
314
  end
338
315
 
339
- # Adjusts any markdown headings in text so they nest under the
340
- # given parent heading level. e.g. with parent_level=3 (###),
341
- # a "# Foo" becomes "#### Foo" and "## Bar" becomes "##### Bar".
342
- # Handles headings with optional leading whitespace.
343
316
  def nest_markdown_headings(text, parent_level)
344
- text.gsub(/^[ \t]*(\#{1,6})\s/) do |match|
345
- existing = $1
346
- "#" * (existing.length + parent_level) + " "
317
+ text.gsub(/^[ \t]*(\#{1,6})\s/) do |_match|
318
+ existing = ::Regexp.last_match(1)
319
+ ("#" * (existing.length + parent_level)) + " "
347
320
  end
348
321
  end
349
322
 
350
323
  def filter_mcp_servers(config)
351
- servers = config.mcp_servers || []
324
+ servers = config.mcp_servers || []
352
325
  use_list = Array(config.mcp_use)
353
326
  skip_list = Array(config.mcp_skip)
354
327
 
@@ -371,133 +344,127 @@ module AIA
371
344
  if File.exist?(expanded)
372
345
  require expanded
373
346
  else
374
- warn "Warning: Tool file not found: #{path}"
347
+ $stderr.puts "Warning: Tool file not found: #{path}"
375
348
  end
376
349
  rescue LoadError, StandardError => e
377
- warn "Warning: Failed to load tool '#{path}': #{e.message}"
350
+ $stderr.puts "Warning: Failed to load tool '#{path}': #{e.message}"
378
351
  end
379
352
  end
380
353
 
354
+ # Canonical --list-skills handler. Runs after config is built (so a -c
355
+ # config file's skills.dir is honored) and resolves skills the same way
356
+ # loading does, so what is listed is always loadable.
357
+ def handle_list_skills(config)
358
+ return unless config.respond_to?(:list_skills) && config.list_skills
359
+
360
+ puts AIA::SkillUtils.list_skills_markdown(config)
361
+ :early_exit
362
+ end
363
+
381
364
  def load_local_tools(config)
382
- # Load required libraries (with gem activation and lazy-load triggering)
383
- Array(config.require_libs).each do |lib|
384
- begin
385
- Adapter::GemActivator.activate_gem_for_require(lib)
386
- require lib
387
- Adapter::GemActivator.trigger_tool_loading(lib)
388
- rescue LoadError => e
389
- warn "Warning: Failed to require '#{lib}': #{e.message}"
390
- warn "Hint: Make sure the gem is installed: gem install #{lib}"
391
- rescue StandardError => e
392
- warn "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
393
- end
394
- end
365
+ require_configured_libs(config)
366
+ require_tool_files(config)
367
+ instantiable_tool_classes
368
+ end
395
369
 
396
- # Load tool files
397
- Array(config.tools&.paths).each do |path|
398
- expanded = File.expand_path(path)
399
- if File.exist?(expanded)
400
- require expanded
401
- else
402
- warn "Warning: Tool file not found: #{path}"
403
- end
404
- rescue LoadError, StandardError => e
405
- warn "Warning: Failed to load tool '#{path}': #{e.message}"
370
+ # :reek:DuplicateMethodCall -- each e.message belongs to a different rescue clause exception; nothing to hoist
371
+ def require_configured_libs(config)
372
+ Array(config.require_libs).each do |lib|
373
+ require lib
374
+ rescue LoadError => e
375
+ $stderr.puts "Warning: Failed to require '#{lib}': #{e.message}"
376
+ $stderr.puts "Hint: Make sure the gem is installed: gem install #{lib}"
377
+ rescue StandardError => e
378
+ $stderr.puts "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
406
379
  end
380
+ end
407
381
 
408
- # Scan ObjectSpace for RubyLLM::Tool subclasses
382
+ # Scan ObjectSpace for RubyLLM::Tool subclasses that can be instantiated.
383
+ def instantiable_tool_classes
409
384
  ObjectSpace.each_object(Class).select do |klass|
410
- next false unless klass < RubyLLM::Tool
385
+ next false unless defined?(RubyLLM::Tool) && klass < RubyLLM::Tool
411
386
 
412
387
  begin
413
388
  klass.new
414
389
  true
415
- rescue ArgumentError, LoadError, StandardError
390
+ rescue StandardError
416
391
  false
417
392
  end
418
393
  end
419
394
  end
420
395
 
421
396
  def first_sentences(text, count)
422
- # Normalize whitespace: collapse newlines and multiple spaces
423
397
  normalized = text.gsub(/\s*\n\s*/, ' ').gsub(/\s{2,}/, ' ').strip
424
398
  sentences = normalized.scan(/[^.!?]*[.!?]/)
425
399
  result = sentences.first(count).join.strip
426
400
  result.empty? ? normalized : result
427
401
  end
428
402
 
403
+ # :reek:TooManyStatements -- per-server connect loop with progress prints on each outcome path
429
404
  def load_mcp_tools_grouped(config)
430
405
  servers = filter_mcp_servers(config)
431
406
  return {} if servers.empty?
432
407
 
433
- # Suppress MCP logger noise during listing
434
408
  quiet_mcp_logger
435
409
 
436
- groups = {}
437
- 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
438
414
 
439
- servers.each do |server|
440
- name = server[:name] || server['name']
441
- command = server[:command] || server['command']
442
- args = server[:args] || server['args'] || []
443
- 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
444
426
 
445
- raw_timeout = server[:timeout] || server['timeout'] || default_timeout
446
- timeout = raw_timeout.to_i < 1000 ? (raw_timeout.to_i * 1000) : raw_timeout.to_i
447
- 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?
448
434
 
449
- mcp_config = { command: command, args: Array(args) }
450
- mcp_config[:env] = env unless env.empty?
435
+ client.tools rescue []
436
+ end
451
437
 
452
- begin
453
- $stderr.print "MCP: Connecting to #{name}..."
454
- $stderr.flush
455
-
456
- client = begin
457
- RubyLLM::MCP.add_client(
458
- name: name, transport_type: :stdio,
459
- config: mcp_config, request_timeout: timeout, start: false
460
- )
461
- rescue ArgumentError
462
- RubyLLM::MCP.add_client(
463
- name: name, transport_type: :stdio,
464
- config: mcp_config, start: false
465
- )
466
- end
467
-
468
- client = RubyLLM::MCP.clients[name]
469
- client.start
470
-
471
- if client.alive?
472
- server_tools = client.tools rescue []
473
- groups[name] = server_tools
474
- $stderr.puts " #{server_tools.size} tools"
475
- else
476
- $stderr.puts " failed"
477
- end
478
- rescue StandardError => e
479
- $stderr.puts " error: #{e.message}"
480
- end
481
- end
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?
482
441
 
483
- 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
+ )
484
452
  end
453
+ # rubocop:enable Metrics/ModuleLength
485
454
 
486
455
  def quiet_mcp_logger
487
- if defined?(RubyLLM::MCP) && RubyLLM::MCP.respond_to?(:config)
488
- mcp_config = RubyLLM::MCP.config
489
- if mcp_config.respond_to?(:logger=)
490
- quiet = Logger.new(File::NULL)
491
- mcp_config.logger = quiet
492
- end
493
- end
456
+ return unless defined?(RubyLLM::MCP) && RubyLLM::MCP.respond_to?(:config)
457
+ mcp_config = RubyLLM::MCP.config
458
+ return unless mcp_config.respond_to?(:logger=)
459
+ quiet = Logger.new(File::NULL)
460
+ mcp_config.logger = quiet
494
461
  end
495
462
 
496
463
  def handle_completion_script(config)
497
464
  return unless config.completion
498
465
 
499
466
  generate_completion_script(config.completion)
500
- exit
467
+ :early_exit
501
468
  end
502
469
 
503
470
  def generate_completion_script(shell)
@@ -506,31 +473,25 @@ module AIA
506
473
  if File.exist?(script_path)
507
474
  puts File.read(script_path)
508
475
  else
509
- warn "ERROR: The shell '#{shell}' is not supported or the completion script is missing."
476
+ $stderr.puts "ERROR: The shell '#{shell}' is not supported or the completion script is missing."
510
477
  end
511
478
  end
512
479
 
513
480
  def validate_final_prompt_requirements(config)
514
481
  chat_mode = config.flags.chat == true
515
482
  fuzzy_mode = config.flags.fuzzy == true
516
- if !chat_mode && !fuzzy_mode && (config.prompt_id.nil? || config.prompt_id.empty?) && (config.context_files.nil? || config.context_files.empty?)
517
- warn "Error: A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
518
- exit 1
519
- end
520
- end
521
-
522
- def configure_prompt_manager(config)
523
- # PM v1.0.0 uses ERB parameters (<%= param %>) instead of regex-based extraction.
524
- # parameter_regex is deprecated and ignored.
525
- if config.prompts.parameter_regex
526
- warn "Warning: --regex / parameter_regex is deprecated. PM v1.0.0 uses ERB parameters (<%= param %>)."
527
- end
483
+ no_prompt = config.prompt_id.nil? || config.prompt_id.empty?
484
+ no_context = config.context_files.nil? || config.context_files.empty?
485
+ return unless !chat_mode && !fuzzy_mode && no_prompt && no_context
486
+ raise AIA::ConfigurationError,
487
+ "A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
528
488
  end
529
489
 
530
490
  def prepare_pipeline(config)
531
- 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
532
493
 
533
- config.pipeline.prepend(config.prompt_id)
494
+ config.pipeline.prepend(prompt_id)
534
495
  end
535
496
 
536
497
  def validate_pipeline_prompts(config)
@@ -543,24 +504,19 @@ module AIA
543
504
 
544
505
  prompt_file_path = File.join(config.prompts.dir, "#{prompt_id}#{config.prompts.extname}")
545
506
  unless File.exist?(prompt_file_path)
546
- warn "Error: Prompt ID '#{prompt_id}' does not exist at #{prompt_file_path}"
507
+ $stderr.puts "Error: Prompt ID '#{prompt_id}' does not exist at #{prompt_file_path}"
547
508
  and_exit = true
548
509
  end
549
510
  end
550
511
 
551
- exit(1) if and_exit
512
+ raise AIA::ConfigurationError, "One or more prompt IDs do not exist." if and_exit
552
513
  end
553
514
 
554
- # Dump configuration to file
555
- #
556
- # @param config [AIA::Config] the configuration to dump
557
- # @param file [String] the file path to dump to
558
515
  def dump_config(config, file)
559
516
  ext = File.extname(file).downcase
560
517
 
561
518
  config_hash = config.to_h
562
519
 
563
- # Remove runtime keys
564
520
  config_hash.delete(:prompt_id)
565
521
  config_hash.delete(:dump_file)
566
522
 
@@ -575,7 +531,6 @@ module AIA
575
531
  File.write(file, content)
576
532
  puts "Config successfully dumped to #{file}"
577
533
  end
578
-
579
534
  end
580
535
  end
581
536
  end