ace-support-models 0.9.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +162 -0
  3. data/LICENSE +21 -0
  4. data/README.md +39 -0
  5. data/Rakefile +13 -0
  6. data/exe/ace-llm-providers +19 -0
  7. data/exe/ace-models +23 -0
  8. data/lib/ace/support/models/atoms/api_fetcher.rb +76 -0
  9. data/lib/ace/support/models/atoms/cache_path_resolver.rb +38 -0
  10. data/lib/ace/support/models/atoms/file_reader.rb +43 -0
  11. data/lib/ace/support/models/atoms/file_writer.rb +63 -0
  12. data/lib/ace/support/models/atoms/json_parser.rb +38 -0
  13. data/lib/ace/support/models/atoms/model_filter.rb +107 -0
  14. data/lib/ace/support/models/atoms/model_name_canonicalizer.rb +119 -0
  15. data/lib/ace/support/models/atoms/provider_config_reader.rb +218 -0
  16. data/lib/ace/support/models/atoms/provider_config_writer.rb +230 -0
  17. data/lib/ace/support/models/cli/commands/cache/clear.rb +43 -0
  18. data/lib/ace/support/models/cli/commands/cache/diff.rb +74 -0
  19. data/lib/ace/support/models/cli/commands/cache/status.rb +54 -0
  20. data/lib/ace/support/models/cli/commands/cache/sync.rb +51 -0
  21. data/lib/ace/support/models/cli/commands/info.rb +33 -0
  22. data/lib/ace/support/models/cli/commands/models/cost.rb +54 -0
  23. data/lib/ace/support/models/cli/commands/models/info.rb +136 -0
  24. data/lib/ace/support/models/cli/commands/models/search.rb +101 -0
  25. data/lib/ace/support/models/cli/commands/providers/list.rb +46 -0
  26. data/lib/ace/support/models/cli/commands/providers/show.rb +54 -0
  27. data/lib/ace/support/models/cli/commands/providers/sync.rb +66 -0
  28. data/lib/ace/support/models/cli/commands/search.rb +35 -0
  29. data/lib/ace/support/models/cli/commands/sync_shortcut.rb +32 -0
  30. data/lib/ace/support/models/cli/providers_cli.rb +72 -0
  31. data/lib/ace/support/models/cli.rb +84 -0
  32. data/lib/ace/support/models/errors.rb +55 -0
  33. data/lib/ace/support/models/models/diff_result.rb +94 -0
  34. data/lib/ace/support/models/models/model_info.rb +129 -0
  35. data/lib/ace/support/models/models/pricing_info.rb +74 -0
  36. data/lib/ace/support/models/models/provider_info.rb +81 -0
  37. data/lib/ace/support/models/models.rb +97 -0
  38. data/lib/ace/support/models/molecules/cache_manager.rb +237 -0
  39. data/lib/ace/support/models/molecules/cost_calculator.rb +135 -0
  40. data/lib/ace/support/models/molecules/diff_generator.rb +171 -0
  41. data/lib/ace/support/models/molecules/model_searcher.rb +176 -0
  42. data/lib/ace/support/models/molecules/model_validator.rb +177 -0
  43. data/lib/ace/support/models/molecules/provider_sync_diff.rb +291 -0
  44. data/lib/ace/support/models/organisms/provider_sync_orchestrator.rb +278 -0
  45. data/lib/ace/support/models/organisms/sync_orchestrator.rb +108 -0
  46. data/lib/ace/support/models/version.rb +9 -0
  47. data/lib/ace/support/models.rb +3 -0
  48. metadata +149 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Models
6
+ module Atoms
7
+ # Canonicalizes model names by stripping provider-specific suffixes
8
+ #
9
+ # OpenRouter uses dynamic and static suffixes that modify routing behavior
10
+ # but don't represent different models in the canonical model registry.
11
+ #
12
+ # @see https://openrouter.ai/docs/faq for suffix documentation
13
+ class ModelNameCanonicalizer
14
+ # OpenRouter dynamic suffixes (work across all models, modify routing)
15
+ OPENROUTER_DYNAMIC_SUFFIXES = %w[
16
+ nitro
17
+ floor
18
+ online
19
+ ].freeze
20
+
21
+ # OpenRouter static suffixes (apply to specific models only)
22
+ OPENROUTER_STATIC_SUFFIXES = %w[
23
+ free
24
+ extended
25
+ exacto
26
+ thinking
27
+ ].freeze
28
+
29
+ # Combined list of all known OpenRouter suffixes
30
+ OPENROUTER_SUFFIXES = (OPENROUTER_DYNAMIC_SUFFIXES + OPENROUTER_STATIC_SUFFIXES).freeze
31
+
32
+ # Provider-specific suffix configurations
33
+ # Maps provider ID to array of suffixes to strip
34
+ PROVIDER_SUFFIXES = {
35
+ "openrouter" => OPENROUTER_SUFFIXES
36
+ }.freeze
37
+
38
+ class << self
39
+ # Extract the canonical model name by stripping provider-specific suffixes
40
+ #
41
+ # @param model_id [String] The model ID (e.g., "openai/gpt-4:nitro")
42
+ # @param provider [String, nil] The provider ID to determine which suffixes to strip
43
+ # @return [String] The canonical model name (e.g., "openai/gpt-4")
44
+ #
45
+ # @example Strip OpenRouter :nitro suffix
46
+ # canonicalize("openai/gpt-4:nitro", provider: "openrouter")
47
+ # # => "openai/gpt-4"
48
+ #
49
+ # @example Preserve model with no suffix
50
+ # canonicalize("openai/gpt-4", provider: "openrouter")
51
+ # # => "openai/gpt-4"
52
+ #
53
+ # @example Non-OpenRouter provider keeps suffix
54
+ # canonicalize("model:variant", provider: "other")
55
+ # # => "model:variant"
56
+ def canonicalize(model_id, provider: nil)
57
+ return model_id if model_id.nil? || model_id.empty?
58
+
59
+ suffixes = PROVIDER_SUFFIXES[provider]
60
+ return model_id unless suffixes
61
+
62
+ strip_suffixes(model_id, suffixes)
63
+ end
64
+
65
+ # Check if a model ID has a known suffix for the given provider
66
+ #
67
+ # @param model_id [String] The model ID to check
68
+ # @param provider [String, nil] The provider ID
69
+ # @return [Boolean] true if the model has a strippable suffix
70
+ def has_suffix?(model_id, provider: nil)
71
+ return false if model_id.nil? || model_id.empty?
72
+
73
+ suffixes = PROVIDER_SUFFIXES[provider]
74
+ return false unless suffixes
75
+
76
+ suffix = extract_suffix(model_id)
77
+ suffix && suffixes.include?(suffix)
78
+ end
79
+
80
+ # Extract the suffix from a model ID if present
81
+ #
82
+ # @param model_id [String] The model ID
83
+ # @return [String, nil] The suffix without the colon, or nil if no suffix
84
+ def extract_suffix(model_id)
85
+ return nil if model_id.nil? || model_id.empty?
86
+
87
+ # Match the last :suffix pattern (handles model IDs like "org/model:suffix")
88
+ match = model_id.match(/:([^:\/]+)$/)
89
+ match&.captures&.first
90
+ end
91
+
92
+ # Get all known suffixes for a provider
93
+ #
94
+ # @param provider [String] The provider ID
95
+ # @return [Array<String>] Array of suffix strings (without colons)
96
+ def suffixes_for(provider)
97
+ PROVIDER_SUFFIXES[provider] || []
98
+ end
99
+
100
+ private
101
+
102
+ # Strip known suffixes from a model ID
103
+ #
104
+ # @param model_id [String] The model ID
105
+ # @param suffixes [Array<String>] Suffixes to strip
106
+ # @return [String] Model ID with suffix stripped
107
+ def strip_suffixes(model_id, suffixes)
108
+ suffix = extract_suffix(model_id)
109
+ return model_id unless suffix && suffixes.include?(suffix)
110
+
111
+ # Remove the :suffix from the end
112
+ model_id.sub(/:#{Regexp.escape(suffix)}$/, "")
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "securerandom"
6
+
7
+ module Ace
8
+ module Support
9
+ module Models
10
+ module Atoms
11
+ # Reads provider configuration files with cascade support:
12
+ # - Project: .ace/llm/providers/
13
+ # - User: ~/.ace/llm/providers/
14
+ # - Gem: ace-llm/.ace-defaults/llm/providers/ (single source of truth)
15
+ class ProviderConfigReader
16
+ class << self
17
+ # Find all provider config directories in cascade order
18
+ # @param config_dir [String, nil] Override config directory
19
+ # @return [Array<String>] List of directories (project first, then user, then gem)
20
+ def config_directories(config_dir: nil)
21
+ dirs = []
22
+
23
+ if config_dir
24
+ dirs << config_dir if Dir.exist?(config_dir)
25
+ else
26
+ # Project-level config
27
+ project_dir = project_config_dir
28
+ dirs << project_dir if project_dir && Dir.exist?(project_dir)
29
+
30
+ # User-level config
31
+ user_dir = user_config_dir
32
+ dirs << user_dir if user_dir && Dir.exist?(user_dir)
33
+
34
+ # Gem-level config (ace-llm/providers/)
35
+ gem_dir = gem_config_dir
36
+ dirs << gem_dir if gem_dir && Dir.exist?(gem_dir)
37
+ end
38
+
39
+ dirs
40
+ end
41
+
42
+ # Find the first writable config directory
43
+ # @param config_dir [String, nil] Override config directory
44
+ # @return [String, nil] Writable directory or nil
45
+ def writable_config_directory(config_dir: nil)
46
+ dirs = config_directories(config_dir: config_dir)
47
+ dirs.find { |dir| writable?(dir) }
48
+ end
49
+
50
+ # Read all provider configs from cascade
51
+ # @param config_dir [String, nil] Override config directory
52
+ # @return [Hash<String, Hash>] Provider name => config hash
53
+ def read_all(config_dir: nil)
54
+ configs = {}
55
+
56
+ # Read from all directories (later wins for same provider)
57
+ config_directories(config_dir: config_dir).reverse_each do |dir|
58
+ read_directory(dir).each do |name, config|
59
+ configs[name] = config
60
+ end
61
+ end
62
+
63
+ configs
64
+ end
65
+
66
+ # Read provider configs from a specific directory
67
+ # @param dir [String] Directory path
68
+ # @return [Hash<String, Hash>] Provider name => config hash
69
+ def read_directory(dir)
70
+ configs = {}
71
+
72
+ Dir.glob(File.join(dir, "*.yml")).each do |file|
73
+ name = File.basename(file, ".yml")
74
+ next if name == "template" || name.end_with?(".example")
75
+
76
+ config = read_file(file)
77
+ configs[name] = config.merge("_source_file" => file) if config
78
+ end
79
+
80
+ configs
81
+ end
82
+
83
+ # Read a single provider config file
84
+ # @param path [String] File path
85
+ # @return [Hash, nil] Parsed YAML or nil on error
86
+ def read_file(path)
87
+ return nil unless File.exist?(path)
88
+
89
+ content = File.read(path)
90
+ YAML.safe_load(content, permitted_classes: [Symbol, Date])
91
+ rescue Errno::EACCES => e
92
+ raise CacheError, "Permission denied reading #{path}: #{e.message}"
93
+ rescue Psych::SyntaxError => e
94
+ raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
95
+ end
96
+
97
+ # Extract models list from a provider config
98
+ # @param config [Hash] Provider config
99
+ # @return [Array<String>] List of model IDs
100
+ def extract_models(config)
101
+ models = config["models"]
102
+ return [] unless models
103
+
104
+ case models
105
+ when Array
106
+ models
107
+ when Hash
108
+ models.keys
109
+ else
110
+ []
111
+ end
112
+ end
113
+
114
+ # Extract models.dev provider ID from config
115
+ # Falls back to provider name if not specified
116
+ # @param config [Hash] Provider config
117
+ # @return [String] models.dev provider ID
118
+ def extract_models_dev_id(config)
119
+ config["models_dev_id"] || config["name"]
120
+ end
121
+
122
+ # Extract last_synced date from config
123
+ # @param config [Hash] Provider config
124
+ # @return [Date, nil] Last sync date or nil
125
+ def extract_last_synced(config)
126
+ value = config["last_synced"]
127
+ return nil unless value
128
+
129
+ case value
130
+ when Date
131
+ value
132
+ when String
133
+ Date.parse(value)
134
+ end
135
+ rescue ArgumentError
136
+ nil
137
+ end
138
+
139
+ private
140
+
141
+ def project_config_dir
142
+ # Use ace-core if available and has project_root method
143
+ if defined?(Ace::Core) && Ace::Core.respond_to?(:project_root)
144
+ project_root = Ace::Core.project_root
145
+ return File.join(project_root, ".ace", "llm", "providers") if project_root
146
+ end
147
+
148
+ # Fallback: traverse up from current dir looking for .ace or .git
149
+ find_project_root_dir
150
+ end
151
+
152
+ def user_config_dir
153
+ home = ENV["HOME"]
154
+ return nil unless home
155
+
156
+ File.join(home, ".ace", "llm", "providers")
157
+ end
158
+
159
+ def gem_config_dir
160
+ # Find ace-llm gem's providers directory (from .ace-defaults/ - single source of truth)
161
+ if defined?(Ace::LLM)
162
+ # Try to find via gem spec
163
+ spec = begin
164
+ Gem::Specification.find_by_name("ace-llm")
165
+ rescue
166
+ nil
167
+ end
168
+ return File.join(spec.gem_dir, ".ace-defaults", "llm", "providers") if spec
169
+ end
170
+
171
+ # Fallback: look relative to this gem in mono-repo development
172
+ # This enables development without installing ace-llm as a gem.
173
+ # Production/installed gem use goes through Gem::Specification above.
174
+ ace_llm_path = find_ace_llm_path
175
+ File.join(ace_llm_path, ".ace-defaults", "llm", "providers") if ace_llm_path
176
+ end
177
+
178
+ def find_project_root_dir
179
+ dir = Dir.pwd
180
+ while dir != "/"
181
+ if Dir.exist?(File.join(dir, ".ace")) || Dir.exist?(File.join(dir, ".git"))
182
+ return File.join(dir, ".ace", "llm", "providers")
183
+ end
184
+ dir = File.dirname(dir)
185
+ end
186
+ nil
187
+ end
188
+
189
+ def find_ace_llm_path
190
+ # Look in common locations relative to this gem
191
+ candidates = [
192
+ File.expand_path("../../../../../../ace-llm", __FILE__),
193
+ File.expand_path("../../../../../../../ace-llm", __FILE__)
194
+ ]
195
+
196
+ candidates.find { |path| Dir.exist?(path) }
197
+ end
198
+
199
+ def writable?(dir)
200
+ return false unless Dir.exist?(dir)
201
+
202
+ # Initialize test_file before begin block to ensure it's available in ensure
203
+ test_file = File.join(dir, ".write_test_#{Process.pid}_#{SecureRandom.hex(4)}")
204
+ begin
205
+ File.write(test_file, "test")
206
+ true
207
+ rescue Errno::EACCES, Errno::EROFS
208
+ false
209
+ ensure
210
+ File.delete(test_file) if File.exist?(test_file)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "date"
6
+
7
+ module Ace
8
+ module Support
9
+ module Models
10
+ module Atoms
11
+ # Writes provider configuration files, preserving structure
12
+ # Updates only the models: section while keeping other fields intact
13
+ class ProviderConfigWriter
14
+ class << self
15
+ # Update the models list in a provider config file
16
+ # @param path [String] Path to config file
17
+ # @param models [Array<String>] New list of model IDs
18
+ # @return [Boolean] true on success
19
+ # @raise [ConfigError] on write errors
20
+ def update_models(path, models)
21
+ content = read_file_content(path)
22
+ raise ConfigError, "Config file not found: #{path}" unless content
23
+
24
+ updated_content = replace_models_section(content, models)
25
+ write_file(path, updated_content)
26
+ true
27
+ end
28
+
29
+ # Write a complete config file
30
+ # @param path [String] Path to config file
31
+ # @param config [Hash] Config hash
32
+ # @return [Boolean] true on success
33
+ def write(path, config)
34
+ ensure_directory(File.dirname(path))
35
+ content = YAML.dump(config)
36
+ write_file(path, content)
37
+ true
38
+ end
39
+
40
+ # Create a backup of a config file
41
+ # @param path [String] Path to config file
42
+ # @return [String] Path to backup file
43
+ def backup(path)
44
+ return nil unless File.exist?(path)
45
+
46
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
47
+ backup_path = "#{path}.backup.#{timestamp}"
48
+ FileUtils.cp(path, backup_path)
49
+ backup_path
50
+ end
51
+
52
+ # Update the last_synced field in a provider config file
53
+ # @param path [String] Path to config file
54
+ # @param date [Date] Date to set (defaults to today)
55
+ # @return [Boolean] true on success
56
+ # @raise [ConfigError] on write errors
57
+ def update_last_synced(path, date = Date.today)
58
+ content = read_file_content(path)
59
+ raise ConfigError, "Config file not found: #{path}" unless content
60
+
61
+ updated_content = replace_or_add_field(content, "last_synced", date.to_s)
62
+ write_file(path, updated_content)
63
+ true
64
+ end
65
+
66
+ # Update both models and last_synced in one operation
67
+ # @param path [String] Path to config file
68
+ # @param models [Array<String>] New list of model IDs
69
+ # @param date [Date] Date to set for last_synced
70
+ # @return [Boolean] true on success
71
+ def update_models_and_sync_date(path, models, date = Date.today)
72
+ content = read_file_content(path)
73
+ raise ConfigError, "Config file not found: #{path}" unless content
74
+
75
+ updated_content = replace_models_section(content, models)
76
+ updated_content = replace_or_add_field(updated_content, "last_synced", date.to_s)
77
+ write_file(path, updated_content)
78
+ true
79
+ end
80
+
81
+ private
82
+
83
+ def read_file_content(path)
84
+ return nil unless File.exist?(path)
85
+
86
+ File.read(path)
87
+ rescue Errno::EACCES => e
88
+ raise ConfigError, "Permission denied reading #{path}: #{e.message}"
89
+ end
90
+
91
+ def write_file(path, content)
92
+ # Validate YAML before writing to catch regex manipulation errors
93
+ YAML.safe_load(content, permitted_classes: [Symbol, Date])
94
+ File.write(path, content)
95
+ rescue Psych::SyntaxError => e
96
+ raise ConfigError, "Generated invalid YAML for #{path}: #{e.message}"
97
+ rescue Errno::EACCES => e
98
+ raise ConfigError, "Permission denied writing #{path}: #{e.message}"
99
+ rescue Errno::ENOSPC => e
100
+ raise ConfigError, "No space left writing #{path}: #{e.message}"
101
+ rescue Errno::EROFS => e
102
+ raise ConfigError, "Read-only filesystem: #{path}: #{e.message}"
103
+ end
104
+
105
+ def ensure_directory(dir)
106
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
107
+ rescue Errno::EACCES => e
108
+ raise ConfigError, "Permission denied creating directory #{dir}: #{e.message}"
109
+ end
110
+
111
+ # Replace the models section in YAML content while preserving structure
112
+ # @param content [String] Original YAML content
113
+ # @param models [Array<String>] New models list
114
+ # @return [String] Updated content
115
+ # @raise [ConfigError] if unsupported YAML styles are detected
116
+ def replace_models_section(content, models)
117
+ # Check for flow-style arrays which are not supported
118
+ if /^\s*models:\s*\[/m.match?(content)
119
+ raise ConfigError, "Flow-style arrays (models: [...]) are not supported for auto-update. " \
120
+ "Please convert to block style (models: followed by list items)."
121
+ end
122
+
123
+ # Check for inline comments on models: line which are not preserved
124
+ if /^\s*models:\s*#/m.match?(content)
125
+ raise ConfigError, "Inline comments on 'models:' line (e.g., 'models: # comment') are not supported. " \
126
+ "Please move the comment to a separate line above 'models:'."
127
+ end
128
+
129
+ lines = content.lines
130
+ result = []
131
+ in_models_section = false
132
+ models_base_indent = 0
133
+
134
+ lines.each do |line|
135
+ # Detect start of models section (with or without items on same line)
136
+ if line =~ /^(\s*)models:\s*$/
137
+ in_models_section = true
138
+ models_base_indent = $1.length
139
+ result << line
140
+
141
+ # Add new models with standard YAML indent
142
+ models.each do |model|
143
+ result << "#{" " * (models_base_indent + 2)}- #{model}\n"
144
+ end
145
+ next
146
+ end
147
+
148
+ # If in models section, skip old model items
149
+ if in_models_section
150
+ # Check if this line is a list item (model entry)
151
+ if line =~ /^(\s*)-\s+/
152
+ item_indent = $1.length
153
+ # Skip if it's at the expected indent for models (base + 0 or base + 2)
154
+ if item_indent == models_base_indent || item_indent == models_base_indent + 2
155
+ next
156
+ end
157
+ end
158
+
159
+ # Empty line - keep but stay in models section
160
+ if line.strip.empty?
161
+ result << line
162
+ next
163
+ end
164
+
165
+ # A new key at base level (not indented more) ends models section
166
+ if line =~ /^(\s*)\S/
167
+ current_indent = $1.length
168
+ if current_indent <= models_base_indent
169
+ in_models_section = false
170
+ result << line
171
+ end
172
+ # Otherwise skip (shouldn't happen for well-formed YAML)
173
+ end
174
+ next
175
+ end
176
+
177
+ result << line
178
+ end
179
+
180
+ result.join
181
+ end
182
+
183
+ # Replace or add a field in YAML content
184
+ # @param content [String] Original YAML content
185
+ # @param field_name [String] Field name to replace or add
186
+ # @param value [String] New value
187
+ # @return [String] Updated content
188
+ def replace_or_add_field(content, field_name, value)
189
+ lines = content.lines
190
+
191
+ # Try to find and replace existing field
192
+ field_found = false
193
+ result = lines.map do |line|
194
+ if line =~ /^(\s*)#{Regexp.escape(field_name)}:\s*(.*)$/
195
+ field_found = true
196
+ "#{$1}#{field_name}: #{value}\n"
197
+ else
198
+ line
199
+ end
200
+ end
201
+
202
+ # If field not found, add it after the name or first line
203
+ unless field_found
204
+ insert_index = 0
205
+ result.each_with_index do |line, idx|
206
+ if /^name:/.match?(line)
207
+ insert_index = idx + 1
208
+ break
209
+ end
210
+ end
211
+ # If no name field, add after first non-comment, non-blank line
212
+ if insert_index == 0
213
+ result.each_with_index do |line, idx|
214
+ next if line.strip.empty? || line.strip.start_with?("#") || line.strip.start_with?("---")
215
+
216
+ insert_index = idx + 1
217
+ break
218
+ end
219
+ end
220
+ result.insert(insert_index, "#{field_name}: #{value}\n")
221
+ end
222
+
223
+ result.join
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Support
7
+ module Models
8
+ module CLI
9
+ module Commands
10
+ module Cache
11
+ # Clear local cache
12
+ class Clear < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+
15
+ desc "Clear local cache"
16
+
17
+ option :json, type: :boolean, desc: "Output as JSON"
18
+
19
+ def call(**options)
20
+ cache_manager = Molecules::CacheManager.new
21
+ result = cache_manager.clear
22
+
23
+ if options[:json]
24
+ puts JSON.pretty_generate(result)
25
+ return
26
+ end
27
+
28
+ if result[:status] == :success
29
+ puts "Cache cleared successfully"
30
+ puts "Deleted: #{result[:deleted_files].join(", ")}" if result[:deleted_files]&.any?
31
+ else
32
+ raise Ace::Support::Cli::Error.new(result[:message])
33
+ end
34
+ rescue => e
35
+ raise Ace::Support::Cli::Error.new(e.message)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Support
7
+ module Models
8
+ module CLI
9
+ module Commands
10
+ module Cache
11
+ # Show changes since last sync
12
+ class Diff < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+
15
+ desc "Show changes since last sync"
16
+
17
+ option :json, type: :boolean, desc: "Output as JSON"
18
+
19
+ def call(**options)
20
+ result = Molecules::DiffGenerator.new.generate
21
+
22
+ if options[:json]
23
+ puts JSON.pretty_generate(result.to_h)
24
+ return
25
+ end
26
+
27
+ unless result.any_changes?
28
+ puts "No changes since last sync"
29
+ return
30
+ end
31
+
32
+ if result.added_providers.any?
33
+ puts "New providers:"
34
+ result.added_providers.each { |p| puts " + #{p}" }
35
+ puts
36
+ end
37
+
38
+ if result.removed_providers.any?
39
+ puts "Removed providers:"
40
+ result.removed_providers.each { |p| puts " - #{p}" }
41
+ puts
42
+ end
43
+
44
+ if result.added_models.any?
45
+ puts "New models:"
46
+ result.added_models.each { |m| puts " + #{m}" }
47
+ puts
48
+ end
49
+
50
+ if result.removed_models.any?
51
+ puts "Removed models:"
52
+ result.removed_models.each { |m| puts " - #{m}" }
53
+ puts
54
+ end
55
+
56
+ if result.updated_models.any?
57
+ puts "Updated models:"
58
+ result.updated_models.each do |update|
59
+ puts " ~ #{update.model_id}: #{update.summary}"
60
+ end
61
+ puts
62
+ end
63
+
64
+ puts "Summary: #{result.summary}"
65
+ rescue CacheError => e
66
+ raise Ace::Support::Cli::Error.new(e.message)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end