commitgpt 0.1.2 → 0.3.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.
@@ -1,70 +1,271 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "httparty"
4
+ require "net/http"
5
+ require "uri"
4
6
  require "json"
5
7
  require "io/console"
8
+ require "tty-prompt"
6
9
  require_relative "string"
10
+ require_relative "config_manager"
7
11
 
8
12
  # CommitGpt based on GPT-3
9
13
  module CommitGpt
10
14
  # Commit AI roboter based on GPT-3
11
15
  class CommitAi
12
- OPENAI_API_KEY = ENV.fetch("OPENAI_API_KEY", nil)
13
- def aicm
16
+ attr_reader :api_key, :base_url, :model, :diff_len
17
+
18
+ def initialize
19
+ provider_config = ConfigManager.get_active_provider_config
20
+
21
+ if provider_config
22
+ @api_key = provider_config["api_key"]
23
+ @base_url = provider_config["base_url"]
24
+ @model = provider_config["model"]
25
+ @diff_len = provider_config["diff_len"] || 32768
26
+ else
27
+ @api_key = nil
28
+ @base_url = nil
29
+ @model = nil
30
+ @diff_len = 32768
31
+ end
32
+ end
33
+
34
+ def aicm(verbose: false)
14
35
  exit(1) unless welcome
15
36
  diff = git_diff || exit(1)
16
- ai_commit_message = message(diff) || exit(1)
17
- puts `git commit -m "#{ai_commit_message}" && echo && echo && git log -1 && echo` if confirmed
18
- end
37
+ if verbose
38
+ puts "▲ Git diff (#{diff.length} chars):".cyan
39
+ puts diff
40
+ puts "\n"
41
+ end
19
42
 
20
- private
43
+ loop do
44
+ ai_commit_message = message(diff) || exit(1)
45
+ action = confirm_commit(ai_commit_message)
21
46
 
22
- def confirmed
23
- puts "▲ Do you want to commit this message? [y/n]".magenta
47
+ case action
48
+ when :commit
49
+ commit_command = "git commit -m \"#{ai_commit_message}\""
50
+ puts "\n▲ Executing: #{commit_command}".orange
51
+ system(commit_command)
52
+ puts "\n\n"
53
+ puts `git log -1`
54
+ break
55
+ when :regenerate
56
+ puts "\n"
57
+ next
58
+ when :edit
59
+ prompt = TTY::Prompt.new
60
+ new_message = prompt.ask("Enter your commit message:")
61
+ if new_message && !new_message.strip.empty?
62
+ commit_command = "git commit -m \"#{new_message}\""
63
+ system(commit_command)
64
+ puts "\n"
65
+ puts `git log -1`
66
+ else
67
+ puts "▲ Commit aborted (empty message).".red
68
+ end
69
+ break
70
+ when :exit
71
+ puts "▲ Exit without commit.".yellow
72
+ break
73
+ end
74
+ end
75
+ end
24
76
 
25
- use_commit_message = nil
26
- use_commit_message = $stdin.getch.downcase until use_commit_message =~ /\A[yn]\z/i
77
+ def list_models
78
+ headers = {
79
+ "Content-Type" => "application/json",
80
+ "User-Agent" => "Ruby/#{RUBY_VERSION}"
81
+ }
82
+ headers["Authorization"] = "Bearer #{@api_key}" if @api_key
83
+
84
+ begin
85
+ response = HTTParty.get("#{@base_url}/models", headers: headers)
86
+ models = response["data"] || []
87
+ models.each { |m| puts m["id"] }
88
+ rescue StandardError => e
89
+ puts "▲ Failed to list models: #{e.message}".red
90
+ end
91
+ end
27
92
 
28
- puts "\n▲ Commit message has not been commited.\n".red if use_commit_message == "n"
93
+ def list_models
94
+ headers = {
95
+ "Content-Type" => "application/json",
96
+ "User-Agent" => "Ruby/#{RUBY_VERSION}"
97
+ }
98
+ headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
29
99
 
30
- use_commit_message == "y"
100
+ begin
101
+ response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
102
+ models = response["data"] || []
103
+ models.each { |m| puts m["id"] }
104
+ rescue StandardError => e
105
+ puts "▲ Failed to list models: #{e.message}".red
106
+ end
31
107
  end
32
108
 
33
- def message(diff = nil)
34
- prompt = "I want you to act like a git commit message writer. I will input a git diff and your job is to convert it into a useful " \
35
- "commit message. Do not preface the commit with anything, use the present tense, return a complete sentence, " \
36
- "and do not repeat yourself: #{diff}"
109
+ private
37
110
 
38
- puts "▲ Generating your AI commit message...\n".gray
39
- ai_commit_message = generate_commit(prompt)
40
- return nil if ai_commit_message.nil?
111
+ def confirm_commit(message)
112
+ prompt = TTY::Prompt.new
113
+
114
+ begin
115
+ prompt.select("Action:") do |menu|
116
+ menu.choice "Commit", :commit
117
+ menu.choice "Regenerate", :regenerate
118
+ menu.choice "Edit", :edit
119
+ menu.choice "Exit without commit", :exit
120
+ end
121
+ rescue TTY::Reader::InputInterrupt, Interrupt
122
+ :exit
123
+ end
124
+ end
41
125
 
42
- puts "#{"▲ Commit message: ".green}git commit -am \"#{ai_commit_message}\"\n\n"
43
- ai_commit_message
126
+ def message(diff = nil)
127
+ generate_commit(diff)
44
128
  end
45
129
 
46
130
  def git_diff
47
- diff = `git diff --cached . ":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"`.chomp
131
+ exclusions = '":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"'
132
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
133
+ diff_unstaged = `git diff . #{exclusions}`.chomp
48
134
 
49
- if diff.empty?
50
- puts "▲ No staged changes found. Make sure there are changes and run `git add .`".red
51
- return nil
135
+ if !diff_unstaged.empty?
136
+ if !diff_cached.empty?
137
+ # Scenario: Mixed state (some staged, some not)
138
+ puts "▲ You have both staged and unstaged changes:".yellow
139
+
140
+ staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
141
+ unstaged_files = `git diff --name-status . #{exclusions}`.chomp
142
+
143
+ puts "\n #{'Staged changes:'.green}"
144
+ puts staged_files.gsub(/^/, " ")
145
+
146
+ puts "\n #{'Unstaged changes:'.red}"
147
+ puts unstaged_files.gsub(/^/, " ")
148
+ puts ""
149
+
150
+ prompt = TTY::Prompt.new
151
+ choice = prompt.select("How to proceed?") do |menu|
152
+ menu.choice "Include unstaged changes (git add .)", :add_all
153
+ menu.choice "Use staged changes only", :staged_only
154
+ menu.choice "Exit", :exit
155
+ end
156
+
157
+ case choice
158
+ when :add_all
159
+ puts "▲ Running git add .".yellow
160
+ system("git add .")
161
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
162
+ when :exit
163
+ return nil
164
+ end
165
+ else
166
+ # Scenario: Only unstaged changes
167
+ choice = prompt_no_staged_changes
168
+ case choice
169
+ when :add_all
170
+ puts "▲ Running git add .".yellow
171
+ system("git add .")
172
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
173
+ if diff_cached.empty?
174
+ puts "▲ Still no changes to commit.".red
175
+ return nil
176
+ end
177
+ when :exit
178
+ return nil
179
+ end
180
+ end
181
+ elsif diff_cached.empty?
182
+ # Scenario: No changes at all (staged or unstaged)
183
+ # Check if there are ANY unstaged files (maybe untracked?)
184
+ # git status --porcelain includes untracked files
185
+ git_status = `git status --porcelain`.chomp
186
+ if git_status.empty?
187
+ puts "▲ No changes to commit. Working tree clean.".yellow
188
+ return nil
189
+ else
190
+ # Only untracked files? Or ignored files?
191
+ # If diff_unstaged is empty but git status is not, it usually means untracked files.
192
+ # Let's offer to add them too.
193
+ choice = prompt_no_staged_changes
194
+ case choice
195
+ when :add_all
196
+ puts "▲ Running git add .".yellow
197
+ system("git add .")
198
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
199
+ when :exit
200
+ return nil
201
+ end
202
+ end
52
203
  end
53
204
 
54
- # Accounting for GPT-3's input req of 4k tokens (approx 8k chars)
55
- if diff.length > 8000
56
- puts "▲ The diff is too large to write a commit message.".red
57
- return nil
205
+ diff = diff_cached
206
+
207
+ if diff.length > @diff_len
208
+ choice = prompt_diff_handling(diff.length, @diff_len)
209
+ case choice
210
+ when :truncate
211
+ puts "▲ Truncating diff to #{@diff_len} chars...".yellow
212
+ diff = diff[0...@diff_len]
213
+ when :unlimited
214
+ puts "▲ Using full diff (#{diff.length} chars)...".yellow
215
+ when :exit
216
+ return nil
217
+ end
58
218
  end
59
219
 
60
220
  diff
61
221
  end
62
222
 
223
+ def prompt_no_staged_changes
224
+ puts "▲ No staged changes found (but unstaged/untracked files exist).".yellow
225
+ prompt = TTY::Prompt.new
226
+ begin
227
+ prompt.select("Choose an option:") do |menu|
228
+ menu.choice "Run 'git add .' to stage all changes", :add_all
229
+ menu.choice "Exit (stage files manually)", :exit
230
+ end
231
+ rescue TTY::Reader::InputInterrupt, Interrupt
232
+ :exit
233
+ end
234
+ end
235
+
236
+ def prompt_diff_handling(current_len, max_len)
237
+ puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
238
+ prompt = TTY::Prompt.new
239
+ begin
240
+ prompt.select("Choose an option:") do |menu|
241
+ menu.choice "Use first #{max_len} characters to generate commit message", :truncate
242
+ menu.choice "Use unlimited characters (may fail or be slow)", :unlimited
243
+ menu.choice "Exit", :exit
244
+ end
245
+ rescue TTY::Reader::InputInterrupt, Interrupt
246
+ :exit
247
+ end
248
+ end
249
+
63
250
  def welcome
64
251
  puts "\n▲ Welcome to AI Commits!".green
65
252
 
66
- if OPENAI_API_KEY.nil?
67
- puts "▲ Please save your OpenAI API key as an env variable by doing 'export OPENAI_API_KEY=YOUR_API_KEY'".red
253
+ # Check if config exists
254
+ unless ConfigManager.config_exists?
255
+ puts "▲ Configuration not found. Generating default config...".yellow
256
+ ConfigManager.generate_default_configs
257
+ puts "▲ Please run 'aicm setup' to configure your provider.".red
258
+ return false
259
+ end
260
+
261
+ # Check if active provider is configured
262
+ if @api_key.nil? || @api_key.empty?
263
+ puts "▲ No active provider configured. Please run 'aicm setup'.".red
264
+ return false
265
+ end
266
+
267
+ if @model.nil? || @model.empty?
268
+ puts "▲ No model selected. Please run 'aicm setup'.".red
68
269
  return false
69
270
  end
70
271
 
@@ -78,27 +279,217 @@ module CommitGpt
78
279
  true
79
280
  end
80
281
 
81
- def generate_commit(prompt = "")
282
+ def generate_commit(diff = "")
283
+ messages = [
284
+ {
285
+ role: "system",
286
+ content: "Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body. " \
287
+ "Message language: English. Rules:\n" \
288
+ "- Commit message must be a maximum of 100 characters.\n" \
289
+ "- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
290
+ "- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text. \n" \
291
+ "- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
292
+ "- Return ONLY the commit message, nothing else."
293
+ },
294
+ {
295
+ role: "user",
296
+ content: "Generate a commit message for the following git diff:\n\n#{diff}"
297
+ }
298
+ ]
299
+
300
+ # Check config for disable_reasoning support (default true if not set)
301
+ provider_config = ConfigManager.get_active_provider_config
302
+ can_disable_reasoning = provider_config.key?("can_disable_reasoning") ? provider_config["can_disable_reasoning"] : true
303
+ # Get configured max_tokens or default to 2000
304
+ configured_max_tokens = provider_config["max_tokens"] || 2000
305
+
82
306
  payload = {
83
- model: "text-davinci-003", prompt: prompt, temperature: 0.7, top_p: 1,
84
- frequency_penalty: 0, presence_penalty: 0, max_tokens: 200, stream: false, n: 1
307
+ model: @model,
308
+ messages: messages,
309
+ temperature: 0.5,
310
+ stream: true
85
311
  }
86
312
 
313
+ if can_disable_reasoning
314
+ payload[:disable_reasoning] = true
315
+ payload[:max_tokens] = 300
316
+ else
317
+ payload[:max_tokens] = configured_max_tokens
318
+ end
319
+
320
+ # Initial UI feedback (only on first try)
321
+ puts "....... Generating your AI commit message ......".gray unless defined?(@is_retrying) && @is_retrying
322
+
323
+ full_content = ""
324
+ full_reasoning = ""
325
+ printed_reasoning = false
326
+ printed_content_prefix = false
327
+ stop_stream = false
328
+
329
+ uri = URI("#{@base_url}/chat/completions")
330
+ http = Net::HTTP.new(uri.host, uri.port)
331
+ http.use_ssl = (uri.scheme == "https")
332
+ http.read_timeout = 120
333
+
334
+ request = Net::HTTP::Post.new(uri)
335
+ request["Content-Type"] = "application/json"
336
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
337
+ request.body = payload.to_json
338
+
87
339
  begin
88
- response = HTTParty.post("https://api.openai.com/v1/completions",
89
- headers: { "Authorization" => "Bearer #{OPENAI_API_KEY}",
90
- "Content-Type" => "application/json", "User-Agent" => "Ruby/#{RUBY_VERSION}" },
91
- body: payload.to_json)
340
+ http.request(request) do |response|
341
+ if response.code != "200"
342
+ # Parse error body
343
+ error_body = response.read_body
344
+ result = JSON.parse(error_body) rescue nil
345
+
346
+ error_msg = if result
347
+ result.dig("error", "message") || result["error"] || result["message"]
348
+ else
349
+ error_body
350
+ end
92
351
 
93
- puts "#{response.inspect}\n"
352
+ if error_msg.nil? || error_msg.to_s.strip.empty?
353
+ error_msg = "HTTP #{response.code}"
354
+ error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
355
+ end
356
+
357
+ if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == "400")
358
+ puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
359
+ ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
360
+ @is_retrying = true
361
+ return generate_commit(diff)
362
+ else
363
+ puts "▲ API Error: #{error_msg}".red
364
+ return nil
365
+ end
366
+ end
367
+
368
+ # Process Streaming Response
369
+ buffer = ""
370
+ response.read_body do |chunk|
371
+ break if stop_stream
94
372
 
95
- ai_commit = response["choices"][0]["text"]
96
- rescue StandardError
97
- puts "▲ There was an error with the OpenAI API. Please try again later.".red
373
+ buffer += chunk
374
+ while (line_end = buffer.index("\n"))
375
+ line = buffer.slice!(0, line_end + 1).strip
376
+ next if line.empty?
377
+ next unless line.start_with?("data: ")
378
+
379
+ data_str = line[6..-1]
380
+ next if data_str == "[DONE]"
381
+
382
+ begin
383
+ data = JSON.parse(data_str)
384
+ delta = data.dig("choices", 0, "delta")
385
+ next unless delta
386
+
387
+ # Handle Reasoning
388
+ reasoning_chunk = delta["reasoning_content"] || delta["reasoning"]
389
+ if reasoning_chunk && !reasoning_chunk.empty?
390
+ unless printed_reasoning
391
+ puts "\nThinking...".gray
392
+ end
393
+ print reasoning_chunk.gray
394
+ full_reasoning += reasoning_chunk
395
+ printed_reasoning = true
396
+ $stdout.flush
397
+ end
398
+
399
+ # Handle Content
400
+ content_chunk = delta["content"]
401
+ if content_chunk && !content_chunk.empty?
402
+ if printed_reasoning && !printed_content_prefix
403
+ puts "" # Newline after reasoning block
404
+ end
405
+
406
+ unless printed_content_prefix
407
+ print "\n▲ Commit message: git commit -am \"".green
408
+ printed_content_prefix = true
409
+ end
410
+
411
+ # Prevent infinite loops/repetitive garbage
412
+ if full_content.length + content_chunk.length > 300
413
+ stop_stream = true
414
+ break
415
+ end
416
+
417
+ print content_chunk.green
418
+ full_content += content_chunk
419
+ $stdout.flush
420
+ end
421
+
422
+ # Handle Usage (some providers send usage at the end)
423
+ if data["usage"]
424
+ @last_usage = data["usage"]
425
+ end
426
+
427
+ rescue JSON::ParserError
428
+ # Partial JSON, wait for more data
429
+ end
430
+ end
431
+ end
432
+ end
433
+ rescue StandardError => e
434
+ puts "▲ Error: #{e.message}".red
98
435
  return nil
99
436
  end
437
+
438
+ # Close the quote
439
+ puts "\"".green if printed_content_prefix
440
+
441
+ # Post-processing Logic (Retry if empty content)
442
+ if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
443
+ if can_disable_reasoning
444
+ puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
445
+ ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
446
+ @is_retrying = true
447
+ return generate_commit(diff)
448
+ else
449
+ puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
450
+ prompt = TTY::Prompt.new
451
+ choice = prompt.select("Choose an action:") do |menu|
452
+ menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
453
+ menu.choice "Set custom max_tokens...", :custom
454
+ menu.choice "Abort", :abort
455
+ end
456
+
457
+ new_max = case choice
458
+ when :double
459
+ configured_max_tokens * 2
460
+ when :custom
461
+ prompt.ask("Enter new max_tokens:", convert: :int)
462
+ when :abort
463
+ return nil
464
+ end
465
+
466
+ if new_max
467
+ puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
468
+ ConfigManager.update_provider(provider_config["name"], { "max_tokens" => new_max })
469
+ @is_retrying = true
470
+ return generate_commit(diff)
471
+ end
472
+ return nil
473
+ end
474
+ end
475
+
476
+ if full_content.empty? && full_reasoning.empty?
477
+ puts "▲ No response from AI.".red
478
+ return nil
479
+ end
480
+
481
+ # Print usage info if available (saved from stream or approximated)
482
+ if defined?(@last_usage) && @last_usage
483
+ puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
484
+ @last_usage = nil
485
+ end
486
+
487
+ # Reset retrying flag
488
+ @is_retrying = false
100
489
 
101
- ai_commit.gsub(/(\r\n|\n|\r)/, "")
490
+ # Take only the first non-empty line to avoid repetition or multi-line garbage
491
+ first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
492
+ first_line&.gsub(/\A["']|["']\z/, "") || ""
102
493
  end
103
494
  end
104
495
  end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "provider_presets"
6
+ require_relative "string"
7
+
8
+ module CommitGpt
9
+ # Manages configuration files for CommitGPT
10
+ class ConfigManager
11
+ class << self
12
+ # Get the config directory path
13
+ def config_dir
14
+ File.expand_path("~/.config/commitgpt")
15
+ end
16
+
17
+ # Get main config file path
18
+ def main_config_path
19
+ File.join(config_dir, "config.yml")
20
+ end
21
+
22
+ # Get local config file path
23
+ def local_config_path
24
+ File.join(config_dir, "config.local.yml")
25
+ end
26
+
27
+ # Ensure config directory exists
28
+ def ensure_config_dir
29
+ FileUtils.mkdir_p(config_dir) unless Dir.exist?(config_dir)
30
+ end
31
+
32
+ # Check if config files exist
33
+ def config_exists?
34
+ File.exist?(main_config_path)
35
+ end
36
+
37
+ # Load and merge configuration files
38
+ def load_config
39
+ return nil unless config_exists?
40
+
41
+ main_config = YAML.load_file(main_config_path) || {}
42
+ local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : {}
43
+
44
+ merge_configs(main_config, local_config)
45
+ end
46
+
47
+ # Get active provider configuration
48
+ def get_active_provider_config
49
+ config = load_config
50
+ return nil if config.nil? || config["active_provider"].nil?
51
+
52
+ active_provider = config["active_provider"]
53
+ providers = config["providers"] || []
54
+
55
+ providers.find { |p| p["name"] == active_provider }
56
+ end
57
+
58
+ # Save main config
59
+ def save_main_config(config)
60
+ ensure_config_dir
61
+ File.write(main_config_path, config.to_yaml)
62
+ end
63
+
64
+ # Save local config
65
+ def save_local_config(config)
66
+ ensure_config_dir
67
+ File.write(local_config_path, config.to_yaml)
68
+ end
69
+
70
+ # Generate default configuration files
71
+ def generate_default_configs
72
+ ensure_config_dir
73
+
74
+ # Generate main config with all providers but empty models
75
+ providers = PROVIDER_PRESETS.map do |preset|
76
+ {
77
+ "name" => preset[:value],
78
+ "model" => "",
79
+ "diff_len" => 32768,
80
+ "base_url" => preset[:base_url]
81
+ }
82
+ end
83
+
84
+ main_config = {
85
+ "providers" => providers,
86
+ "active_provider" => ""
87
+ }
88
+
89
+ # Generate local config with empty API keys
90
+ local_providers = PROVIDER_PRESETS.map do |preset|
91
+ {
92
+ "name" => preset[:value],
93
+ "api_key" => ""
94
+ }
95
+ end
96
+
97
+ local_config = {
98
+ "providers" => local_providers
99
+ }
100
+
101
+ save_main_config(main_config)
102
+ save_local_config(local_config)
103
+
104
+ # Remind user to add config.local.yml to .gitignore
105
+ puts "▲ Generated default configuration files.".green
106
+ puts "▲ Remember to add ~/.config/commitgpt/config.local.yml to your .gitignore".yellow
107
+ end
108
+
109
+ # Get list of configured providers (with API keys)
110
+ def configured_providers
111
+ config = load_config
112
+ return [] if config.nil?
113
+
114
+ providers = config["providers"] || []
115
+ providers.select { |p| p["api_key"] && !p["api_key"].empty? }
116
+ end
117
+
118
+ # Update provider configuration
119
+ def update_provider(provider_name, main_attrs = {}, local_attrs = {})
120
+ # Update main config
121
+ main_config = YAML.load_file(main_config_path)
122
+ provider = main_config["providers"].find { |p| p["name"] == provider_name }
123
+ provider&.merge!(main_attrs)
124
+ save_main_config(main_config)
125
+
126
+ # Update local config
127
+ local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : { "providers" => [] }
128
+ local_provider = local_config["providers"].find { |p| p["name"] == provider_name }
129
+ if local_provider
130
+ local_provider.merge!(local_attrs)
131
+ else
132
+ local_config["providers"] << { "name" => provider_name }.merge(local_attrs)
133
+ end
134
+ save_local_config(local_config)
135
+ end
136
+
137
+ # Set active provider
138
+ def set_active_provider(provider_name)
139
+ main_config = YAML.load_file(main_config_path)
140
+ main_config["active_provider"] = provider_name
141
+ save_main_config(main_config)
142
+ end
143
+
144
+ private
145
+
146
+ # Merge main config with local config (local overrides main)
147
+ def merge_configs(main_config, local_config)
148
+ result = main_config.dup
149
+
150
+ # Merge provider-specific settings
151
+ main_providers = main_config["providers"] || []
152
+ local_providers = local_config["providers"] || []
153
+
154
+ merged_providers = main_providers.map do |main_provider|
155
+ local_provider = local_providers.find { |lp| lp["name"] == main_provider["name"] }
156
+ local_provider ? main_provider.merge(local_provider) : main_provider
157
+ end
158
+
159
+ result["providers"] = merged_providers
160
+ result
161
+ end
162
+ end
163
+ end
164
+ end