aia 1.0.0 → 1.1.1

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
@@ -48,7 +48,7 @@ module AIA
48
48
  # ==========================================================================
49
49
 
50
50
  # Nested section attributes (defined as hashes, converted to ConfigSection)
51
- attr_config :service, :llm, :prompts, :output, :audio, :image, :embedding,
51
+ attr_config :service, :llm, :prompts, :roles, :skills, :output, :audio, :image, :embedding,
52
52
  :tools, :flags, :registry, :paths, :logger
53
53
 
54
54
  # Array/collection attributes
@@ -56,7 +56,7 @@ module AIA
56
56
 
57
57
  # Runtime attributes (not loaded from config files)
58
58
  attr_accessor :prompt_id, :stdin_content, :remaining_args, :dump_file,
59
- :completion, :mcp_list, :list_tools,
59
+ :completion, :mcp_list, :list_tools, :list_skills,
60
60
  :executable_prompt_content,
61
61
  :tool_names, :loaded_tools,
62
62
  :log_level_override, :log_file_override,
@@ -105,6 +105,8 @@ module AIA
105
105
  service: config_section_coercion(:service),
106
106
  llm: config_section_coercion(:llm),
107
107
  prompts: config_section_coercion(:prompts),
108
+ roles: config_section_coercion(:roles),
109
+ skills: config_section_coercion(:skills),
108
110
  output: config_section_coercion(:output),
109
111
  audio: config_section_coercion(:audio),
110
112
  image: config_section_coercion(:image),
@@ -169,6 +171,9 @@ module AIA
169
171
  prompts_dir: [:prompts, :dir],
170
172
  roles_prefix: [:prompts, :roles_prefix],
171
173
  role: [:prompts, :role],
174
+ skills_dir: [:skills, :dir],
175
+ skills_prefix: [:prompts, :skills_prefix],
176
+ skills: [:prompts, :skills],
172
177
  parameter_regex: [:prompts, :parameter_regex],
173
178
  system_prompt: [:prompts, :system_prompt],
174
179
  # output section
@@ -235,6 +240,8 @@ module AIA
235
240
  llm: llm.to_h,
236
241
  models: models.map(&:to_h),
237
242
  prompts: prompts.to_h,
243
+ roles: roles.to_h,
244
+ skills: skills.to_h,
238
245
  output: output.to_h,
239
246
  audio: audio.to_h,
240
247
  image: image.to_h,
@@ -296,7 +303,7 @@ module AIA
296
303
  send("#{key}=", Array(value)) if respond_to?("#{key}=")
297
304
  when :mcp_servers
298
305
  self.mcp_servers = Array(value)
299
- when :service, :llm, :prompts, :output, :audio, :image, :embedding,
306
+ when :service, :llm, :prompts, :roles, :skills, :output, :audio, :image, :embedding,
300
307
  :tools, :flags, :registry, :paths, :logger
301
308
  section = send(key)
302
309
  if section.is_a?(MywayConfig::ConfigSection) && value.is_a?(Hash)
@@ -388,6 +395,18 @@ module AIA
388
395
  if output.history_file
389
396
  output.history_file = File.expand_path(output.history_file)
390
397
  end
398
+
399
+ if roles.dir
400
+ roles.dir = File.expand_path(roles.dir)
401
+ end
402
+
403
+ if skills.dir
404
+ skills.dir = File.expand_path(skills.dir)
405
+ end
406
+
407
+ if tools.dir
408
+ tools.dir = File.expand_path(tools.dir)
409
+ end
391
410
  end
392
411
 
393
412
  def ensure_arrays
@@ -401,6 +420,9 @@ module AIA
401
420
 
402
421
  # Ensure tools.paths is an array
403
422
  tools.paths = [] if tools.paths.nil?
423
+
424
+ # Ensure prompts.skills is an array
425
+ prompts.skills = [] if prompts.respond_to?(:skills) && prompts.skills.nil?
404
426
  end
405
427
 
406
428
  # Process MCP JSON files and merge servers into mcp_servers
@@ -441,6 +463,10 @@ module AIA
441
463
  registry.send("#{key}=", value) if registry.respond_to?("#{key}=")
442
464
  when :paths
443
465
  paths.send("#{key}=", value) if paths.respond_to?("#{key}=")
466
+ when :roles
467
+ roles.send("#{key}=", value) if roles.respond_to?("#{key}=")
468
+ when :skills
469
+ skills.send("#{key}=", value) if skills.respond_to?("#{key}=")
444
470
  end
445
471
  end
446
472
  end
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
@@ -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,49 +33,93 @@ 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)
58
89
  args = Array(args)
59
90
  skill_name = args.first&.strip
91
+
60
92
  if skill_name.nil? || skill_name.empty?
61
- warn "Error: /skill requires a skill name"
93
+ warn "Error: /skill requires a skill name. Use /skills to list available skills."
62
94
  AIA::LoggerManager.aia_logger.error("/skill requires a skill name")
63
95
  return nil
64
96
  end
65
97
 
66
- 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)
67
107
  unless skill_dir
68
- warn "Error: No skill matching '#{skill_name}' found in #{SKILLS_DIR}"
69
- AIA::LoggerManager.aia_logger.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
70
115
  return nil
71
116
  end
72
117
 
118
+ return File.read(skill_dir) if File.file?(skill_dir)
119
+
73
120
  skill_path = File.join(skill_dir, 'SKILL.md')
74
121
  unless File.exist?(skill_path)
75
- warn "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."
76
123
  AIA::LoggerManager.aia_logger.error("Skill directory '#{File.basename(skill_dir)}' has no SKILL.md")
77
124
  return nil
78
125
  end
@@ -91,28 +138,15 @@ module AIA
91
138
 
92
139
  private
93
140
 
94
- def resolve_skill_dir(skill_name)
95
- return nil unless Dir.exist?(SKILLS_DIR)
96
-
97
- exact = File.join(SKILLS_DIR, skill_name)
98
- return safe_skill_path(exact) if Dir.exist?(exact)
99
-
100
- Dir.children(SKILLS_DIR)
101
- .sort
102
- .each do |entry|
103
- next unless entry.start_with?(skill_name)
104
- candidate = File.join(SKILLS_DIR, entry)
105
- return safe_skill_path(candidate) if Dir.exist?(candidate)
106
- end
107
-
108
- nil
141
+ def aia_skills_dir
142
+ AIA.config.skills.dir
109
143
  end
110
144
 
111
- def safe_skill_path(path)
112
- resolved = File.realpath(path)
113
- resolved.start_with?(File.realpath(SKILLS_DIR)) ? resolved : nil
114
- rescue Errno::ENOENT
115
- nil
145
+ def terminal_width
146
+ IO.console&.winsize&.last || 80
147
+ rescue StandardError
148
+ 80
116
149
  end
150
+
117
151
  end
118
152
  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
 
@@ -58,6 +66,7 @@ module AIA
58
66
 
59
67
  def fetch_role(role_id)
60
68
  return handle_missing_role("roles/") if role_id.nil?
69
+ return fetch_role_from_path(role_id) if path_based_id?(role_id)
61
70
 
62
71
  unless role_id.start_with?(AIA.config.prompts.roles_prefix)
63
72
  role_id = "#{AIA.config.prompts.roles_prefix}/#{role_id}"
@@ -66,7 +75,9 @@ module AIA
66
75
  role_file_path = File.join(@prompts_dir, "#{role_id}#{AIA.config.prompts.extname}")
67
76
 
68
77
  parsed = if File.exist?(role_file_path)
69
- 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))
70
81
  else
71
82
  warn "Warning: Invalid role ID or file not found: #{role_id}"
72
83
  logger.warn("Invalid role ID or file not found: #{role_id}")
@@ -296,6 +307,20 @@ module AIA
296
307
  end
297
308
 
298
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
+
299
324
  def fuzzy_search_prompt(prompt_id)
300
325
  new_prompt_id = search_prompt_id_with_fzf(prompt_id)
301
326
 
@@ -5,6 +5,8 @@ require "pm"
5
5
 
6
6
  module AIA
7
7
  class PromptPipeline
8
+ include AIA::SkillUtils
9
+
8
10
  def initialize(prompt_handler, chat_processor, ui_presenter, input_collector)
9
11
  @prompt_handler = prompt_handler
10
12
  @chat_processor = chat_processor
@@ -69,9 +71,11 @@ module AIA
69
71
  # Collect parameter values from user
70
72
  values = @input_collector.collect(all_params)
71
73
 
72
- # Render role and prompt
74
+ # Render role, skills, and prompt.
75
+ # Order: role (personality) → skills (task instructions) → prompt (user request)
73
76
  parts = []
74
77
  parts << role_parsed.to_s(values) if role_parsed
78
+ load_skills(AIA.config.prompts.skills).each { |body| parts << body }
75
79
  parts << prompt_parsed.to_s(values)
76
80
 
77
81
  if @include_context_flag
@@ -91,6 +95,46 @@ module AIA
91
95
  prompt_text
92
96
  end
93
97
 
98
+ # Load skill bodies for the given skill IDs in order.
99
+ # Each skill lives at skills_dir/<name>/SKILL.md; supports prefix matching.
100
+ # Path-based IDs (starting with /, ~/, ./, ../) are resolved as direct paths.
101
+ # Returns only the body content (front matter stripped).
102
+ def load_skills(skill_ids)
103
+ return [] if skill_ids.nil? || skill_ids.empty?
104
+
105
+ skills_dir = AIA.config.skills.dir
106
+
107
+ Array(skill_ids).filter_map do |skill_name|
108
+ skill_name = skill_name.to_s.strip
109
+ next if skill_name.empty?
110
+
111
+ unless path_based_id?(skill_name) || Dir.exist?(skills_dir)
112
+ warn "Warning: No skill matching '#{skill_name}' found in #{skills_dir}"
113
+ next
114
+ end
115
+
116
+ skill_dir = find_skill_dir(skill_name, skills_dir)
117
+ unless skill_dir
118
+ if path_based_id?(skill_name)
119
+ warn "Warning: No skill directory found at '#{File.expand_path(skill_name)}'"
120
+ else
121
+ warn "Warning: No skill matching '#{skill_name}' found in #{skills_dir}"
122
+ end
123
+ next
124
+ end
125
+
126
+ next skill_body(File.read(skill_dir)) if File.file?(skill_dir)
127
+
128
+ skill_path = File.join(skill_dir, 'SKILL.md')
129
+ unless File.exist?(skill_path)
130
+ warn "Warning: Skill '#{skill_name}' has no SKILL.md in #{skill_dir}"
131
+ next
132
+ end
133
+
134
+ skill_body(File.read(skill_path))
135
+ end
136
+ end
137
+
94
138
  # Add context files to prompt text
95
139
  def add_context_files(prompt_text)
96
140
  return prompt_text unless AIA.config.context_files && !AIA.config.context_files.empty?
@@ -0,0 +1,61 @@
1
+ # lib/aia/skill_utils.rb
2
+
3
+ require 'yaml'
4
+
5
+ module AIA
6
+ module SkillUtils
7
+ extend self
8
+
9
+ def path_based_id?(id)
10
+ id.to_s.start_with?('/', './', '../', '~/')
11
+ end
12
+
13
+ def parse_front_matter(path)
14
+ return {} unless File.exist?(path)
15
+ content = File.read(path)
16
+ return {} unless content.start_with?('---')
17
+ end_marker = content.index("\n---", 3)
18
+ return {} unless end_marker
19
+ yaml_text = content[3...end_marker]
20
+ YAML.safe_load(yaml_text) || {}
21
+ rescue StandardError
22
+ {}
23
+ end
24
+
25
+ def find_skill_dir(skill_name, base_dir)
26
+ if path_based_id?(skill_name)
27
+ expanded = File.expand_path(skill_name)
28
+ return expanded if Dir.exist?(expanded)
29
+ return expanded if File.file?(expanded)
30
+ return nil
31
+ end
32
+
33
+ exact = File.join(base_dir, skill_name)
34
+ return safe_skill_path(exact, base_dir) if Dir.exist?(exact)
35
+
36
+ Dir.children(base_dir).sort.each do |entry|
37
+ next unless entry.start_with?(skill_name)
38
+ candidate = File.join(base_dir, entry)
39
+ return safe_skill_path(candidate, base_dir) if Dir.exist?(candidate)
40
+ end
41
+
42
+ nil
43
+ rescue Errno::ENOENT
44
+ nil
45
+ end
46
+
47
+ def skill_body(content)
48
+ return content unless content.start_with?('---')
49
+ end_marker = content.index("\n---", 3)
50
+ return content unless end_marker
51
+ content[(end_marker + 4)..].lstrip
52
+ end
53
+
54
+ def safe_skill_path(path, dir)
55
+ resolved = File.realpath(path)
56
+ resolved.start_with?(File.realpath(dir) + File::SEPARATOR) ? resolved : nil
57
+ rescue Errno::ENOENT
58
+ nil
59
+ end
60
+ end
61
+ end
data/lib/aia.rb CHANGED
@@ -25,6 +25,7 @@ require_relative 'refinements/string' # adds #include_any? #include_all?
25
25
 
26
26
  require_relative 'aia/errors'
27
27
  require_relative 'aia/utility'
28
+ require_relative 'aia/skill_utils'
28
29
  require_relative 'aia/version'
29
30
  require_relative 'aia/config'
30
31
  require_relative 'aia/logger'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aia
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -517,6 +517,7 @@ files:
517
517
  - lib/aia/prompt_pipeline.rb
518
518
  - lib/aia/ruby_llm_adapter.rb
519
519
  - lib/aia/session.rb
520
+ - lib/aia/skill_utils.rb
520
521
  - lib/aia/ui_presenter.rb
521
522
  - lib/aia/utility.rb
522
523
  - lib/aia/version.rb
@@ -546,7 +547,7 @@ metadata:
546
547
  post_install_message: |2+
547
548
 
548
549
  ╔══════════════════════════════════════════════════════════════╗
549
- ║ AIA — AI Assistant v1.0
550
+ ║ AIA — AI Assistant v1.0
550
551
  ╚══════════════════════════════════════════════════════════════╝
551
552
 
552
553
  Get started: aia --help
@@ -566,7 +567,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
566
567
  - !ruby/object:Gem::Version
567
568
  version: '0'
568
569
  requirements: []
569
- rubygems_version: 4.0.6
570
+ rubygems_version: 4.0.10
570
571
  specification_version: 4
571
572
  summary: Multi-model AI CLI with dynamic prompts, consensus responses, shell & Ruby
572
573
  integration, and seamless chat workflows.