ace-llm 0.30.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 +7 -0
- data/.ace-defaults/llm/config.yml +31 -0
- data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
- data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
- data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
- data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
- data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
- data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
- data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
- data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
- data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
- data/.ace-defaults/llm/providers/anthropic.yml +34 -0
- data/.ace-defaults/llm/providers/google.yml +36 -0
- data/.ace-defaults/llm/providers/groq.yml +29 -0
- data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
- data/.ace-defaults/llm/providers/mistral.yml +33 -0
- data/.ace-defaults/llm/providers/openai.yml +33 -0
- data/.ace-defaults/llm/providers/openrouter.yml +45 -0
- data/.ace-defaults/llm/providers/togetherai.yml +26 -0
- data/.ace-defaults/llm/providers/xai.yml +30 -0
- data/.ace-defaults/llm/providers/zai.yml +18 -0
- data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
- data/CHANGELOG.md +641 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-llm +25 -0
- data/handbook/guides/llm-query-tool-reference.g.md +683 -0
- data/handbook/templates/agent/plan-mode.template.md +48 -0
- data/lib/ace/llm/atoms/env_reader.rb +155 -0
- data/lib/ace/llm/atoms/error_classifier.rb +200 -0
- data/lib/ace/llm/atoms/http_client.rb +162 -0
- data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
- data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
- data/lib/ace/llm/cli/commands/query.rb +280 -0
- data/lib/ace/llm/cli.rb +24 -0
- data/lib/ace/llm/configuration.rb +180 -0
- data/lib/ace/llm/models/fallback_config.rb +216 -0
- data/lib/ace/llm/molecules/client_registry.rb +336 -0
- data/lib/ace/llm/molecules/config_loader.rb +39 -0
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
- data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
- data/lib/ace/llm/molecules/format_handlers.rb +183 -0
- data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
- data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
- data/lib/ace/llm/molecules/preset_loader.rb +99 -0
- data/lib/ace/llm/molecules/provider_loader.rb +198 -0
- data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
- data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
- data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
- data/lib/ace/llm/organisms/base_client.rb +264 -0
- data/lib/ace/llm/organisms/google_client.rb +187 -0
- data/lib/ace/llm/organisms/groq_client.rb +197 -0
- data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
- data/lib/ace/llm/organisms/mistral_client.rb +180 -0
- data/lib/ace/llm/organisms/openai_client.rb +195 -0
- data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
- data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
- data/lib/ace/llm/organisms/xai_client.rb +213 -0
- data/lib/ace/llm/organisms/zai_client.rb +149 -0
- data/lib/ace/llm/query_interface.rb +455 -0
- data/lib/ace/llm/version.rb +7 -0
- data/lib/ace/llm.rb +61 -0
- metadata +318 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Atoms
|
|
6
|
+
# ProviderConfigValidator validates provider configuration structure and content
|
|
7
|
+
class ProviderConfigValidator
|
|
8
|
+
# Required fields for any provider configuration
|
|
9
|
+
REQUIRED_FIELDS = %w[name class gem].freeze
|
|
10
|
+
|
|
11
|
+
# Optional fields with expected types
|
|
12
|
+
OPTIONAL_FIELDS = {
|
|
13
|
+
"models" => Array,
|
|
14
|
+
"api_key" => [Hash, NilClass],
|
|
15
|
+
"capabilities" => Array,
|
|
16
|
+
"default_options" => Hash,
|
|
17
|
+
"endpoint" => String,
|
|
18
|
+
"version" => String,
|
|
19
|
+
"aliases" => Hash
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Valid capability values
|
|
23
|
+
VALID_CAPABILITIES = %w[
|
|
24
|
+
text_generation
|
|
25
|
+
streaming
|
|
26
|
+
function_calling
|
|
27
|
+
vision
|
|
28
|
+
embeddings
|
|
29
|
+
code_generation
|
|
30
|
+
chat_completion
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Validation result
|
|
34
|
+
ValidationResult = Struct.new(:valid, :errors, :warnings) do
|
|
35
|
+
def valid?
|
|
36
|
+
valid
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def invalid?
|
|
40
|
+
!valid
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate a provider configuration
|
|
45
|
+
# @param config [Hash] Provider configuration to validate
|
|
46
|
+
# @return [ValidationResult] Validation result with errors and warnings
|
|
47
|
+
def validate(config)
|
|
48
|
+
errors = []
|
|
49
|
+
warnings = []
|
|
50
|
+
|
|
51
|
+
# Check that config is a Hash
|
|
52
|
+
unless config.is_a?(Hash)
|
|
53
|
+
errors << "Configuration must be a Hash, got #{config.class}"
|
|
54
|
+
return ValidationResult.new(false, errors, warnings)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Validate required fields
|
|
58
|
+
REQUIRED_FIELDS.each do |field|
|
|
59
|
+
if config[field].nil? || config[field].to_s.strip.empty?
|
|
60
|
+
errors << "Missing required field: '#{field}'"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validate field types
|
|
65
|
+
validate_field_types(config, errors, warnings)
|
|
66
|
+
|
|
67
|
+
# Validate specific field content
|
|
68
|
+
validate_name(config["name"], errors) if config["name"]
|
|
69
|
+
validate_class(config["class"], errors) if config["class"]
|
|
70
|
+
validate_gem(config["gem"], errors) if config["gem"]
|
|
71
|
+
validate_models(config["models"], warnings) if config["models"]
|
|
72
|
+
validate_api_key(config["api_key"], errors, warnings) if config["api_key"]
|
|
73
|
+
validate_capabilities(config["capabilities"], warnings) if config["capabilities"]
|
|
74
|
+
validate_default_options(config["default_options"], warnings) if config["default_options"]
|
|
75
|
+
validate_aliases(config["aliases"], errors, warnings) if config["aliases"]
|
|
76
|
+
|
|
77
|
+
# Return validation result
|
|
78
|
+
ValidationResult.new(errors.empty?, errors, warnings)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Validate a batch of configurations
|
|
82
|
+
# @param configs [Array<Hash>] Array of provider configurations
|
|
83
|
+
# @return [Hash] Map of config name to validation result
|
|
84
|
+
def validate_batch(configs)
|
|
85
|
+
results = {}
|
|
86
|
+
|
|
87
|
+
configs.each do |config|
|
|
88
|
+
name = config["name"] || "unnamed"
|
|
89
|
+
results[name] = validate(config)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
results
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Validate field types match expected types
|
|
98
|
+
def validate_field_types(config, errors, warnings)
|
|
99
|
+
config.each do |field, value|
|
|
100
|
+
next if REQUIRED_FIELDS.include?(field)
|
|
101
|
+
next unless OPTIONAL_FIELDS.key?(field)
|
|
102
|
+
|
|
103
|
+
expected_types = Array(OPTIONAL_FIELDS[field])
|
|
104
|
+
unless expected_types.any? { |type| value.is_a?(type) }
|
|
105
|
+
errors << "Field '#{field}' must be #{expected_types.join(" or ")}, got #{value.class}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Warn about unknown fields
|
|
110
|
+
unknown_fields = config.keys - REQUIRED_FIELDS - OPTIONAL_FIELDS.keys
|
|
111
|
+
unless unknown_fields.empty?
|
|
112
|
+
warnings << "Unknown fields in configuration: #{unknown_fields.join(", ")}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Validate provider name format
|
|
117
|
+
def validate_name(name, errors)
|
|
118
|
+
unless name.is_a?(String) && name.match?(/\A[a-z0-9_-]+\z/i)
|
|
119
|
+
errors << "Provider name must contain only letters, numbers, hyphens, and underscores"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Validate class name format
|
|
124
|
+
def validate_class(class_name, errors)
|
|
125
|
+
unless class_name.is_a?(String) && class_name.match?(/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/)
|
|
126
|
+
errors << "Class must be a valid Ruby class name (e.g., 'Ace::LLM::Organisms::GoogleClient')"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validate gem name format
|
|
131
|
+
def validate_gem(gem_name, errors)
|
|
132
|
+
unless gem_name.is_a?(String) && gem_name.match?(/\A[a-z0-9][a-z0-9_-]*\z/)
|
|
133
|
+
errors << "Gem name must be a valid RubyGems name"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Validate models array
|
|
138
|
+
def validate_models(models, warnings)
|
|
139
|
+
if models.empty?
|
|
140
|
+
warnings << "No models specified for provider"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
models.each do |model|
|
|
144
|
+
unless model.is_a?(String) && !model.strip.empty?
|
|
145
|
+
warnings << "Invalid model entry: #{model.inspect} (must be non-empty string)"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate API key configuration
|
|
151
|
+
def validate_api_key(api_key_config, errors, warnings)
|
|
152
|
+
if api_key_config.is_a?(Hash)
|
|
153
|
+
# Check for valid configuration keys
|
|
154
|
+
valid_keys = %w[env value required description]
|
|
155
|
+
unknown_keys = api_key_config.keys - valid_keys
|
|
156
|
+
|
|
157
|
+
unless unknown_keys.empty?
|
|
158
|
+
warnings << "Unknown API key configuration keys: #{unknown_keys.join(", ")}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Must have either env or value
|
|
162
|
+
unless api_key_config["env"] || api_key_config["value"]
|
|
163
|
+
errors << "API key configuration must specify either 'env' or 'value'"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Warn if using direct value
|
|
167
|
+
if api_key_config["value"]
|
|
168
|
+
warnings << "Using direct API key value in configuration is not recommended for security"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Validate env var name format
|
|
172
|
+
if api_key_config["env"] && !api_key_config["env"].match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
173
|
+
warnings << "Environment variable name should be uppercase with underscores"
|
|
174
|
+
end
|
|
175
|
+
else
|
|
176
|
+
errors << "API key configuration must be a Hash"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Validate capabilities array
|
|
181
|
+
def validate_capabilities(capabilities, warnings)
|
|
182
|
+
invalid_capabilities = capabilities - VALID_CAPABILITIES
|
|
183
|
+
|
|
184
|
+
unless invalid_capabilities.empty?
|
|
185
|
+
warnings << "Unknown capabilities: #{invalid_capabilities.join(", ")}. " \
|
|
186
|
+
"Valid capabilities: #{VALID_CAPABILITIES.join(", ")}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Validate default options
|
|
191
|
+
def validate_default_options(options, warnings)
|
|
192
|
+
# Validate temperature range
|
|
193
|
+
if options["temperature"]
|
|
194
|
+
temp = options["temperature"]
|
|
195
|
+
if temp.is_a?(Numeric)
|
|
196
|
+
if temp < 0.0 || temp > 2.0
|
|
197
|
+
warnings << "Temperature should be between 0.0 and 2.0, got #{temp}"
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
warnings << "Temperature must be a number, got #{temp.class}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Validate max_tokens
|
|
205
|
+
if options["max_tokens"]
|
|
206
|
+
max_tokens = options["max_tokens"]
|
|
207
|
+
if max_tokens.is_a?(Integer)
|
|
208
|
+
if max_tokens <= 0
|
|
209
|
+
warnings << "max_tokens must be positive, got #{max_tokens}"
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
warnings << "max_tokens must be an integer, got #{max_tokens.class}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Validate aliases structure
|
|
218
|
+
def validate_aliases(aliases, errors, warnings)
|
|
219
|
+
unless aliases.is_a?(Hash)
|
|
220
|
+
errors << "Aliases must be a Hash, got #{aliases.class}"
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Check for valid sections
|
|
225
|
+
valid_sections = %w[global model]
|
|
226
|
+
unknown_sections = aliases.keys - valid_sections
|
|
227
|
+
unless unknown_sections.empty?
|
|
228
|
+
warnings << "Unknown alias sections: #{unknown_sections.join(", ")}. Valid sections: global, model"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Validate global aliases if present
|
|
232
|
+
if aliases["global"]
|
|
233
|
+
if aliases["global"].is_a?(Hash)
|
|
234
|
+
aliases["global"].each do |key, value|
|
|
235
|
+
unless value.is_a?(String)
|
|
236
|
+
errors << "Global alias '#{key}' must be a String, got #{value.class}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
else
|
|
240
|
+
errors << "Global aliases must be a Hash, got #{aliases["global"].class}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Validate model aliases if present
|
|
245
|
+
if aliases["model"]
|
|
246
|
+
if aliases["model"].is_a?(Hash)
|
|
247
|
+
aliases["model"].each do |key, value|
|
|
248
|
+
unless value.is_a?(String)
|
|
249
|
+
errors << "Model alias '#{key}' must be a String, got #{value.class}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
else
|
|
253
|
+
errors << "Model aliases must be a Hash, got #{aliases["model"].class}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Atoms
|
|
9
|
+
# XDGDirectoryResolver provides XDG Base Directory Specification
|
|
10
|
+
# compliant directory resolution for cache and data storage.
|
|
11
|
+
# This atom has no dependencies on other parts of this gem.
|
|
12
|
+
class XDGDirectoryResolver
|
|
13
|
+
# Application name used in directory paths
|
|
14
|
+
APP_NAME = "ace-llm"
|
|
15
|
+
|
|
16
|
+
# Environment variable names
|
|
17
|
+
XDG_CACHE_HOME = "XDG_CACHE_HOME"
|
|
18
|
+
XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
|
|
19
|
+
XDG_DATA_HOME = "XDG_DATA_HOME"
|
|
20
|
+
HOME = "HOME"
|
|
21
|
+
|
|
22
|
+
# Default directory permissions
|
|
23
|
+
DEFAULT_DIR_PERMISSIONS = 0o700
|
|
24
|
+
|
|
25
|
+
# Resolve XDG-compliant cache directory path
|
|
26
|
+
# @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
|
|
27
|
+
# @return [String] Absolute path to cache directory
|
|
28
|
+
def self.cache_directory(env_reader = ENV)
|
|
29
|
+
xdg_cache_home = env_reader[XDG_CACHE_HOME]
|
|
30
|
+
home_dir = env_reader[HOME]
|
|
31
|
+
|
|
32
|
+
# Use XDG_CACHE_HOME if set and non-empty
|
|
33
|
+
cache_base = if xdg_cache_home && !xdg_cache_home.strip.empty?
|
|
34
|
+
File.expand_path(xdg_cache_home.strip)
|
|
35
|
+
elsif home_dir && !home_dir.strip.empty?
|
|
36
|
+
# Fall back to ~/.cache if HOME is available
|
|
37
|
+
File.expand_path(".cache", home_dir.strip)
|
|
38
|
+
else
|
|
39
|
+
# Last resort: use current directory
|
|
40
|
+
File.expand_path(".cache")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
File.join(cache_base, APP_NAME)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve XDG-compliant config directory path
|
|
47
|
+
# @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
|
|
48
|
+
# @return [String] Absolute path to config directory
|
|
49
|
+
def self.config_directory(env_reader = ENV)
|
|
50
|
+
xdg_config_home = env_reader[XDG_CONFIG_HOME]
|
|
51
|
+
home_dir = env_reader[HOME]
|
|
52
|
+
|
|
53
|
+
# Use XDG_CONFIG_HOME if set and non-empty
|
|
54
|
+
config_base = if xdg_config_home && !xdg_config_home.strip.empty?
|
|
55
|
+
File.expand_path(xdg_config_home.strip)
|
|
56
|
+
elsif home_dir && !home_dir.strip.empty?
|
|
57
|
+
# Fall back to ~/.config if HOME is available
|
|
58
|
+
File.expand_path(".config", home_dir.strip)
|
|
59
|
+
else
|
|
60
|
+
# Last resort: use current directory
|
|
61
|
+
File.expand_path(".config")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
File.join(config_base, APP_NAME)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve XDG-compliant data directory path
|
|
68
|
+
# @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
|
|
69
|
+
# @return [String] Absolute path to data directory
|
|
70
|
+
def self.data_directory(env_reader = ENV)
|
|
71
|
+
xdg_data_home = env_reader[XDG_DATA_HOME]
|
|
72
|
+
home_dir = env_reader[HOME]
|
|
73
|
+
|
|
74
|
+
# Use XDG_DATA_HOME if set and non-empty
|
|
75
|
+
data_base = if xdg_data_home && !xdg_data_home.strip.empty?
|
|
76
|
+
File.expand_path(xdg_data_home.strip)
|
|
77
|
+
elsif home_dir && !home_dir.strip.empty?
|
|
78
|
+
# Fall back to ~/.local/share if HOME is available
|
|
79
|
+
File.expand_path(".local/share", home_dir.strip)
|
|
80
|
+
else
|
|
81
|
+
# Last resort: use current directory
|
|
82
|
+
File.expand_path(".local/share")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
File.join(data_base, APP_NAME)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Ensure directory exists with proper permissions
|
|
89
|
+
# @param directory [String] Directory path
|
|
90
|
+
# @param permissions [Integer] Directory permissions (default: 0700)
|
|
91
|
+
# @return [String] The created directory path
|
|
92
|
+
# @raise [SystemCallError] If directory cannot be created
|
|
93
|
+
def self.ensure_directory(directory, permissions = DEFAULT_DIR_PERMISSIONS)
|
|
94
|
+
FileUtils.mkdir_p(directory, mode: permissions)
|
|
95
|
+
directory
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get cache subdirectory path for specific cache type
|
|
99
|
+
# @param cache_type [String] Type of cache (e.g., 'models', 'http', 'pricing')
|
|
100
|
+
# @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
|
|
101
|
+
# @return [String] Path to cache subdirectory
|
|
102
|
+
def self.cache_subdirectory(cache_type, env_reader = ENV)
|
|
103
|
+
base_cache_dir = cache_directory(env_reader)
|
|
104
|
+
File.join(base_cache_dir, cache_type.to_s)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolve and ensure cache subdirectory exists
|
|
108
|
+
# @param cache_type [String] Type of cache
|
|
109
|
+
# @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
|
|
110
|
+
# @param permissions [Integer] Directory permissions (default: 0700)
|
|
111
|
+
# @return [String] Path to existing cache subdirectory
|
|
112
|
+
def self.ensure_cache_subdirectory(cache_type, env_reader = ENV, permissions = DEFAULT_DIR_PERMISSIONS)
|
|
113
|
+
subdir_path = cache_subdirectory(cache_type, env_reader)
|
|
114
|
+
ensure_directory(subdir_path, permissions)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Validate directory path for security
|
|
118
|
+
# @param path [String] Directory path to validate
|
|
119
|
+
# @return [Boolean] True if path is safe to use
|
|
120
|
+
def self.safe_directory_path?(path)
|
|
121
|
+
return false if path.nil? || path.empty?
|
|
122
|
+
|
|
123
|
+
# Reject paths with null bytes (security concern)
|
|
124
|
+
return false if path.include?("\0")
|
|
125
|
+
|
|
126
|
+
# Reject paths with parent directory traversal attempts
|
|
127
|
+
return false if path.include?("..")
|
|
128
|
+
|
|
129
|
+
# Must be absolute path after expansion
|
|
130
|
+
begin
|
|
131
|
+
expanded = File.expand_path(path)
|
|
132
|
+
pathname = Pathname.new(expanded)
|
|
133
|
+
return false unless pathname.absolute?
|
|
134
|
+
rescue
|
|
135
|
+
return false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Instance methods for non-static usage
|
|
142
|
+
def initialize(env_reader = ENV)
|
|
143
|
+
@env_reader = env_reader
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Instance method to get cache directory
|
|
147
|
+
# @return [String] Cache directory path
|
|
148
|
+
def cache_directory
|
|
149
|
+
self.class.cache_directory(@env_reader)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Instance method to get config directory
|
|
153
|
+
# @return [String] Config directory path
|
|
154
|
+
def config_directory
|
|
155
|
+
self.class.config_directory(@env_reader)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Instance method to get data directory
|
|
159
|
+
# @return [String] Data directory path
|
|
160
|
+
def data_directory
|
|
161
|
+
self.class.data_directory(@env_reader)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Instance method to ensure directory exists
|
|
165
|
+
# @param directory [String] Directory path
|
|
166
|
+
# @param permissions [Integer] Directory permissions (default: 0700)
|
|
167
|
+
# @return [String] The created directory path
|
|
168
|
+
def ensure_directory(directory, permissions = DEFAULT_DIR_PERMISSIONS)
|
|
169
|
+
self.class.ensure_directory(directory, permissions)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Instance method to get cache subdirectory
|
|
173
|
+
# @param cache_type [String] Type of cache
|
|
174
|
+
# @return [String] Path to cache subdirectory
|
|
175
|
+
def cache_subdirectory(cache_type)
|
|
176
|
+
self.class.cache_subdirectory(cache_type, @env_reader)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Instance method to ensure cache subdirectory exists
|
|
180
|
+
# @param cache_type [String] Type of cache
|
|
181
|
+
# @param permissions [Integer] Directory permissions (default: 0700)
|
|
182
|
+
# @return [String] Path to existing cache subdirectory
|
|
183
|
+
def ensure_cache_subdirectory(cache_type, permissions = DEFAULT_DIR_PERMISSIONS)
|
|
184
|
+
self.class.ensure_cache_subdirectory(cache_type, @env_reader, permissions)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|