aia 1.0.0 → 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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +51 -0
- data/README.md +172 -15
- data/docs/cli-reference.md +92 -1
- data/docs/configuration.md +22 -0
- data/docs/directives-reference.md +35 -15
- data/docs/index.md +36 -62
- data/docs/prompt_management.md +88 -1
- data/lib/aia/chat_loop.rb +54 -0
- data/lib/aia/chat_processor_service.rb +4 -1
- data/lib/aia/config/cli_parser.rb +49 -11
- data/lib/aia/config/defaults.yml +17 -2
- data/lib/aia/config/validator.rb +47 -6
- data/lib/aia/config.rb +29 -3
- data/lib/aia/directive.rb +29 -0
- data/lib/aia/directives/model_directives.rb +28 -27
- data/lib/aia/directives/web_and_file_directives.rb +75 -41
- data/lib/aia/prompt_handler.rb +26 -1
- data/lib/aia/prompt_pipeline.rb +45 -1
- data/lib/aia/skill_utils.rb +61 -0
- data/lib/aia.rb +1 -0
- metadata +4 -3
|
@@ -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,
|
|
18
|
+
show_local_models(model_names, positive_terms, negative_terms)
|
|
17
19
|
else
|
|
18
|
-
show_rubyllm_models(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
196
|
-
|
|
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 #{
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
34
|
-
def skills(
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
95
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
data/lib/aia/prompt_handler.rb
CHANGED
|
@@ -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
|
|
data/lib/aia/prompt_pipeline.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 1.1.0
|
|
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.
|
|
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.
|