Module: AIA::ConfigValidator

Defined in:
lib/aia/config/validator.rb

Class Method Summary collapse

Class Method Details

.configure_prompt_manager(config) ⇒ Object



505
506
507
508
509
510
511
# File 'lib/aia/config/validator.rb', line 505

def configure_prompt_manager(config)
  # PM v1.0.0 uses ERB parameters (<%= param %>) instead of regex-based extraction.
  # parameter_regex is deprecated and ignored.
  if config.prompts.parameter_regex
    warn "Warning: --regex / parameter_regex is deprecated. PM v1.0.0 uses ERB parameters (<%= param %>)."
  end
end

.dump_config(config, file) ⇒ Object

Dump configuration to file

Parameters:

  • config (AIA::Config)

    the configuration to dump

  • file (String)

    the file path to dump to



541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/aia/config/validator.rb', line 541

def dump_config(config, file)
  ext = File.extname(file).downcase

  config_hash = config.to_h

  # Remove runtime keys
  config_hash.delete(:prompt_id)
  config_hash.delete(:dump_file)

  content = case ext
            when '.yml', '.yaml'
              require 'yaml'
              YAML.dump(config_hash.transform_keys(&:to_s))
            else
              raise "Unsupported config file format: #{ext}. Use .yml or .yaml"
            end

  File.write(file, content)
  puts "Config successfully dumped to #{file}"
end

.filter_mcp_servers(config) ⇒ Object



346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/aia/config/validator.rb', line 346

def filter_mcp_servers(config)
  servers  = config.mcp_servers || []
  use_list  = Array(config.mcp_use)
  skip_list = Array(config.mcp_skip)

  if !use_list.empty?
    servers.select { |s| use_list.include?(s[:name] || s['name']) }
  elsif !skip_list.empty?
    servers.reject { |s| skip_list.include?(s[:name] || s['name']) }
  else
    servers
  end
end

.first_sentences(text, count) ⇒ Object



404
405
406
407
408
409
410
# File 'lib/aia/config/validator.rb', line 404

def first_sentences(text, count)
  # Normalize whitespace: collapse newlines and multiple spaces
  normalized = text.gsub(/\s*\n\s*/, ' ').gsub(/\s{2,}/, ' ').strip
  sentences  = normalized.scan(/[^.!?]*[.!?]/)
  result     = sentences.first(count).join.strip
  result.empty? ? normalized : result
end

.generate_completion_script(shell) ⇒ Object



486
487
488
489
490
491
492
493
494
# File 'lib/aia/config/validator.rb', line 486

def generate_completion_script(shell)
  script_path = File.join(File.dirname(__FILE__), "../aia_completion.#{shell}")

  if File.exist?(script_path)
    puts File.read(script_path)
  else
    warn "ERROR: The shell '#{shell}' is not supported or the completion script is missing."
  end
end

.handle_completion_script(config) ⇒ Object



479
480
481
482
483
484
# File 'lib/aia/config/validator.rb', line 479

def handle_completion_script(config)
  return unless config.completion

  generate_completion_script(config.completion)
  exit
end

.handle_dump_config(config) ⇒ Object



183
184
185
186
187
188
# File 'lib/aia/config/validator.rb', line 183

def handle_dump_config(config)
  return unless config.dump_file

  dump_config(config, config.dump_file)
  exit 0
end

.handle_executable_prompt(config) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/aia/config/validator.rb', line 88

def handle_executable_prompt(config)
  # Auto-detect: no prompt_id, first context_file starts with shebang
  return unless config.prompt_id.nil?
  return unless config.context_files && !config.context_files.empty?

  candidate = config.context_files.first
  return unless File.exist?(candidate) && File.readable?(candidate)

  first_line = File.open(candidate, &:readline).strip rescue nil
  return unless first_line&.start_with?('#!')

  # This is an executable prompt — the file content IS the prompt
  config.context_files.shift
  config.executable_prompt_content = File.read(candidate).lines[1..].join
  config.prompt_id = '__EXECUTABLE_PROMPT__'
end

.handle_fuzzy_search_prompt_id(config) ⇒ Object



155
156
157
158
159
# File 'lib/aia/config/validator.rb', line 155

def handle_fuzzy_search_prompt_id(config)
  return unless (config.flags.fuzzy == true) && (config.prompt_id.nil? || config.prompt_id.empty?)

  config.prompt_id = '__FUZZY_SEARCH__'
end

.handle_list_skills(config) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/aia/config/validator.rb', line 239

def handle_list_skills(config)
  return unless config.list_skills

  skills_dir = AIA.config.skills.dir

  unless Dir.exist?(skills_dir)
    $stderr.puts "No skills directory found at #{skills_dir}"
    exit 0
  end

  skill_dirs = Dir.glob("*/SKILL.md", base: skills_dir).map { |f| File.dirname(f) }.sort

  if skill_dirs.empty?
    $stderr.puts "No skills found in #{skills_dir}"
    exit 0
  end

  skill_dirs.each do |skill_name|
    skill_md = File.join(skills_dir, skill_name, 'SKILL.md')
    fm = AIA::SkillUtils.parse_front_matter(skill_md)

    puts "## #{skill_name}"
    puts
    puts "| Key | Value |"
    puts "|-----|-------|"
    fm.each do |key, value|
      puts "| #{key} | #{value} |"
    end
    puts
  end

  exit 0
end

.handle_list_tools(config) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/aia/config/validator.rb', line 215

def handle_list_tools(config)
  return unless config.list_tools

  local_tools = load_local_tools(config)
  mcp_tool_groups = {}

  if config.mcp_list
    mcp_tool_groups = load_mcp_tools_grouped(config)
  end

  if local_tools.empty? && mcp_tool_groups.empty?
    $stderr.puts "No tools available."
    exit 0
  end

  if $stdout.tty?
    list_tools_terminal(local_tools, mcp_tool_groups)
  else
    list_tools_markdown(local_tools, mcp_tool_groups)
  end

  exit 0
end

.handle_mcp_list(config) ⇒ Object



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/aia/config/validator.rb', line 190

def handle_mcp_list(config)
  return unless config.mcp_list
  return if config.list_tools  # defer to handle_list_tools for combined output

  servers = filter_mcp_servers(config)

  if servers.empty?
    puts "No MCP servers configured."
  else
    label = mcp_filter_active?(config) ? "Active" : "Configured"
    puts "#{label} MCP servers:\n\n"
    servers.each do |server|
      name    = server[:name]    || server['name']    || '(unnamed)'
      command = server[:command] || server['command']  || '(no command)'
      args    = server[:args]    || server['args']     || []
      args_str = args.empty? ? '' : " #{args.join(' ')}"
      puts "  #{name}"
      puts "    command: #{command}#{args_str}"
      puts
    end
  end

  exit 0
end

.handle_stdin_as_prompt(config) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/aia/config/validator.rb', line 105

def handle_stdin_as_prompt(config)
  return unless config.prompt_id.nil?
  return unless config.stdin_content && !config.stdin_content.strip.empty?

  content = config.stdin_content

  # Strip shebang line if present (e.g., piped from an executable prompt)
  if content.lines.first&.strip&.start_with?('#!')
    content = content.lines[1..].join
  end

  config.executable_prompt_content = content
  config.stdin_content = nil  # prevent double-processing in build_prompt_text
  config.prompt_id = '__EXECUTABLE_PROMPT__'
end

.list_tools_markdown(local_tools, mcp_tool_groups) ⇒ Object



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/aia/config/validator.rb', line 301

def list_tools_markdown(local_tools, mcp_tool_groups)
  total = local_tools.size + mcp_tool_groups.values.sum(&:size)
  sources = 1 + mcp_tool_groups.size

  puts "# Available Tools"
  puts
  puts "> #{total} tools from #{sources} source#{'s' if sources > 1}"
  puts

  unless local_tools.empty?
    puts "## Local Tools (#{local_tools.size})"
    puts
    local_tools.each { |tool| print_tool_markdown(tool) }
  end

  mcp_tool_groups.each do |server_name, tools|
    puts "## MCP: #{server_name} (#{tools.size})"
    puts
    tools.each { |tool| print_tool_markdown(tool) }
  end
end

.list_tools_terminal(local_tools, mcp_tool_groups) ⇒ Object



273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/aia/config/validator.rb', line 273

def list_tools_terminal(local_tools, mcp_tool_groups)
  width  = (ENV['COLUMNS'] || 80).to_i - 4
  indent = '    '

  unless local_tools.empty?
    puts "Local Tools:\n\n"
    local_tools.each { |tool| print_tool_terminal(tool, width, indent) }
  end

  mcp_tool_groups.each do |server_name, tools|
    puts "MCP: #{server_name} (#{tools.size} tools)\n\n"
    tools.each { |tool| print_tool_terminal(tool, width, indent) }
  end
end

.load_local_tools(config) ⇒ Object



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/aia/config/validator.rb', line 364

def load_local_tools(config)
  # Load required libraries (with gem activation and lazy-load triggering)
  Array(config.require_libs).each do |lib|
    begin
      Adapter::GemActivator.activate_gem_for_require(lib)
      require lib
      Adapter::GemActivator.trigger_tool_loading(lib)
    rescue LoadError => e
      warn "Warning: Failed to require '#{lib}': #{e.message}"
      warn "Hint: Make sure the gem is installed: gem install #{lib}"
    rescue StandardError => e
      warn "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
    end
  end

  # Load tool files
  Array(config.tools&.paths).each do |path|
    expanded = File.expand_path(path)
    if File.exist?(expanded)
      require expanded
    else
      warn "Warning: Tool file not found: #{path}"
    end
  rescue LoadError, StandardError => e
    warn "Warning: Failed to load tool '#{path}': #{e.message}"
  end

  # Scan ObjectSpace for RubyLLM::Tool subclasses
  ObjectSpace.each_object(Class).select do |klass|
    next false unless klass < RubyLLM::Tool

    begin
      klass.new
      true
    rescue ArgumentError, LoadError, StandardError
      false
    end
  end
end

.load_mcp_tools_grouped(config) ⇒ Object



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/aia/config/validator.rb', line 412

def load_mcp_tools_grouped(config)
  servers = filter_mcp_servers(config)
  return {} if servers.empty?

  # Suppress MCP logger noise during listing
  quiet_mcp_logger

  groups = {}
  default_timeout = 8_000

  servers.each do |server|
    name    = server[:name]    || server['name']
    command = server[:command] || server['command']
    args    = server[:args]    || server['args'] || []
    env     = server[:env]     || server['env']  || {}

    raw_timeout = server[:timeout] || server['timeout'] || default_timeout
    timeout = raw_timeout.to_i < 1000 ? (raw_timeout.to_i * 1000) : raw_timeout.to_i
    timeout = [timeout, 30_000].min

    mcp_config = { command: command, args: Array(args) }
    mcp_config[:env] = env unless env.empty?

    begin
      $stderr.print "MCP: Connecting to #{name}..."
      $stderr.flush

      client = begin
        RubyLLM::MCP.add_client(
          name: name, transport_type: :stdio,
          config: mcp_config, request_timeout: timeout, start: false
        )
      rescue ArgumentError
        RubyLLM::MCP.add_client(
          name: name, transport_type: :stdio,
          config: mcp_config, start: false
        )
      end

      client = RubyLLM::MCP.clients[name]
      client.start

      if client.alive?
        server_tools = client.tools rescue []
        groups[name] = server_tools
        $stderr.puts " #{server_tools.size} tools"
      else
        $stderr.puts " failed"
      end
    rescue StandardError => e
      $stderr.puts " error: #{e.message}"
    end
  end

  groups
end

.mcp_filter_active?(config) ⇒ Boolean

Returns:

  • (Boolean)


360
361
362
# File 'lib/aia/config/validator.rb', line 360

def mcp_filter_active?(config)
  !Array(config.mcp_use).empty? || !Array(config.mcp_skip).empty?
end

.nest_markdown_headings(text, parent_level) ⇒ Object

Adjusts any markdown headings in text so they nest under the given parent heading level. e.g. with parent_level=3 (###), a “# Foo” becomes “#### Foo” and “## Bar” becomes “##### Bar”. Handles headings with optional leading whitespace.



339
340
341
342
343
344
# File 'lib/aia/config/validator.rb', line 339

def nest_markdown_headings(text, parent_level)
  text.gsub(/^[ \t]*(\#{1,6})\s/) do |match|
    existing = $1
    "#" * (existing.length + parent_level) + " "
  end
end

.normalize_boolean_flag(flags_section, flag) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/aia/config/validator.rb', line 167

def normalize_boolean_flag(flags_section, flag)
  value = flags_section.send(flag)
  return if [TrueClass, FalseClass].include?(value.class)

  normalized = case value
               when nil, '', 'false', false
                 false
               when 'true', true
                 true
               else
                 true
               end

  flags_section.send("#{flag}=", normalized)
end

.normalize_boolean_flags(config) ⇒ Object



161
162
163
164
165
# File 'lib/aia/config/validator.rb', line 161

def normalize_boolean_flags(config)
  normalize_boolean_flag(config.flags, :chat)
  normalize_boolean_flag(config.flags, :fuzzy)
  normalize_boolean_flag(config.flags, :consensus)
end

.prepare_pipeline(config) ⇒ Object



513
514
515
516
517
# File 'lib/aia/config/validator.rb', line 513

def prepare_pipeline(config)
  return if config.prompt_id.nil? || config.prompt_id.empty? || config.prompt_id == config.pipeline.first

  config.pipeline.prepend(config.prompt_id)
end


323
324
325
326
327
328
329
330
331
332
333
# File 'lib/aia/config/validator.rb', line 323

def print_tool_markdown(tool)
  name = tool.respond_to?(:name) ? tool.name : tool.class.name
  desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''

  puts "### `#{name}`"
  puts
  unless desc.empty?
    puts nest_markdown_headings(desc, 3)
    puts
  end
end


288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/aia/config/validator.rb', line 288

def print_tool_terminal(tool, width, indent)
  name = tool.respond_to?(:name) ? tool.name : tool.class.name
  desc = tool.respond_to?(:description) ? tool.description.to_s.strip : ''

  puts "  #{name}"
  unless desc.empty?
    brief = first_sentences(desc, 3)
    wrapped = WordWrapper::MinimumRaggedness.new(width, brief).wrap
    wrapped.split("\n").each { |line| puts "#{indent}#{line}" }
  end
  puts
end

.process_prompt_id_from_args(config, remaining_args) ⇒ Object



64
65
66
67
68
69
70
71
72
73
# File 'lib/aia/config/validator.rb', line 64

def process_prompt_id_from_args(config, remaining_args)
  return if remaining_args.empty?

  maybe_id = remaining_args.first
  maybe_id_plus = File.join(config.prompts.dir, maybe_id + config.prompts.extname)

  if AIA.bad_file?(maybe_id) && AIA.good_file?(maybe_id_plus)
    config.prompt_id = remaining_args.shift
  end
end

.process_role_configuration(config) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/aia/config/validator.rb', line 128

def process_role_configuration(config)
  role = config.prompts.role
  return if role.nil? || role.empty?

  roles_prefix = config.prompts.roles_prefix

  unless AIA::SkillUtils.path_based_id?(role) || roles_prefix.nil? || roles_prefix.empty? || role.start_with?(roles_prefix)
    config.prompts.role = "#{roles_prefix}/#{role}"
    role = config.prompts.role
  end

  config.prompts.roles_dir ||= File.join(config.prompts.dir, roles_prefix.to_s)

  # In chat-only mode (no prompt_id), leave the role configured so ChatLoop
  # can inject it as initial context. Promoting it to prompt_id would cause
  # PM to receive the role path as a literal string rather than file content.
  return if config.flags&.chat == true

  if config.prompt_id.nil? || config.prompt_id.empty?
    unless role.nil? || role.empty?
      config.prompt_id = role
      config.pipeline.prepend(config.prompt_id)
      config.prompts.role = ''
    end
  end
end

.process_stdin_contentObject



49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/aia/config/validator.rb', line 49

def process_stdin_content
  stdin_content = String.new

  if !STDIN.tty? && !STDIN.closed?
    begin
      stdin_content << "\n" + STDIN.read
      STDIN.reopen('/dev/tty')
    rescue => _
      # If we can't reopen, continue without error
    end
  end

  stdin_content
end

.quiet_mcp_loggerObject



469
470
471
472
473
474
475
476
477
# File 'lib/aia/config/validator.rb', line 469

def quiet_mcp_logger
  if defined?(RubyLLM::MCP) && RubyLLM::MCP.respond_to?(:config)
    mcp_config = RubyLLM::MCP.config
    if mcp_config.respond_to?(:logger=)
      quiet = Logger.new(File::NULL)
      mcp_config.logger = quiet
    end
  end
end

.tailor(config) ⇒ AIA::Config

Tailor and validate the configuration

Parameters:

  • config (AIA::Config)

    the configuration to validate

Returns:



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/aia/config/validator.rb', line 19

def tailor(config)
  remaining_args = config.remaining_args&.dup || []
  config.remaining_args = nil

  # Process STDIN content if available
  stdin_content = process_stdin_content
  config.stdin_content = stdin_content if stdin_content && !stdin_content.strip.empty?

  # Process arguments and validate
  process_prompt_id_from_args(config, remaining_args)
  validate_and_set_context_files(config, remaining_args)
  handle_executable_prompt(config)
  handle_stdin_as_prompt(config)
  handle_dump_config(config)
  handle_mcp_list(config)
  handle_list_tools(config)
  handle_list_skills(config)
  handle_completion_script(config)
  validate_required_prompt_id(config)
  process_role_configuration(config)
  handle_fuzzy_search_prompt_id(config)
  normalize_boolean_flags(config)
  validate_final_prompt_requirements(config)
  configure_prompt_manager(config)
  prepare_pipeline(config)
  validate_pipeline_prompts(config)

  config
end

.validate_and_set_context_files(config, remaining_args) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/aia/config/validator.rb', line 75

def validate_and_set_context_files(config, remaining_args)
  return if remaining_args.empty?

  bad_files = remaining_args.reject { |filename| AIA.good_file?(filename) }
  if bad_files.any?
    warn "Error: The following files do not exist: #{bad_files.join(', ')}"
    exit 1
  end

  config.context_files ||= []
  config.context_files += remaining_args
end

.validate_final_prompt_requirements(config) ⇒ Object



496
497
498
499
500
501
502
503
# File 'lib/aia/config/validator.rb', line 496

def validate_final_prompt_requirements(config)
  chat_mode = config.flags.chat == true
  fuzzy_mode = config.flags.fuzzy == true
  if !chat_mode && !fuzzy_mode && (config.prompt_id.nil? || config.prompt_id.empty?) && (config.context_files.nil? || config.context_files.empty?)
    warn "Error: A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
    exit 1
  end
end

.validate_pipeline_prompts(config) ⇒ Object



519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/aia/config/validator.rb', line 519

def validate_pipeline_prompts(config)
  return if config.pipeline.empty?

  and_exit = false

  config.pipeline.each do |prompt_id|
    next if prompt_id.nil? || prompt_id.empty? || prompt_id == '__FUZZY_SEARCH__' || prompt_id == '__EXECUTABLE_PROMPT__'

    prompt_file_path = File.join(config.prompts.dir, "#{prompt_id}#{config.prompts.extname}")
    unless File.exist?(prompt_file_path)
      warn "Error: Prompt ID '#{prompt_id}' does not exist at #{prompt_file_path}"
      and_exit = true
    end
  end

  exit(1) if and_exit
end

.validate_required_prompt_id(config) ⇒ Object



121
122
123
124
125
126
# File 'lib/aia/config/validator.rb', line 121

def validate_required_prompt_id(config)
  return unless config.prompt_id.nil? && !(config.flags.chat == true) && !(config.flags.fuzzy == true)

  warn "Error: A prompt ID is required unless using --chat, --fuzzy, or providing context files. Use -h or --help for help."
  exit 1
end