aia 0.9.1 → 0.9.3rc1

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.
data/lib/aia/config.rb CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  require 'yaml'
9
9
  require 'toml-rb'
10
+ require 'date'
10
11
  require 'erb'
11
12
  require 'optparse'
12
13
  require 'json'
@@ -29,9 +30,10 @@ module AIA
29
30
  role: '',
30
31
  system_prompt: '',
31
32
 
32
- # MCP configuration
33
- mcp_servers: [],
34
- allowed_tools: nil, # nil means all tools are allowed; otherwise an Array of Strings which are the tool names
33
+ # Tools
34
+ allowed_tools: nil, # nil means all tools are allowed; otherwise an Array of Strings which are the tool names
35
+ rejected_tools: nil, # nil means no tools are rejected
36
+ tool_paths: [], # Strings - absolute and relative to tools
35
37
 
36
38
  # Flags
37
39
  markdown: true,
@@ -51,7 +53,6 @@ module AIA
51
53
  pipeline: [],
52
54
 
53
55
  # PromptManager::Prompt Tailoring
54
-
55
56
  parameter_regex: PromptManager::Prompt.parameter_regex.to_s,
56
57
 
57
58
  # LLM tuning parameters
@@ -63,15 +64,18 @@ module AIA
63
64
  image_size: '1024x1024',
64
65
  image_quality: 'standard',
65
66
  image_style: 'vivid',
67
+
66
68
  model: 'gpt-4o-mini',
67
69
  speech_model: 'tts-1',
68
70
  transcription_model: 'whisper-1',
71
+ embedding_model: 'text-embedding-ada-002',
72
+ image_model: 'dall-e-3',
73
+ refresh: 0, # days between refreshes of model info; 0 means every startup
74
+ last_refresh: Date.today - 1,
75
+
69
76
  voice: 'alloy',
70
77
  adapter: 'ruby_llm', # 'ruby_llm' or ???
71
78
 
72
- # Embedding parameters
73
- embedding_model: 'text-embedding-ada-002',
74
-
75
79
  # Default speak command
76
80
  speak_command: 'afplay', # 'afplay' for audio files
77
81
 
@@ -97,6 +101,14 @@ module AIA
97
101
  )
98
102
 
99
103
  tailor_the_config(config)
104
+ load_libraries(config)
105
+ load_tools(config)
106
+
107
+ if config.dump_file
108
+ dump_config(config, config.dump_file)
109
+ end
110
+
111
+ config
100
112
  end
101
113
 
102
114
 
@@ -129,7 +141,6 @@ module AIA
129
141
  exit 1
130
142
  end
131
143
 
132
-
133
144
  unless config.role.empty?
134
145
  unless config.roles_prefix.empty?
135
146
  unless config.role.start_with?(config.roles_prefix)
@@ -177,11 +188,6 @@ module AIA
177
188
  and_exit = true
178
189
  end
179
190
 
180
- if config.dump_file
181
- dump_config(config, config.dump_file)
182
- and_exit = true
183
- end
184
-
185
191
  exit if and_exit
186
192
 
187
193
  # Only require a prompt_id if we're not in chat mode, not using fuzzy search, and no context files
@@ -200,16 +206,64 @@ module AIA
200
206
  PromptManager::Prompt.parameter_regex = Regexp.new(config.parameter_regex)
201
207
  end
202
208
 
203
- debug_me{[ 'config.mcp_servers' ]}
209
+ config
210
+ end
211
+
212
+
213
+ def self.load_libraries(config)
214
+ return if config.require_libs.empty?
215
+
216
+ exit_on_error = false
217
+
218
+ config.require_libs.each do |library|
219
+ begin
220
+ require(library)
221
+ rescue => e
222
+ STDERR.puts "Error loading library '#{library}' #{e.message}"
223
+ exit_on_error = true
224
+ end
225
+ end
226
+
227
+ exit(1) if exit_on_error
228
+
229
+ config
230
+ end
231
+
232
+
233
+ def self.load_tools(config)
234
+ return if config.tool_paths.empty?
235
+
236
+ exit_on_error = false
237
+
238
+ unless config.allowed_tools.nil?
239
+ config.tool_paths.select! do |path|
240
+ config.allowed_tools.any? { |allowed| path.include?(allowed) }
241
+ end
242
+ end
243
+
244
+ unless config.rejected_tools.nil?
245
+ config.tool_paths.reject! do |path|
246
+ config.rejected_tools.any? { |rejected| path.include?(rejected) }
247
+ end
248
+ end
204
249
 
205
- unless config.mcp_servers.empty?
206
- # create a single JSON file contain all of the MCP server definitions specified my the --mcp option
207
- config.mcp_servers = combine_mcp_server_json_files config.mcp_servers
250
+ config.tool_paths.each do |tool_path|
251
+ begin
252
+ # expands path based on PWD
253
+ absolute_tool_path = File.expand_path(tool_path)
254
+ require(absolute_tool_path)
255
+ rescue => e
256
+ STDERR.puts "Error loading tool '#{tool_path}' #{e.message}"
257
+ exit_on_error = true
258
+ end
208
259
  end
209
260
 
261
+ exit(1) if exit_on_error
262
+
210
263
  config
211
264
  end
212
265
 
266
+
213
267
  # envar values are always String object so need other config
214
268
  # layers to know the prompter type for each key's value
215
269
  def self.envar_options(default, cli_config)
@@ -241,237 +295,264 @@ module AIA
241
295
  def self.cli_options
242
296
  config = OpenStruct.new
243
297
 
244
- opt_parser = OptionParser.new do |opts|
245
- opts.banner = "Usage: aia [options] [PROMPT_ID] [CONTEXT_FILE]*\n" +
246
- " aia --chat [PROMPT_ID] [CONTEXT_FILE]*\n" +
247
- " aia --chat [CONTEXT_FILE]*"
248
-
249
- opts.on("--chat", "Begin a chat session with the LLM after the initial prompt response; will set --no-out_file so that the LLM response comes to STDOUT.") do
250
- config.chat = true
251
- puts "Debug: Setting chat mode to true" if config.debug
252
- end
298
+ begin
299
+ opt_parser = OptionParser.new do |opts|
300
+ opts.banner = "Usage: aia [options] [PROMPT_ID] [CONTEXT_FILE]*\n" +
301
+ " aia --chat [PROMPT_ID] [CONTEXT_FILE]*\n" +
302
+ " aia --chat [CONTEXT_FILE]*"
303
+
304
+ opts.on("--chat", "Begin a chat session with the LLM after the initial prompt response; will set --no-out_file so that the LLM response comes to STDOUT.") do
305
+ config.chat = true
306
+ puts "Debug: Setting chat mode to true" if config.debug
307
+ end
253
308
 
254
- opts.on("--adapter ADAPTER", "Interface that adapts AIA to the LLM") do |adapter|
255
- adapter.downcase!
256
- valid_adapters = %w[ ruby_llm ] # NOTE: Add additional adapters here when needed
257
- if valid_adapters.include? adapter
258
- config.adapter = adapter
259
- else
260
- STDERR.puts "ERROR: Invalid adapter #{adapter} must be one of these: #{valid_adapters.join(', ')}"
261
- exit 1
309
+ opts.on("--adapter ADAPTER", "Interface that adapts AIA to the LLM") do |adapter|
310
+ adapter.downcase!
311
+ valid_adapters = %w[ ruby_llm ] # NOTE: Add additional adapters here when needed
312
+ if valid_adapters.include? adapter
313
+ config.adapter = adapter
314
+ else
315
+ STDERR.puts "ERROR: Invalid adapter #{adapter} must be one of these: #{valid_adapters.join(', ')}"
316
+ exit 1
317
+ end
262
318
  end
263
- end
264
319
 
320
+ opts.on("-m MODEL", "--model MODEL", "Name of the LLM model to use") do |model|
321
+ config.model = model
322
+ end
265
323
 
266
- opts.on("-m MODEL", "--model MODEL", "Name of the LLM model to use") do |model|
267
- config.model = model
268
- end
324
+ opts.on("--terse", "Adds a special instruction to the prompt asking the AI to keep responses short and to the point") do
325
+ config.terse = true
326
+ end
269
327
 
270
- opts.on("--terse", "Adds a special instruction to the prompt asking the AI to keep responses short and to the point") do
271
- config.terse = true
272
- end
328
+ opts.on("-c", "--config_file FILE", "Load config file") do |file|
329
+ if File.exist?(file)
330
+ ext = File.extname(file).downcase
331
+ content = File.read(file)
273
332
 
274
- opts.on("-c", "--config_file FILE", "Load config file") do |file|
275
- if File.exist?(file)
276
- ext = File.extname(file).downcase
277
- content = File.read(file)
333
+ # Process ERB if filename ends with .erb
334
+ if file.end_with?('.erb')
335
+ content = ERB.new(content).result
336
+ file = file.chomp('.erb')
337
+ File.write(file, content)
338
+ end
278
339
 
279
- # Process ERB if filename ends with .erb
280
- if file.end_with?('.erb')
281
- content = ERB.new(content).result
282
- file = file.chomp('.erb')
283
- File.write(file, content)
340
+ file_config = case ext
341
+ when '.yml', '.yaml'
342
+ YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true)
343
+ when '.toml'
344
+ TomlRB.parse(content)
345
+ else
346
+ raise "Unsupported config file format: #{ext}"
347
+ end
348
+
349
+ file_config.each do |key, value|
350
+ config[key.to_sym] = value
351
+ end
352
+ else
353
+ raise "Config file not found: #{file}"
284
354
  end
355
+ end
285
356
 
286
- file_config = case ext
287
- when '.yml', '.yaml'
288
- YAML.safe_load(content, permitted_classes: [Symbol], symbolize_names: true)
289
- when '.toml'
290
- TomlRB.parse(content)
291
- else
292
- raise "Unsupported config file format: #{ext}"
293
- end
294
-
295
- file_config.each do |key, value|
296
- config[key.to_sym] = value
297
- end
298
- else
299
- raise "Config file not found: #{file}"
357
+ opts.on("-p", "--prompts_dir DIR", "Directory containing prompt files") do |dir|
358
+ config.prompts_dir = dir
300
359
  end
301
- end
302
360
 
303
- opts.on("-p", "--prompts_dir DIR", "Directory containing prompt files") do |dir|
304
- config.prompts_dir = dir
305
- end
361
+ opts.on("--roles_prefix PREFIX", "Subdirectory name for role files (default: roles)") do |prefix|
362
+ config.roles_prefix = prefix
363
+ end
306
364
 
307
- opts.on("--roles_prefix PREFIX", "Subdirectory name for role files (default: roles)") do |prefix|
308
- config.roles_prefix = prefix
309
- end
365
+ opts.on("-r", "--role ROLE_ID", "Role ID to prepend to prompt") do |role|
366
+ config.role = role
367
+ end
310
368
 
311
- opts.on("-r", "--role ROLE_ID", "Role ID to prepend to prompt") do |role|
312
- config.role = role
313
- end
369
+ opts.on("--refresh DAYS", Integer, "Refresh models database interval in days") do |days|
370
+ config.refresh = days || 0
371
+ end
314
372
 
315
- opts.on('--regex pattern', 'Regex pattern to extract parameters from prompt text') do |pattern|
316
- config.parameter_regex = pattern
317
- end
373
+ opts.on('--regex pattern', 'Regex pattern to extract parameters from prompt text') do |pattern|
374
+ config.parameter_regex = pattern
375
+ end
318
376
 
319
- opts.on("-o", "--[no-]out_file [FILE]", "Output file (default: temp.md)") do |file|
320
- config.out_file = file ? File.expand_path(file, Dir.pwd) : 'temp.md'
321
- end
377
+ opts.on("-o", "--[no-]out_file [FILE]", "Output file (default: temp.md)") do |file|
378
+ config.out_file = file ? File.expand_path(file, Dir.pwd) : 'temp.md'
379
+ end
322
380
 
323
- opts.on("-a", "--[no-]append", "Append to output file instead of overwriting") do |append|
324
- config.append = append
325
- end
381
+ opts.on("-a", "--[no-]append", "Append to output file instead of overwriting") do |append|
382
+ config.append = append
383
+ end
326
384
 
327
- opts.on("-l", "--[no-]log_file [FILE]", "Log file") do |file|
328
- config.log_file = file
329
- end
385
+ opts.on("-l", "--[no-]log_file [FILE]", "Log file") do |file|
386
+ config.log_file = file
387
+ end
330
388
 
331
- opts.on("--md", "--[no-]markdown", "Format with Markdown") do |md|
332
- config.markdown = md
333
- end
389
+ opts.on("--md", "--[no-]markdown", "Format with Markdown") do |md|
390
+ config.markdown = md
391
+ end
334
392
 
335
- opts.on("-n", "--next PROMPT_ID", "Next prompt to process") do |next_prompt|
336
- config.next = next_prompt
337
- end
393
+ opts.on("-n", "--next PROMPT_ID", "Next prompt to process") do |next_prompt|
394
+ config.next = next_prompt
395
+ end
338
396
 
339
- opts.on("--pipeline PROMPTS", "Pipeline of prompts to process") do |pipeline|
340
- config.pipeline = pipeline.split(',')
341
- end
397
+ opts.on("--pipeline PROMPTS", "Pipeline of prompts to process") do |pipeline|
398
+ config.pipeline = pipeline.split(',')
399
+ end
342
400
 
343
- opts.on("-f", "--fuzzy", "Use fuzzy matching for prompt search") do
344
- unless system("which fzf > /dev/null 2>&1")
345
- STDERR.puts "Error: 'fzf' is not installed. Please install 'fzf' to use the --fuzzy option."
346
- exit 1
401
+ opts.on("-f", "--fuzzy", "Use fuzzy matching for prompt search") do
402
+ unless system("which fzf > /dev/null 2>&1")
403
+ STDERR.puts "Error: 'fzf' is not installed. Please install 'fzf' to use the --fuzzy option."
404
+ exit 1
405
+ end
406
+ config.fuzzy = true
347
407
  end
348
- config.fuzzy = true
349
- end
350
408
 
351
- opts.on("-d", "--debug", "Enable debug output") do
352
- config.debug = $DEBUG_ME = true
353
- end
409
+ opts.on("-d", "--debug", "Enable debug output") do
410
+ config.debug = $DEBUG_ME = true
411
+ end
354
412
 
355
- opts.on("--no-debug", "Disable debug output") do
356
- config.debug = $DEBUG_ME = false
357
- end
413
+ opts.on("--no-debug", "Disable debug output") do
414
+ config.debug = $DEBUG_ME = false
415
+ end
358
416
 
359
- opts.on("-v", "--verbose", "Be verbose") do
360
- config.verbose = true
361
- end
417
+ opts.on("-v", "--verbose", "Be verbose") do
418
+ config.verbose = true
419
+ end
362
420
 
363
- opts.on("--speak", "Simple implementation. Uses the speech model to convert text to audio, then plays the audio. Fun with --chat. Supports configuration of speech model and voice.") do
364
- config.speak = true
365
- end
421
+ opts.on("--speak", "Simple implementation. Uses the speech model to convert text to audio, then plays the audio. Fun with --chat. Supports configuration of speech model and voice.") do
422
+ config.speak = true
423
+ end
366
424
 
367
- opts.on("--voice VOICE", "Voice to use for speech") do |voice|
368
- config.voice = voice
369
- end
425
+ opts.on("--voice VOICE", "Voice to use for speech") do |voice|
426
+ config.voice = voice
427
+ end
370
428
 
371
- opts.on("--sm", "--speech_model MODEL", "Speech model to use") do |model|
372
- config.speech_model = model
373
- end
429
+ opts.on("--sm", "--speech_model MODEL", "Speech model to use") do |model|
430
+ config.speech_model = model
431
+ end
374
432
 
375
- opts.on("--tm", "--transcription_model MODEL", "Transcription model to use") do |model|
376
- config.transcription_model = model
377
- end
433
+ opts.on("--tm", "--transcription_model MODEL", "Transcription model to use") do |model|
434
+ config.transcription_model = model
435
+ end
378
436
 
379
- opts.on("--is", "--image_size SIZE", "Image size for image generation") do |size|
380
- config.image_size = size
381
- end
437
+ opts.on("--is", "--image_size SIZE", "Image size for image generation") do |size|
438
+ config.image_size = size
439
+ end
382
440
 
383
- opts.on("--iq", "--image_quality QUALITY", "Image quality for image generation") do |quality|
384
- config.image_quality = quality
385
- end
441
+ opts.on("--iq", "--image_quality QUALITY", "Image quality for image generation") do |quality|
442
+ config.image_quality = quality
443
+ end
386
444
 
387
- opts.on("--style", "--image_style STYLE", "Style for image generation") do |style|
388
- config.image_style = style
389
- end
445
+ opts.on("--style", "--image_style STYLE", "Style for image generation") do |style|
446
+ config.image_style = style
447
+ end
390
448
 
391
- opts.on("--system_prompt PROMPT_ID", "System prompt ID to use for chat sessions") do |prompt_id|
392
- config.system_prompt = prompt_id
393
- end
449
+ opts.on("--system_prompt PROMPT_ID", "System prompt ID to use for chat sessions") do |prompt_id|
450
+ config.system_prompt = prompt_id
451
+ end
394
452
 
395
- # AI model parameters
396
- opts.on("-t", "--temperature TEMP", Float, "Temperature for text generation") do |temp|
397
- config.temperature = temp
398
- end
453
+ # AI model parameters
454
+ opts.on("-t", "--temperature TEMP", Float, "Temperature for text generation") do |temp|
455
+ config.temperature = temp
456
+ end
399
457
 
400
- opts.on("--max_tokens TOKENS", Integer, "Maximum tokens for text generation") do |tokens|
401
- config.max_tokens = tokens
402
- end
458
+ opts.on("--max_tokens TOKENS", Integer, "Maximum tokens for text generation") do |tokens|
459
+ config.max_tokens = tokens
460
+ end
403
461
 
404
- opts.on("--top_p VALUE", Float, "Top-p sampling value") do |value|
405
- config.top_p = value
406
- end
462
+ opts.on("--top_p VALUE", Float, "Top-p sampling value") do |value|
463
+ config.top_p = value
464
+ end
407
465
 
408
- opts.on("--frequency_penalty VALUE", Float, "Frequency penalty") do |value|
409
- config.frequency_penalty = value
410
- end
466
+ opts.on("--frequency_penalty VALUE", Float, "Frequency penalty") do |value|
467
+ config.frequency_penalty = value
468
+ end
411
469
 
412
- opts.on("--presence_penalty VALUE", Float, "Presence penalty") do |value|
413
- config.presence_penalty = value
414
- end
470
+ opts.on("--presence_penalty VALUE", Float, "Presence penalty") do |value|
471
+ config.presence_penalty = value
472
+ end
415
473
 
416
- opts.on("--dump FILE", "Dump config to file") do |file|
417
- config.dump_file = file
418
- end
474
+ opts.on("--dump FILE", "Dump config to file") do |file|
475
+ config.dump_file = file
476
+ end
419
477
 
420
- opts.on("--completion SHELL", "Show completion script for bash|zsh|fish - default is nil") do |shell|
421
- config.completion = shell
422
- end
478
+ opts.on("--completion SHELL", "Show completion script for bash|zsh|fish - default is nil") do |shell|
479
+ config.completion = shell
480
+ end
423
481
 
424
- opts.on("--version", "Show version") do
425
- puts AIA::VERSION
426
- exit
427
- end
482
+ opts.on("--version", "Show version") do
483
+ puts AIA::VERSION
484
+ exit
485
+ end
428
486
 
429
- opts.on("-h", "--help", "Prints this help") do
430
- puts opts
431
- exit
432
- end
487
+ opts.on("-h", "--help", "Prints this help") do
488
+ puts opts
489
+ exit
490
+ end
433
491
 
434
- opts.on("--rq LIBS", "Ruby libraries to require for Ruby directive") do |libs|
435
- config.require_libs = libs.split(',')
436
- end
492
+ opts.on("--rq LIBS", "--require LIBS", "Ruby libraries to require for Ruby directive") do |libs|
493
+ config.require_libs ||= []
494
+ config.require_libs += libs.split(',')
495
+ end
496
+
497
+ opts.on("--tools PATH_LIST", "Add a tool(s)") do |a_path_list|
498
+ config.tool_paths ||= []
499
+
500
+ if a_path_list.empty?
501
+ STDERR.puts "No list of paths for --tools option"
502
+ exit 1
503
+ else
504
+ paths = a_path_list.split(',').map(&:strip).uniq
505
+ end
506
+
507
+ paths.each do |a_path|
508
+ if File.exist?(a_path)
509
+ if File.file?(a_path)
510
+ if '.rb' == File.extname(a_path)
511
+ config.tool_paths << a_path
512
+ else
513
+ STDERR.puts "file should have *.rb extension: #{a_path}"
514
+ exit 1
515
+ end
516
+ elsif File.directory?(a_path)
517
+ rb_files = Dir.glob(File.join(a_path, '**', '*.rb'))
518
+ config.tool_paths += rb_files
519
+ end
520
+ else
521
+ STDERR.puts "file/dir path is not valid: #{a_path}"
522
+ exit 1
523
+ end
524
+ end
437
525
 
438
- opts.on("--mcp FILE", "Add MCP server configuration from JSON file. Can be specified multiple times.") do |file|
439
- # debug_me FIXME ruby-mcp-client is looking for a single JSON file that
440
- # could contain multiple server definitions that looks like this:
441
- # {
442
- # "mcpServers": {
443
- # "server one": { ... },
444
- # "server two": { ... }, ....
445
- # }
446
- # }
447
- # FIXME: need to rurn multiple JSON files into one.
448
- if AIA.good_file?(file)
449
- config.mcp_servers ||= []
450
- config.mcp_servers << file
451
- begin
452
- server_config = JSON.parse(File.read(file))
453
- config.mcp_servers_config ||= []
454
- config.mcp_servers_config << server_config
455
- rescue JSON::ParserError => e
456
- STDERR.puts "Error parsing MCP server config file #{file}: #{e.message}"
526
+ config.tool_paths.uniq!
527
+ end
528
+
529
+ opts.on("--at", "--allowed_tools TOOLS_LIST", "Allow only these tools to be used") do |tools_list|
530
+ config.allowed_tools ||= []
531
+ if tools_list.empty?
532
+ STDERR.puts "No list of tool names provided for --allowed_tools option"
457
533
  exit 1
534
+ else
535
+ config.allowed_tools += tools_list.split(',').map(&:strip)
536
+ config.allowed_tools.uniq!
458
537
  end
459
- else
460
- STDERR.puts "MCP server config file not found: #{file}"
461
- exit 1
462
538
  end
463
- end
464
539
 
465
- opts.on("--at", "--allowed_tools TOOLS_LIST", "Allow only these tools to be used") do |tools_list|
466
- config.allowed_tools ||= []
467
- if tools_list.empty?
468
- STDERR.puts "No list of tool names provided for --allowed_tools option"
469
- exit 1
470
- else
471
- config.allowed_tools += tools_list.split(',').map(&:strip)
472
- config.allowed_tools.uniq!
540
+ opts.on("--rt", "--rejected_tools TOOLS_LIST", "Reject these tools") do |tools_list|
541
+ config.rejected_tools ||= []
542
+ if tools_list.empty?
543
+ STDERR.puts "No list of tool names provided for --rejected_tools option"
544
+ exit 1
545
+ else
546
+ config.rejected_tools += tools_list.split(',').map(&:strip)
547
+ config.rejected_tools.uniq!
548
+ end
473
549
  end
474
550
  end
551
+ opt_parser.parse!
552
+ rescue => e
553
+ STDERR.puts "ERROR: #{e.message}"
554
+ STDERR.puts " use --help for usage report"
555
+ exit 1
475
556
  end
476
557
 
477
558
  args = ARGV.dup
@@ -519,6 +600,12 @@ module AIA
519
600
  STDERR.puts "WARNING:Config file not found: #{file}"
520
601
  end
521
602
 
603
+ if config.last_refresh
604
+ if config.last_refresh.is_a? String
605
+ config.last_refresh = Date.strptime(config.last_refresh, '%Y-%m-%d')
606
+ end
607
+ end
608
+
522
609
  config
523
610
  end
524
611
 
@@ -537,10 +624,10 @@ module AIA
537
624
  def self.dump_config(config, file)
538
625
  # Implementation for config dump
539
626
  ext = File.extname(file).downcase
540
- config_hash = config.to_h
541
627
 
542
- # Remove non-serializable objects
543
- config_hash.delete_if { |_, v| !v.nil? && !v.is_a?(String) && !v.is_a?(Numeric) && !v.is_a?(TrueClass) && !v.is_a?(FalseClass) && !v.is_a?(Array) && !v.is_a?(Hash) }
628
+ config.last_refresh = config.last_refresh.to_s if config.last_refresh.is_a? Date
629
+
630
+ config_hash = config.to_h
544
631
 
545
632
  # Remove dump_file key to prevent automatic exit on next load
546
633
  config_hash.delete(:dump_file)
@@ -557,95 +644,5 @@ module AIA
557
644
  File.write(file, content)
558
645
  puts "Config successfully dumped to #{file}"
559
646
  end
560
-
561
-
562
- # Combine multiple MCP server JSON files into a single file
563
- def self.combine_mcp_server_json_files(file_paths)
564
- raise ArgumentError, "No JSON files provided" if file_paths.nil? || file_paths.empty?
565
-
566
- # The output will have only one top-level key: "mcpServers"
567
- mcp_servers = {} # This will store all collected server_name => server_config pairs
568
-
569
- file_paths.each do |file_path|
570
- file_content = JSON.parse(File.read(file_path))
571
- # Clean basename, e.g., "filesystem.json" -> "filesystem", "foo.json.erb" -> "foo"
572
- cleaned_basename = File.basename(file_path).sub(/\.json\.erb$/, '').sub(/\.json$/, '')
573
-
574
- if file_content.is_a?(Hash)
575
- if file_content.key?("mcpServers") && file_content["mcpServers"].is_a?(Hash)
576
- # Case A: {"mcpServers": {"name1": {...}, "name2": {...}}}
577
- file_content["mcpServers"].each do |server_name, server_data|
578
- if mcp_servers.key?(server_name)
579
- STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' found. Overwriting with definition from #{file_path}."
580
- end
581
- mcp_servers[server_name] = server_data
582
- end
583
- # Check if the root hash itself is a single server definition
584
- elsif is_single_server_definition?(file_content)
585
- # Case B: {"type": "stdio", ...} or {"url": "...", ...}
586
- # Use "name" property from JSON if present, otherwise use cleaned_basename
587
- server_name = file_content["name"] || cleaned_basename
588
- if mcp_servers.key?(server_name)
589
- STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' (from file #{file_path}). Overwriting."
590
- end
591
- mcp_servers[server_name] = file_content
592
- else
593
- # Case D: Fallback for {"custom_name1": {server_config1}, "custom_name2": {server_config2}}
594
- # This assumes top-level keys are server names and values are server configs.
595
- file_content.each do |server_name, server_data|
596
- if server_data.is_a?(Hash) && is_single_server_definition?(server_data)
597
- if mcp_servers.key?(server_name)
598
- STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' found in #{file_path}. Overwriting."
599
- end
600
- mcp_servers[server_name] = server_data
601
- else
602
- STDERR.puts "Warning: Unrecognized structure for key '#{server_name}' in #{file_path}. Value is not a valid server definition. Skipping."
603
- end
604
- end
605
- end
606
- elsif file_content.is_a?(Array)
607
- # Case C: [ {server_config1}, {server_config2_with_name} ]
608
- file_content.each_with_index do |server_data, index|
609
- if server_data.is_a?(Hash) && is_single_server_definition?(server_data)
610
- # Use "name" property from JSON if present, otherwise generate one
611
- server_name = server_data["name"] || "#{cleaned_basename}_#{index}"
612
- if mcp_servers.key?(server_name)
613
- STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' (from array in #{file_path}). Overwriting."
614
- end
615
- mcp_servers[server_name] = server_data
616
- else
617
- STDERR.puts "Warning: Unrecognized item in array in #{file_path} at index #{index}. Skipping."
618
- end
619
- end
620
- else
621
- STDERR.puts "Warning: Unrecognized JSON structure in #{file_path}. Skipping."
622
- end
623
- end
624
-
625
- # Create the final output structure
626
- output = {"mcpServers" => mcp_servers}
627
- temp_file = Tempfile.new(['combined', '.json'])
628
- temp_file.write(JSON.pretty_generate(output))
629
- temp_file.close
630
-
631
- temp_file.path
632
- end
633
-
634
- # Helper method to determine if a hash represents a valid MCP server definition
635
- def self.is_single_server_definition?(config)
636
- return false unless config.is_a?(Hash)
637
- type = config['type']
638
- if type
639
- return true if type == 'stdio' && config.key?('command')
640
- return true if type == 'sse' && config.key?('url')
641
- # Potentially other explicit types if they exist in MCP
642
- return false # Known type but missing required fields for it, or unknown type
643
- else
644
- # Infer type
645
- return true if config.key?('command') || config.key?('args') || config.key?('env') # stdio
646
- return true if config.key?('url') # sse
647
- end
648
- false
649
- end
650
647
  end
651
648
  end