aia 1.0.0.pre.beta → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/CHANGELOG.md +89 -0
  4. data/COMMITS.md +192 -11
  5. data/README.md +327 -110
  6. data/docs/cli-reference.md +93 -10
  7. data/docs/configuration.md +29 -36
  8. data/docs/contributing.md +2 -2
  9. data/docs/directives-reference.md +49 -27
  10. data/docs/examples/index.md +2 -2
  11. data/docs/examples/mcp/index.md +93 -97
  12. data/docs/examples/prompts/automation/index.md +3 -2
  13. data/docs/examples/tools/index.md +17 -27
  14. data/docs/faq.md +9 -12
  15. data/docs/guides/basic-usage.md +4 -4
  16. data/docs/guides/chat.md +39 -34
  17. data/docs/guides/tools.md +4 -4
  18. data/docs/index.md +36 -62
  19. data/docs/installation.md +1 -1
  20. data/docs/mcp-integration.md +75 -139
  21. data/docs/prompt_management.md +88 -1
  22. data/docs/security.md +79 -81
  23. data/docs/tools-and-mcp-examples.md +8 -6
  24. data/docs/workflows-and-pipelines.md +2 -6
  25. data/examples/.gitignore +1 -0
  26. data/examples/README.md +41 -0
  27. data/examples/run_all.sh +261 -0
  28. data/lib/aia/adapter/chat_execution.rb +9 -7
  29. data/lib/aia/adapter/mcp_connector.rb +0 -29
  30. data/lib/aia/adapter/modality_handlers.rb +23 -15
  31. data/lib/aia/adapter/tool_filter.rb +21 -0
  32. data/lib/aia/adapter/tool_loader.rb +1 -9
  33. data/lib/aia/chat_loop.rb +244 -0
  34. data/lib/aia/chat_processor_service.rb +6 -3
  35. data/lib/aia/config/cli_parser.rb +56 -18
  36. data/lib/aia/config/defaults.yml +17 -2
  37. data/lib/aia/config/validator.rb +52 -11
  38. data/lib/aia/config.rb +29 -3
  39. data/lib/aia/directive.rb +29 -0
  40. data/lib/aia/directives/configuration_directives.rb +2 -1
  41. data/lib/aia/directives/execution_directives.rb +1 -1
  42. data/lib/aia/directives/model_directives.rb +28 -27
  43. data/lib/aia/directives/web_and_file_directives.rb +78 -40
  44. data/lib/aia/errors.rb +20 -1
  45. data/lib/aia/fzf.rb +8 -7
  46. data/lib/aia/input_collector.rb +24 -0
  47. data/lib/aia/prompt_handler.rb +36 -8
  48. data/lib/aia/prompt_pipeline.rb +183 -0
  49. data/lib/aia/session.rb +22 -372
  50. data/lib/aia/skill_utils.rb +61 -0
  51. data/lib/aia/ui_presenter.rb +8 -0
  52. data/lib/aia.rb +4 -0
  53. metadata +19 -45
data/lib/aia/directive.rb CHANGED
@@ -84,5 +84,34 @@ module AIA
84
84
  ""
85
85
  end
86
86
  end
87
+
88
+ private
89
+
90
+ # Split args into positive and negative search terms.
91
+ # Tokens prefixed with -, ~, or ! are negative (exclusion) terms.
92
+ # All other tokens (bare or +-prefixed) are positive (inclusion) terms.
93
+ # All terms are downcased.
94
+ #
95
+ # @param args [Array<String>] raw argument tokens
96
+ # @return [Array<Array<String>>] [positive_terms, negative_terms]
97
+ def parse_search_terms(args)
98
+ positive = []
99
+ negative = []
100
+
101
+ Array(args).each do |arg|
102
+ arg.split.each do |token|
103
+ downcased = token.downcase
104
+ if downcased =~ /\A[-~!]/
105
+ negative << downcased[1..]
106
+ elsif downcased.start_with?('+')
107
+ positive << downcased[1..]
108
+ else
109
+ positive << downcased
110
+ end
111
+ end
112
+ end
113
+
114
+ [positive, negative]
115
+ end
87
116
  end
88
117
  end
@@ -32,7 +32,8 @@ module AIA
32
32
  if AIA.config.respond_to?(setter)
33
33
  AIA.config.send(setter, new_value)
34
34
  else
35
- puts "Warning: Unknown config option '#{config_item}'"
35
+ warn "Warning: Unknown config option '#{config_item}'"
36
+ AIA::LoggerManager.aia_logger.warn("Unknown config option '#{config_item}'")
36
37
  end
37
38
  ""
38
39
  end
@@ -10,7 +10,7 @@ module AIA
10
10
 
11
11
  begin
12
12
  String(eval(ruby_code))
13
- rescue Exception => e
13
+ rescue StandardError => e
14
14
  <<~ERROR
15
15
  This ruby code failed: #{ruby_code}
16
16
  #{e.message}
@@ -4,6 +4,8 @@ module AIA
4
4
  class ModelDirectives < Directive
5
5
  desc "List all available AI models"
6
6
  def available_models(args = nil, context_manager = nil)
7
+ positive_terms, negative_terms = parse_search_terms(Array(args))
8
+
7
9
  current_models = AIA.config.models
8
10
 
9
11
  model_names = current_models.map do |m|
@@ -13,9 +15,9 @@ module AIA
13
15
  using_local_provider = model_names.any? { |m| m.start_with?('ollama/', 'lms/') }
14
16
 
15
17
  if using_local_provider
16
- show_local_models(model_names, args)
18
+ show_local_models(model_names, positive_terms, negative_terms)
17
19
  else
18
- show_rubyllm_models(args)
20
+ show_rubyllm_models(positive_terms, negative_terms)
19
21
  end
20
22
 
21
23
  ""
@@ -80,7 +82,7 @@ module AIA
80
82
 
81
83
  # --- helpers (no desc → not registered) ---
82
84
 
83
- def show_local_models(current_models, args)
85
+ def show_local_models(current_models, positive_terms, negative_terms)
84
86
  require 'net/http'
85
87
  require 'json'
86
88
 
@@ -91,15 +93,15 @@ module AIA
91
93
  if model_spec.start_with?('ollama/')
92
94
  api_base = ENV.fetch('OLLAMA_API_BASE', 'http://localhost:11434')
93
95
  api_base = api_base.gsub(%r{/v1/?$}, '')
94
- show_ollama_models(api_base, args)
96
+ show_ollama_models(api_base, positive_terms, negative_terms)
95
97
  elsif model_spec.start_with?('lms/')
96
98
  api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234')
97
- show_lms_models(api_base, args)
99
+ show_lms_models(api_base, positive_terms, negative_terms)
98
100
  end
99
101
  end
100
102
  end
101
103
 
102
- def show_ollama_models(api_base, args)
104
+ def show_ollama_models(api_base, positive_terms, negative_terms)
103
105
  begin
104
106
  uri = URI("#{api_base}/api/tags")
105
107
  response = Net::HTTP.get_response(uri)
@@ -127,8 +129,12 @@ module AIA
127
129
  modified = model['modified_at'] ? Time.parse(model['modified_at']).strftime('%Y-%m-%d') : 'unknown'
128
130
 
129
131
  entry = "- ollama/#{name} (size: #{size}, modified: #{modified})"
132
+ entry_down = entry.downcase
133
+
134
+ show_it = positive_terms.empty? || positive_terms.any? { |q| entry_down.include?(q) }
135
+ show_it &&= negative_terms.none? { |q| entry_down.include?(q) }
130
136
 
131
- if args.nil? || args.empty? || args.any? { |q| entry.downcase.include?(q.downcase) }
137
+ if show_it
132
138
  puts entry
133
139
  counter += 1
134
140
  end
@@ -142,7 +148,7 @@ module AIA
142
148
  end
143
149
  end
144
150
 
145
- def show_lms_models(api_base, args)
151
+ def show_lms_models(api_base, positive_terms, negative_terms)
146
152
  begin
147
153
  uri = URI("#{api_base.gsub(%r{/v1/?$}, '')}/v1/models")
148
154
  response = Net::HTTP.get_response(uri)
@@ -167,8 +173,12 @@ module AIA
167
173
  models.each do |model|
168
174
  name = model['id']
169
175
  entry = "- lms/#{name}"
176
+ entry_down = entry.downcase
177
+
178
+ show_it = positive_terms.empty? || positive_terms.any? { |q| entry_down.include?(q) }
179
+ show_it &&= negative_terms.none? { |q| entry_down.include?(q) }
170
180
 
171
- if args.nil? || args.empty? || args.any? { |q| entry.downcase.include?(q.downcase) }
181
+ if show_it
172
182
  puts entry
173
183
  counter += 1
174
184
  end
@@ -192,22 +202,17 @@ module AIA
192
202
  "%.1f %s" % [bytes.to_f / (1024 ** exp), units[exp]]
193
203
  end
194
204
 
195
- def show_rubyllm_models(args)
196
- query = args
197
-
198
- if query && 1 == query.size
199
- query = query.first.split(',')
200
- end
205
+ def show_rubyllm_models(positive_terms, negative_terms)
206
+ modality_terms = positive_terms.select { |q| q.include?('_to_') }
207
+ text_terms = positive_terms.reject { |q| q.include?('_to_') }
201
208
 
202
209
  header = "\nAvailable LLMs"
203
- header += " for #{query.join(' and ')}" if query
210
+ header += " for #{positive_terms.join(' and ')}" unless positive_terms.empty?
211
+ header += " excluding: #{negative_terms.join(', ')}" unless negative_terms.empty?
204
212
 
205
213
  puts header + ':'
206
214
  puts
207
215
 
208
- q1 = query ? query.select { |q| q.include?('_to_') } : []
209
- q2 = query ? query.reject { |q| q.include?('_to_') } : []
210
-
211
216
  counter = 0
212
217
 
213
218
  RubyLLM.models.all.each do |llm|
@@ -218,16 +223,12 @@ module AIA
218
223
  mode = "#{inputs} to #{outputs}"
219
224
  in_1m = llm.pricing.text_tokens.standard.to_h[:input_per_million]
220
225
  entry = "- #{llm.id} (#{llm.provider}) in: $#{in_1m} cw: #{cw} mode: #{mode} caps: #{caps}"
221
-
222
- if query.nil? || query.empty?
223
- counter += 1
224
- puts entry
225
- next
226
- end
226
+ entry_down = entry.downcase
227
227
 
228
228
  show_it = true
229
- q1.each { |q| show_it &&= llm.modalities.send("#{q}?") }
230
- q2.each { |q| show_it &&= entry.include?(q) }
229
+ modality_terms.each { |q| show_it &&= llm.modalities.send("#{q}?") }
230
+ text_terms.each { |q| show_it &&= entry_down.include?(q) }
231
+ negative_terms.each { |q| show_it &&= !entry_down.include?(q) }
231
232
 
232
233
  if show_it
233
234
  counter += 1
@@ -2,11 +2,14 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'clipboard'
5
+ require 'yaml'
6
+ require 'io/console'
7
+ require 'word_wrapper'
5
8
 
6
9
  module AIA
7
10
  class WebAndFileDirectives < Directive
11
+ include AIA::SkillUtils
8
12
  PUREMD_API_KEY = ENV.fetch('PUREMD_API_KEY', nil)
9
- SKILLS_DIR = File.expand_path('~/.claude/skills')
10
13
 
11
14
  desc "Fetch and include content from a webpage"
12
15
  def webpage(args, _context_manager = nil)
@@ -30,46 +33,94 @@ module AIA
30
33
  alias_method :website, :webpage
31
34
  alias_method :web, :webpage
32
35
 
33
- desc "List available Claude Code skills"
34
- def skills(_args = [], _context_manager = nil)
35
- unless Dir.exist?(SKILLS_DIR)
36
- puts "No skills directory found at #{SKILLS_DIR}"
36
+ desc "List available AIA skills"
37
+ def skills(args = [], _context_manager = nil)
38
+ dir = aia_skills_dir
39
+
40
+ unless Dir.exist?(dir)
41
+ puts "No skills directory found at #{dir}"
37
42
  return nil
38
43
  end
39
44
 
40
- entries = Dir.children(SKILLS_DIR)
41
- .select { |e| Dir.exist?(File.join(SKILLS_DIR, e)) }
42
- .sort
45
+ positive_terms, negative_terms = parse_search_terms(Array(args))
43
46
 
44
- if entries.empty?
45
- puts "No skills found in #{SKILLS_DIR}"
46
- else
47
- puts "\nAvailable Skills"
48
- puts "================"
49
- entries.each { |name| puts " #{name}" }
50
- puts "\nTotal: #{entries.size} skills"
47
+ skill_dirs = Dir.children(dir)
48
+ .select { |e| Dir.exist?(File.join(dir, e)) }
49
+ .sort
50
+
51
+ if skill_dirs.empty?
52
+ puts "No skills found in #{dir}"
53
+ return nil
54
+ end
55
+
56
+ skill_data = skill_dirs.filter_map do |name|
57
+ fm = parse_front_matter(File.join(dir, name, 'SKILL.md'))
58
+ text = "#{name} #{fm.values.join(' ').downcase}"
59
+ pos_ok = positive_terms.empty? || positive_terms.all? { |t| text.include?(t) }
60
+ neg_ok = negative_terms.none? { |t| text.include?(t) }
61
+ [name, fm] if pos_ok && neg_ok
62
+ end
63
+
64
+ if skill_data.empty?
65
+ puts "No skills matching your query"
66
+ return nil
51
67
  end
52
68
 
69
+ width = terminal_width
70
+ puts "\nAvailable Skills (#{dir}):"
71
+ puts "=" * [width, 60].min
72
+
73
+ skill_data.each do |name, fm|
74
+ display_name = fm['name'] || name
75
+ desc_text = fm['description'].to_s.strip
76
+ puts "\n #{display_name}"
77
+ unless desc_text.empty?
78
+ puts WordWrapper::MinimumRaggedness.new(width - 4, desc_text).wrap
79
+ .split("\n").map { |l| " #{l}" }.join("\n")
80
+ end
81
+ end
82
+
83
+ puts "\nTotal: #{skill_data.size} skill#{'s' if skill_data.size != 1}"
53
84
  nil
54
85
  end
55
86
 
56
- desc "Include a Claude Code skill from ~/.claude/skills/"
87
+ desc "Include an AIA skill from the skills directory"
57
88
  def skill(args = [], _context_manager = nil)
89
+ args = Array(args)
58
90
  skill_name = args.first&.strip
91
+
59
92
  if skill_name.nil? || skill_name.empty?
60
- STDERR.puts "Error: /skill requires a skill name"
93
+ warn "Error: /skill requires a skill name. Use /skills to list available skills."
94
+ AIA::LoggerManager.aia_logger.error("/skill requires a skill name")
61
95
  return nil
62
96
  end
63
97
 
64
- skill_dir = resolve_skill_dir(skill_name)
98
+ dir = aia_skills_dir
99
+
100
+ unless Dir.exist?(dir)
101
+ warn "Error: Skills directory not found at #{dir}"
102
+ AIA::LoggerManager.aia_logger.error("Skills directory not found at #{dir}")
103
+ return nil
104
+ end
105
+
106
+ skill_dir = find_skill_dir(skill_name, dir)
65
107
  unless skill_dir
66
- STDERR.puts "Error: No skill matching '#{skill_name}' found in #{SKILLS_DIR}"
108
+ if path_based_id?(skill_name)
109
+ warn "Error: No skill directory found at '#{File.expand_path(skill_name)}'. Use /skills to list available skills."
110
+ AIA::LoggerManager.aia_logger.error("No skill directory found at '#{File.expand_path(skill_name)}'")
111
+ else
112
+ warn "Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills."
113
+ AIA::LoggerManager.aia_logger.error("No skill matching '#{skill_name}' found in #{dir}")
114
+ end
67
115
  return nil
68
116
  end
69
117
 
118
+ return File.read(skill_dir) if File.file?(skill_dir)
119
+
70
120
  skill_path = File.join(skill_dir, 'SKILL.md')
71
121
  unless File.exist?(skill_path)
72
- STDERR.puts "Error: Skill directory '#{File.basename(skill_dir)}' has no SKILL.md"
122
+ warn "Error: Skill directory '#{File.basename(skill_dir)}' has no SKILL.md. Use /skills to list available skills."
123
+ AIA::LoggerManager.aia_logger.error("Skill directory '#{File.basename(skill_dir)}' has no SKILL.md")
73
124
  return nil
74
125
  end
75
126
 
@@ -87,28 +138,15 @@ module AIA
87
138
 
88
139
  private
89
140
 
90
- def resolve_skill_dir(skill_name)
91
- return nil unless Dir.exist?(SKILLS_DIR)
92
-
93
- exact = File.join(SKILLS_DIR, skill_name)
94
- return safe_skill_path(exact) if Dir.exist?(exact)
95
-
96
- Dir.children(SKILLS_DIR)
97
- .sort
98
- .each do |entry|
99
- next unless entry.start_with?(skill_name)
100
- candidate = File.join(SKILLS_DIR, entry)
101
- return safe_skill_path(candidate) if Dir.exist?(candidate)
102
- end
103
-
104
- nil
141
+ def aia_skills_dir
142
+ AIA.config.skills.dir
105
143
  end
106
144
 
107
- def safe_skill_path(path)
108
- resolved = File.realpath(path)
109
- resolved.start_with?(File.realpath(SKILLS_DIR)) ? resolved : nil
110
- rescue Errno::ENOENT
111
- nil
145
+ def terminal_width
146
+ IO.console&.winsize&.last || 80
147
+ rescue StandardError
148
+ 80
112
149
  end
150
+
113
151
  end
114
152
  end
data/lib/aia/errors.rb CHANGED
@@ -1,5 +1,24 @@
1
1
  # lib/aia/errors.rb
2
2
 
3
3
  module AIA
4
- class ConfigurationError < StandardError; end
4
+ # Base error class for all AIA errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised for configuration validation failures
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised for prompt loading, parsing, or processing failures
11
+ class PromptError < Error; end
12
+
13
+ # Raised for RubyLLM::Tool loading or execution failures
14
+ class ToolError < Error; end
15
+
16
+ # Raised for MCP server connection or communication failures
17
+ class MCPError < Error; end
18
+
19
+ # Raised for LLM adapter failures (model setup, chat execution)
20
+ class AdapterError < Error; end
21
+
22
+ # Raised for directive parsing or execution failures
23
+ class DirectiveError < Error; end
5
24
  end
data/lib/aia/fzf.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # fzf is a general-purpose command-line fuzzy finder
3
3
 
4
4
  require 'shellwords'
5
+ require 'open3'
5
6
  require 'tempfile'
6
7
 
7
8
  class AIA::Fzf
@@ -37,19 +38,19 @@ class AIA::Fzf
37
38
 
38
39
  def build_command
39
40
  fzf_options = DEFAULT_PARAMETERS.dup
40
- fzf_options << "--header='#{subject} which contain: #{query}\nPress ESC to cancel.'"
41
- fzf_options << "--preview='cat #{directory}/{1}#{extension}'"
41
+ escaped_dir = Shellwords.escape(directory)
42
+ escaped_ext = Shellwords.escape(extension)
43
+ fzf_options << "--header=#{Shellwords.escape("#{subject} which contain: #{query}\nPress ESC to cancel.")}"
44
+ fzf_options << "--preview=#{Shellwords.escape("cat #{escaped_dir}/{1}#{escaped_ext}")}"
42
45
  fzf_options << "--prompt=#{Shellwords.escape(prompt)}"
43
46
 
44
- fzf_command = "fzf #{fzf_options.join(' ')}"
45
-
46
- @command = "cat #{tempfile_path} | #{fzf_command}"
47
+ @fzf_args = fzf_options
47
48
  end
48
49
 
49
50
 
50
51
  def run
51
- # puts "Executing: #{@command}"
52
- selected = `#{@command}`.strip
52
+ input = list.join("\n")
53
+ selected, status = Open3.capture2('fzf', *@fzf_args, stdin_data: input)
53
54
 
54
55
  selected.strip.empty? ? nil : selected.strip
55
56
  ensure
@@ -0,0 +1,24 @@
1
+ # lib/aia/input_collector.rb
2
+ # frozen_string_literal: true
3
+
4
+ module AIA
5
+ class InputCollector
6
+ # Collect variable values from user input via HistoryManager
7
+ def collect(parameters)
8
+ return {} if parameters.nil? || parameters.empty?
9
+
10
+ values = {}
11
+ input_manager = AIA::HistoryManager.new
12
+
13
+ parameters.each do |name, default|
14
+ value = input_manager.request_variable_value(
15
+ variable_name: name,
16
+ default_value: default,
17
+ )
18
+ values[name] = value
19
+ end
20
+
21
+ values
22
+ end
23
+ end
24
+ end
@@ -6,6 +6,14 @@ require 'erb'
6
6
 
7
7
  module AIA
8
8
  class PromptHandler
9
+ include AIA::SkillUtils
10
+
11
+ # Struct for path-based role content (bypasses PM parsing)
12
+ RoleContent = Struct.new(:content) do
13
+ def to_s; content; end
14
+ def metadata; nil; end
15
+ end
16
+
9
17
  # Root-level YAML keys that are shorthands for deeper config paths
10
18
  SHORTHAND_KEYS = %w[model temperature top_p next pipeline shell erb].freeze
11
19
 
@@ -46,7 +54,8 @@ module AIA
46
54
  parsed = if File.exist?(prompt_file_path)
47
55
  PM.parse(prompt_id)
48
56
  else
49
- puts "Warning: Invalid prompt ID or file not found: #{prompt_id}"
57
+ warn "Warning: Invalid prompt ID or file not found: #{prompt_id}"
58
+ logger.warn("Invalid prompt ID or file not found: #{prompt_id}")
50
59
  handle_missing_prompt(prompt_id)
51
60
  end
52
61
 
@@ -57,6 +66,7 @@ module AIA
57
66
 
58
67
  def fetch_role(role_id)
59
68
  return handle_missing_role("roles/") if role_id.nil?
69
+ return fetch_role_from_path(role_id) if path_based_id?(role_id)
60
70
 
61
71
  unless role_id.start_with?(AIA.config.prompts.roles_prefix)
62
72
  role_id = "#{AIA.config.prompts.roles_prefix}/#{role_id}"
@@ -65,9 +75,12 @@ module AIA
65
75
  role_file_path = File.join(@prompts_dir, "#{role_id}#{AIA.config.prompts.extname}")
66
76
 
67
77
  parsed = if File.exist?(role_file_path)
68
- PM.parse(role_id)
78
+ # PM.parse(role_id) cannot resolve subdirectory IDs like "roles/jersey_mike"
79
+ # and returns the ID string as content. Parse from raw file content instead.
80
+ PM.parse_string(File.read(role_file_path))
69
81
  else
70
- puts "Warning: Invalid role ID or file not found: #{role_id}"
82
+ warn "Warning: Invalid role ID or file not found: #{role_id}"
83
+ logger.warn("Invalid role ID or file not found: #{role_id}")
71
84
  handle_missing_role(role_id)
72
85
  end
73
86
 
@@ -90,7 +103,8 @@ module AIA
90
103
  role_parsed = fetch_role(role_id)
91
104
  role_parsed.to_s
92
105
  rescue => e
93
- puts "Warning: Could not load role '#{role_id}' for model: #{e.message}"
106
+ warn "Warning: Could not load role '#{role_id}' for model: #{e.message}"
107
+ logger.warn("Could not load role '#{role_id}' for model: #{e.message}")
94
108
  nil
95
109
  end
96
110
 
@@ -280,19 +294,33 @@ module AIA
280
294
  def handle_missing_prompt(prompt_id)
281
295
  prompt_id = prompt_id.to_s.strip
282
296
  if prompt_id.empty?
283
- STDERR.puts "Error: Prompt ID cannot be empty"
297
+ warn "Error: Prompt ID cannot be empty"
284
298
  exit 1
285
299
  end
286
300
 
287
301
  if AIA.config.flags.fuzzy
288
302
  fuzzy_search_prompt(prompt_id)
289
303
  else
290
- STDERR.puts "Error: Could not find prompt with ID: #{prompt_id}"
304
+ warn "Error: Could not find prompt with ID: #{prompt_id}"
291
305
  exit 1
292
306
  end
293
307
  end
294
308
 
295
309
 
310
+ def fetch_role_from_path(role_id)
311
+ expanded = File.expand_path(role_id)
312
+ expanded += '.md' if File.extname(expanded).empty?
313
+
314
+ unless File.exist?(expanded)
315
+ $stderr.puts "Warning: Role file not found at path: #{expanded}"
316
+ logger.warn("Role file not found at path: #{expanded}")
317
+ return handle_missing_role(role_id)
318
+ end
319
+
320
+ RoleContent.new(File.read(expanded))
321
+ end
322
+
323
+
296
324
  def fuzzy_search_prompt(prompt_id)
297
325
  new_prompt_id = search_prompt_id_with_fzf(prompt_id)
298
326
 
@@ -307,14 +335,14 @@ module AIA
307
335
  def handle_missing_role(role_id)
308
336
  role_id = role_id.to_s.strip
309
337
  if role_id.empty? || role_id == "roles/"
310
- STDERR.puts "Error: Role ID cannot be empty"
338
+ warn "Error: Role ID cannot be empty"
311
339
  exit 1
312
340
  end
313
341
 
314
342
  if AIA.config.flags.fuzzy
315
343
  fuzzy_search_role(role_id)
316
344
  else
317
- STDERR.puts "Error: Could not find role with ID: #{role_id}"
345
+ warn "Error: Could not find role with ID: #{role_id}"
318
346
  exit 1
319
347
  end
320
348
  end